diff --git a/src/app/globals.css b/src/app/globals.css index 95cb9de..1068158 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -75,8 +75,11 @@ body { font-family: Arial, Helvetica, sans-serif; } -/* Smooth scrolling and anchor offset for sticky header */ -html { scroll-behavior: smooth; } +/* Smooth scrolling and stable layout when overlays lock page scrolling. */ +html { + scroll-behavior: smooth; + scrollbar-gutter: stable; +} :root { --anchor-offset: 38px; } @media (min-width: 768px) { :root { --anchor-offset: 72px; } } /* Modern browsers honor scroll-padding-top on the scroll container */ @@ -174,6 +177,55 @@ html { scroll-padding-top: var(--anchor-offset); } .anim-pulse { animation: pulseSoft 2.4s ease-in-out infinite; } .anim-pop { animation: popIn 220ms cubic-bezier(0.16, 1, 0.3, 1); } +/* Onboarding beacon: rose dot ringed against the page, breathing halo behind it. + The halo's static look is the reduced-motion resting state. */ +@keyframes onboardingBeaconScale { + /* 10px beacon / 26px halo: begin at the beacon's outer edge. */ + 0% { transform: scale(0.385); } + 64%, 100% { transform: scale(1.4); } +} +@keyframes onboardingBeaconFade { + 0%, 34% { opacity: 0.55; } + 64%, 100% { opacity: 0; } +} +@keyframes onboardingBeaconFadeDark { + 0% { opacity: 1; } + 64%, 100% { opacity: 0; } +} +.onboarding-beacon { + position: absolute; + display: block; + z-index: 3; + width: 10px; + height: 10px; + border-radius: 50%; + background: #f43f5e; + outline: 2px solid var(--background); +} +.onboarding-beacon-large { + width: 12px; + height: 12px; +} +.onboarding-beacon::after { + content: ""; + position: absolute; + inset: -8px; + border-radius: 50%; + background: rgba(244, 63, 94, 0.45); + transform-origin: center; + animation: + onboardingBeaconScale 1.5s cubic-bezier(0, 0, 0.2, 1) infinite, + onboardingBeaconFade 1.5s linear infinite; +} +@media (prefers-color-scheme: dark) { + .onboarding-beacon::after { + background: rgba(244, 63, 94, 0.95); + animation: + onboardingBeaconScale 1.5s cubic-bezier(0, 0, 0.2, 1) infinite, + onboardingBeaconFadeDark 1.5s linear infinite; + } +} + @keyframes heroConnectorDash { to { stroke-dashoffset: -24; } } @@ -187,6 +239,7 @@ html { scroll-padding-top: var(--anchor-offset); } .anim-float, .anim-pulse, .anim-pop, + .onboarding-beacon::after, .hero-connector-path, .hero-hack-image { animation: none !important; diff --git a/src/components/Hack/HackActions.tsx b/src/components/Hack/HackActions.tsx index 37ce560..01b786a 100644 --- a/src/components/Hack/HackActions.tsx +++ b/src/components/Hack/HackActions.tsx @@ -3,7 +3,9 @@ import React from "react"; import StickyActionBar from "@/components/Hack/StickyActionBar"; import BaseRomErrorModal, { type BaseRomErrorModalState } from "@/components/Hack/BaseRomErrorModal"; +import HackOnboardingOverlay from "@/components/Hack/Onboarding/HackOnboardingOverlay"; import { useBaseRoms } from "@/contexts/BaseRomContext"; +import { useHackOnboarding } from "@/hooks/useHackOnboarding"; import { baseRoms } from "@/data/baseRoms"; import type { DownloadEventDetail } from "@/types/util"; import { getSignedPatchUrl, updatePatchDownloadCount, reportPatchDownloadEvent } from "@/app/hack/[slug]/actions"; @@ -73,7 +75,17 @@ const HackActions: React.FC = ({ hackSlug, patcherSelector, }) => { - const { isLinked, hasPermission, hasCached, importUploadedBlob, ensurePermission, linkRom, getFileBlob, supported } = useBaseRoms(); + const { + isLinked, + hasPermission, + hasCached, + importUploadedBlob, + ensurePermission, + linkRom, + getFileBlob, + supported, + loading: baseRomsLoading, + } = useBaseRoms(); const [file, setFile] = React.useState(null); const [status, setStatus] = React.useState<"idle" | "ready" | "patching" | "done" | "downloading">("idle"); const [patchProgress, setPatchProgress] = React.useState(null); @@ -85,9 +97,18 @@ const HackActions: React.FC = ({ const [romErrorModal, setRomErrorModal] = React.useState(null); const [isVerifyingRom, setIsVerifyingRom] = React.useState(false); const [selectedPatchId, setSelectedPatchId] = React.useState(patcherSelector.defaultPatchId); + const [versionPickerOpen, setVersionPickerOpen] = React.useState(false); const selectedPatchIdRef = React.useRef(selectedPatchId); const fetchSessionIdRef = React.useRef(undefined); + const linkInteractionPendingRef = React.useRef(false); selectedPatchIdRef.current = selectedPatchId; + const romReady = hasPermission(baseRomId) || hasCached(baseRomId); + const onboarding = useHackOnboarding({ + hasVersionPicker: patcherSelector.selectablePatches.length > 1, + romReady, + romReadyKnown: !baseRomsLoading, + versionPickerOpen, + }); function getFetchSessionId(): string | null { if (fetchSessionIdRef.current !== undefined) { @@ -119,6 +140,12 @@ const HackActions: React.FC = ({ setSelectedPatchId(patcherSelector.defaultPatchId); }, [patcherSelector.defaultPatchId]); + React.useEffect(() => { + if (!romReady || !linkInteractionPendingRef.current) return; + linkInteractionPendingRef.current = false; + onboarding.onHackPageRomSupplied(); + }, [romReady, onboarding.onHackPageRomSupplied]); + function resetPatchSession() { setTermsAgreed(false); setPatchUrl(null); @@ -128,6 +155,7 @@ const HackActions: React.FC = ({ } function onVersionChange(nextPatchId: number) { + onboarding.onVersionConfirmed(); if (nextPatchId === selectedPatchId) return; selectedPatchIdRef.current = nextPatchId; setSelectedPatchId(nextPatchId); @@ -244,6 +272,7 @@ const HackActions: React.FC = ({ await importUploadedBlob(f); setFile(f); setStatus("ready"); + onboarding.onHackPageRomSupplied(); } finally { setIsVerifyingRom(false); } @@ -495,6 +524,7 @@ const HackActions: React.FC = ({ await discardSink(); return; } + onboarding.onHackPageRomSupplied(); } const linkedFile = await getFileBlob(baseRomId); if (!linkedFile) { @@ -537,6 +567,7 @@ const HackActions: React.FC = ({ } setStatus("done"); + onboarding.onPatchSucceeded(); // Best-effort log applied event for counting and animate badge try { @@ -580,6 +611,17 @@ const HackActions: React.FC = ({ } } + async function onClickLink() { + if (isLinked(baseRomId)) { + const permission = await ensurePermission(baseRomId, true); + if (permission === "granted") onboarding.onHackPageRomSupplied(); + return; + } + + linkInteractionPendingRef.current = true; + await linkRom(baseRomId); + } + return ( <> = ({ selectablePatches={patcherSelector.selectablePatches} selectedPatchId={selectedPatch?.id ?? selectedPatchId} onVersionChange={onVersionChange} + onVersionPickerOpenChange={setVersionPickerOpen} + onboardingHighlight={onboarding.cardOpen ? onboarding.currentStep : null} + onboardingDimBar={onboarding.cardOpen && onboarding.locked && !onboarding.hideForPicker} + onboardingGateLabel={onboarding.showGate ? onboarding.gateLabel : null} + onOnboardingGateClick={onboarding.openFromGate} author={author} filename={selectedFilename} baseRomName={baseRomName} @@ -596,14 +643,26 @@ const HackActions: React.FC = ({ status={status} error={error} isLinked={isLinked(baseRomId)} - romReady={hasPermission(baseRomId) || hasCached(baseRomId)} - onClickLink={() => (isLinked(baseRomId) ? ensurePermission(baseRomId, true) : linkRom(baseRomId))} + romReady={romReady} + onClickLink={onClickLink} supported={supported} onUploadChange={onSelectFile} termsAgreed={termsAgreed} isVerifyingRom={isVerifyingRom} patchProgress={patchProgress} /> + {onboarding.cardOpen && onboarding.currentStep && ( + + )} {romErrorModal && ( void; +} + +/** + * Opt-in entry point for the hack page onboarding tour. + * + * Positions itself against the StickyActionBar root, so it must be rendered + * inside that root: an attached tab under the desktop bar (1px overlap, no top + * border) and a detached frost pill floating above the mobile sheet. + */ +export default function HackOnboardingGate({ label, onClick }: HackOnboardingGateProps) { + return ( + + ); +} diff --git a/src/components/Hack/Onboarding/HackOnboardingOverlay.tsx b/src/components/Hack/Onboarding/HackOnboardingOverlay.tsx new file mode 100644 index 0000000..291b81b --- /dev/null +++ b/src/components/Hack/Onboarding/HackOnboardingOverlay.tsx @@ -0,0 +1,253 @@ +"use client"; + +import React from "react"; +import Link from "next/link"; +import { FiX } from "react-icons/fi"; + +export type HackOnboardingStep = "version" | "selectRom" | "agree"; + +interface HackOnboardingOverlayProps { + step: HackOnboardingStep; + /** Tour opened from the gate traps focus and locks scroll. Passive nudges do not. */ + locked: boolean; + isLastStep: boolean; + onNext: () => void; + onGotIt: () => void; + /** Closes the card without marking steps dismissed. Fired by × and Escape. */ + onDismiss: () => void; + /** Set while the version picker owns the screen. */ + hide: boolean; + /** Shown in the Select ROM wait copy, e.g. "Pokemon Emerald". */ + baseRomName?: string | null; +} + +const STEP_COPY: Record = { + version: { + title: "Choose your version", + body: "This hack has more than one release. Pick the version you want here before you patch.", + }, + selectRom: { + title: "The key to your download", + body: "Hackdex does not host ROM files. You supply a clean base ROM for patching. It will be cached in your browser so you can keep using it later for other hacks.", + }, + agree: { + title: "One last step", + body: ( + <> + Excellent! Now press "Agree and Patch" to accept{" "} + + the Terms + {" "} + and build the patched ROM in your browser so you can start playing. + + ), + }, +}; + +interface Anchor { + barTop: number; + barBottom: number; + barLeft: number; + barRight: number; + viewportWidth: number; + viewportHeight: number; + desktop: boolean; +} + +function sameAnchor(a: Anchor, b: Anchor) { + return ( + a.barTop === b.barTop && + a.barBottom === b.barBottom && + a.barLeft === b.barLeft && + a.barRight === b.barRight && + a.viewportWidth === b.viewportWidth && + a.viewportHeight === b.viewportHeight && + a.desktop === b.desktop + ); +} + +/** + * Tracks the live geometry of the sticky action bar so the coach card can sit + * under it on desktop and above the sheet on mobile, whatever height it has. + */ +function useBarAnchor(active: boolean): Anchor | null { + const [anchor, setAnchor] = React.useState(null); + + React.useEffect(() => { + if (!active) return; + const bar = document.querySelector("[data-hack-action-bar]"); + if (!bar) return; + + const measure = () => { + const rect = bar.getBoundingClientRect(); + const next: Anchor = { + barTop: rect.top, + barBottom: rect.bottom, + barLeft: rect.left, + barRight: rect.right, + viewportWidth: window.innerWidth, + viewportHeight: window.innerHeight, + desktop: window.matchMedia("(min-width: 768px)").matches, + }; + setAnchor((previous) => (previous && sameAnchor(previous, next) ? previous : next)); + }; + + measure(); + const observer = new ResizeObserver(measure); + observer.observe(bar); + window.addEventListener("scroll", measure, { passive: true }); + window.addEventListener("resize", measure); + return () => { + observer.disconnect(); + window.removeEventListener("scroll", measure); + window.removeEventListener("resize", measure); + }; + }, [active]); + + return anchor; +} + +function cardPosition(anchor: Anchor, step: HackOnboardingStep): React.CSSProperties { + if (!anchor.desktop) { + return { left: 16, right: 16, bottom: anchor.viewportHeight - anchor.barTop + 12 }; + } + return { + top: anchor.barBottom + 14, + width: 280, + // Step 1 points back at the version chip; the ROM steps follow the CTA. + ...(step === "version" + ? { left: anchor.barLeft } + : { right: anchor.viewportWidth - anchor.barRight }), + }; +} + +/** + * Coach mark for the hack page onboarding. The dimmed bar, lit control and rose + * beacon are drawn by StickyActionBar; this owns the scrim and the card. + */ +export default function HackOnboardingOverlay({ + step, + locked, + isLastStep, + onNext, + onGotIt, + onDismiss, + hide, + baseRomName, +}: HackOnboardingOverlayProps) { + const cardRef = React.useRef(null); + const titleId = React.useId(); + const anchor = useBarAnchor(!hide); + const copy = STEP_COPY[step]; + + React.useEffect(() => { + if (hide) return; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") onDismiss(); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [hide, onDismiss]); + + React.useEffect(() => { + if (hide || !locked) return; + const previous = document.body.style.overflow; + document.body.style.overflow = "hidden"; + return () => { + document.body.style.overflow = previous; + }; + }, [hide, locked]); + + // The card only mounts once the bar has been measured, so wait for it. + const cardMounted = anchor !== null; + React.useEffect(() => { + if (hide || !locked || !cardMounted) return; + const card = cardRef.current; + if (!card) return; + card.focus(); + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Tab") return; + const spotlight = Array.from( + document.querySelectorAll("[data-onboarding-spotlight]:not([disabled])"), + ); + const cardControls = Array.from( + card.querySelectorAll("button:not([disabled]), a[href]"), + ); + const focusable = [...spotlight, ...cardControls]; + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (!first) return; + const active = document.activeElement; + const inCycle = active instanceof HTMLElement && focusable.includes(active); + if (active === (event.shiftKey ? first : last) || !inCycle) { + event.preventDefault(); + (event.shiftKey ? last : first).focus(); + } + }; + + document.addEventListener("keydown", onKeyDown, true); + return () => document.removeEventListener("keydown", onKeyDown, true); + }, [hide, locked, cardMounted]); + + if (hide) return null; + + return ( + <> +
+ {anchor && ( +
+ {(step === "selectRom" || !isLastStep) && ( + + )} +

+ {copy.title} +

+

{copy.body}

+ {step === "selectRom" ? ( +

+ Select a clean {baseRomName || "base"} ROM to continue. +

+ ) : ( +
+ +
+ )} +
+ )} + + ); +} diff --git a/src/components/Hack/Onboarding/index.ts b/src/components/Hack/Onboarding/index.ts new file mode 100644 index 0000000..c427734 --- /dev/null +++ b/src/components/Hack/Onboarding/index.ts @@ -0,0 +1,2 @@ +export { default as HackOnboardingGate } from "./HackOnboardingGate"; +export { default as HackOnboardingOverlay, type HackOnboardingStep } from "./HackOnboardingOverlay"; diff --git a/src/components/Hack/StickyActionBar.tsx b/src/components/Hack/StickyActionBar.tsx index 26d29e2..11afbac 100644 --- a/src/components/Hack/StickyActionBar.tsx +++ b/src/components/Hack/StickyActionBar.tsx @@ -6,6 +6,9 @@ import { FiChevronDown, FiX } from "react-icons/fi"; import { platformAccept } from "@/utils/idb"; import { useBaseRoms } from "@/contexts/BaseRomContext"; import type { Platform } from "@/data/baseRoms"; +import HackOnboardingGate from "@/components/Hack/Onboarding/HackOnboardingGate"; + +type OnboardingTarget = "version" | "selectRom" | "agree"; interface StickyActionBarProps { title: string; @@ -28,6 +31,15 @@ interface StickyActionBarProps { termsAgreed: boolean; isVerifyingRom?: boolean; patchProgress?: number | null; + onVersionPickerOpenChange?: (open: boolean) => void; + /** Control the onboarding is pointing at. Dims the bar around it and lights it up. */ + onboardingHighlight?: OnboardingTarget | null; + onboardingDimBar?: boolean; + /** The beacon rides along with `onboardingHighlight`; set false to suppress it. */ + onboardingBeacon?: boolean; + /** Renders the onboarding opt-in gate when both gate props are supplied. */ + onboardingGateLabel?: string | null; + onOnboardingGateClick?: () => void; } export default function StickyActionBar({ @@ -51,6 +63,12 @@ export default function StickyActionBar({ termsAgreed, isVerifyingRom = false, patchProgress = null, + onVersionPickerOpenChange, + onboardingHighlight = null, + onboardingDimBar = false, + onboardingBeacon = true, + onboardingGateLabel = null, + onOnboardingGateClick, }: StickyActionBarProps) { const [mounted, setMounted] = React.useState(false); React.useEffect(() => setMounted(true), []); @@ -61,6 +79,25 @@ export default function StickyActionBar({ const [patchAgainReady, setPatchAgainReady] = React.useState(true); const [versionPickerOpen, setVersionPickerOpen] = React.useState(false); const hasVersionPicker = selectablePatches.length > 1 && !!onVersionChange; + const onboardingActive = onboardingDimBar || onboardingHighlight !== null; + + // Reported through a ref so an inline parent callback cannot loop the effect. + const versionPickerOpenChange = React.useRef(onVersionPickerOpenChange); + React.useEffect(() => { + versionPickerOpenChange.current = onVersionPickerOpenChange; + }, [onVersionPickerOpenChange]); + React.useEffect(() => { + versionPickerOpenChange.current?.(versionPickerOpen); + }, [versionPickerOpen]); + + // Lifts the target above the dim layer and rings it with the spotlight. + const isSpotlight = (target: OnboardingTarget) => onboardingHighlight === target; + const spotlight = (target: OnboardingTarget) => + isSpotlight(target) + ? " relative z-[2] outline-[3px] outline-offset-2 outline-[color-mix(in_oklab,var(--accent)_45%,transparent)]" + : ""; + const spotlightAttr = (target: OnboardingTarget) => + isSpotlight(target) || undefined; // Keep error mounted to allow fade-out when error becomes null React.useEffect(() => { @@ -99,8 +136,23 @@ export default function StickyActionBar({ }; return ( -
-
+
+ {/* The error banner occupies the same edge as the gate, so it wins. */} + {onboardingGateLabel && onOnboardingGateClick && errorMessage === null && ( + + )} +
+ {onboardingDimBar && ( +
+ )}
{title}
@@ -110,8 +162,9 @@ export default function StickyActionBar({ aria-label="Patch version" aria-haspopup="dialog" aria-expanded={versionPickerOpen} + data-onboarding-spotlight={spotlightAttr("version")} onClick={() => setVersionPickerOpen((open) => !open)} - className="shrink-0 max-w-44 ml-auto md:ml-0 inline-flex items-center gap-1.5 rounded-full border border-[var(--border)] bg-[var(--surface-2)] px-2.5 py-1 text-[11px] font-semibold text-foreground/90 shadow-sm focus:outline-none md:hover:bg-[var(--surface-3)] md:focus:ring-2 md:focus:ring-[var(--accent)]" + className={`relative shrink-0 max-w-44 ml-auto md:ml-0 inline-flex items-center gap-1.5 rounded-full border border-[var(--border)] bg-[var(--surface-2)] px-2.5 py-1 text-[11px] font-semibold text-foreground/90 shadow-sm cursor-pointer focus:outline-none md:hover:bg-[var(--surface-3)] md:focus:ring-2 md:focus:ring-[var(--accent)]${spotlight("version")}`} > {version} {versionPickerOpen ? ( @@ -119,6 +172,9 @@ export default function StickyActionBar({ ) : ( )} + {onboardingBeacon && onboardingHighlight === "version" && ( + + )} ) : version && ( {version} @@ -127,7 +183,7 @@ export default function StickyActionBar({
By {author}
{hasVersionPicker && versionPickerOpen && ( -
+
Select version
@@ -174,22 +230,30 @@ export default function StickyActionBar({ disabled={isVerifyingRom} className="hidden" /> - + {onboardingBeacon && onboardingHighlight === "selectRom" && ( + )} - + )} {!baseRomsLoading && !romReady && isLinked && ( @@ -197,29 +261,43 @@ export default function StickyActionBar({ type="button" onClick={onClickLink} disabled={!supported} - className="w-5/6 md:w-auto rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-3 py-2 text-sm md:text-xs cursor-pointer hover:bg-[var(--surface-3)] hover:text-foreground disabled:cursor-not-allowed disabled:opacity-60" + data-onboarding-spotlight={spotlightAttr("selectRom")} + className={`relative w-5/6 md:w-auto rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-3 py-2 text-sm md:text-xs cursor-pointer hover:bg-[var(--surface-3)] hover:text-foreground disabled:cursor-not-allowed disabled:opacity-60${spotlight("selectRom")}`} > Grant permission + {onboardingBeacon && onboardingHighlight === "selectRom" && ( + + )} )} - + + {onboardingBeacon && onboardingHighlight === "agree" && ( + + )} +
{hasVersionPicker && versionPickerOpen && ( @@ -265,6 +343,16 @@ export default function StickyActionBar({ ); } +/** Rose onboarding beacon with a reduced-motion-safe halo. */ +function OnboardingBeacon({ className, large = false }: { className: string; large?: boolean }) { + return ( + + ); +} + function VersionRadioList({ patches, selectedPatchId, diff --git a/src/hooks/useHackOnboarding.ts b/src/hooks/useHackOnboarding.ts new file mode 100644 index 0000000..2b51d71 --- /dev/null +++ b/src/hooks/useHackOnboarding.ts @@ -0,0 +1,148 @@ +"use client"; + +import React from "react"; +import { + DEFAULT_HACK_ONBOARDING_STATE, + getLeftoverHackOnboardingSteps, + readHackOnboardingState, + writeHackOnboardingState, + type HackOnboardingState, + type HackOnboardingStep, + type HackOnboardingStepState, +} from "@/utils/hack-onboarding"; + +interface UseHackOnboardingOptions { + hasVersionPicker: boolean; + romReady: boolean; + romReadyKnown?: boolean; + versionPickerOpen: boolean; +} + +export function useHackOnboarding({ + hasVersionPicker, + romReady, + romReadyKnown = true, + versionPickerOpen, +}: UseHackOnboardingOptions) { + const [steps, setSteps] = React.useState(DEFAULT_HACK_ONBOARDING_STATE); + const [hydrated, setHydrated] = React.useState(false); + const [tourSteps, setTourSteps] = React.useState([]); + const [locked, setLocked] = React.useState(false); + const previousRomReady = React.useRef(null); + + React.useEffect(() => { + setSteps(readHackOnboardingState()); + setHydrated(true); + }, []); + + const updateStep = React.useCallback((step: HackOnboardingStep, value: HackOnboardingStepState) => { + setSteps((current) => { + const next = { ...current, [step]: value }; + writeHackOnboardingState(next); + return next; + }); + }, []); + + const leftoverSteps = React.useMemo( + () => getLeftoverHackOnboardingSteps(steps, { hasVersionPicker, romReady }), + [steps, hasVersionPicker, romReady], + ); + const currentStep = tourSteps[0] ?? null; + const cardOpen = currentStep !== null; + + const closeCard = React.useCallback(() => { + setTourSteps([]); + setLocked(false); + }, []); + + const finishCurrentStep = React.useCallback((value: HackOnboardingStepState) => { + if (!currentStep) return; + updateStep(currentStep, value); + setTourSteps((current) => current.slice(1)); + if (tourSteps.length === 1) setLocked(false); + }, [currentStep, tourSteps.length, updateStep]); + + const openFromGate = React.useCallback(() => { + if (!romReadyKnown || leftoverSteps.length === 0) return; + setTourSteps(leftoverSteps); + setLocked(true); + }, [romReadyKnown, leftoverSteps]); + + const next = React.useCallback(() => { + finishCurrentStep("dismissed"); + }, [finishCurrentStep]); + + const gotIt = React.useCallback(() => { + finishCurrentStep("dismissed"); + }, [finishCurrentStep]); + + const dismissCard = React.useCallback(() => { + closeCard(); + }, [closeCard]); + + const onVersionConfirmed = React.useCallback(() => { + if (!hasVersionPicker) return; + updateStep("version", "passed"); + if (currentStep === "version") { + setTourSteps((current) => current.slice(1)); + if (tourSteps.length === 1) setLocked(false); + } + }, [hasVersionPicker, currentStep, tourSteps.length, updateStep]); + + const onHackPageRomSupplied = React.useCallback(() => { + updateStep("selectRom", "passed"); + }, [updateStep]); + + const onPatchSucceeded = React.useCallback(() => { + updateStep("agree", "passed"); + closeCard(); + }, [updateStep, closeCard]); + + React.useEffect(() => { + if (!romReadyKnown) return; + if (previousRomReady.current === null) { + previousRomReady.current = romReady; + return; + } + + const becameReady = !previousRomReady.current && romReady; + previousRomReady.current = romReady; + if (!hydrated || !becameReady) return; + + // Selecting a ROM always continues to Agree, even if leftover no longer + // lists it (Agree may already be passed or dismissed). + if (locked && tourSteps.length > 0) { + setTourSteps((current) => { + const withoutSelectRom = current.filter((step) => step !== "selectRom"); + return withoutSelectRom.includes("agree") ? withoutSelectRom : [...withoutSelectRom, "agree"]; + }); + return; + } + + if (tourSteps.length === 0) { + setTourSteps(["agree"]); + setLocked(false); + } + }, [hydrated, locked, romReady, romReadyKnown, tourSteps.length]); + + const gateLabel = leftoverSteps.length === 1 && leftoverSteps[0] === "version" + ? "How do I select a version?" + : "How do I download?"; + + return { + showGate: hydrated && romReadyKnown && !cardOpen && leftoverSteps.length > 0, + gateLabel, + cardOpen, + locked, + currentStep, + isLastStep: tourSteps.length === 1, + hideForPicker: cardOpen && versionPickerOpen, + openFromGate, + next, + gotIt, + dismissCard, + onVersionConfirmed, + onHackPageRomSupplied, + onPatchSucceeded, + }; +} diff --git a/src/utils/hack-onboarding.ts b/src/utils/hack-onboarding.ts new file mode 100644 index 0000000..8571170 --- /dev/null +++ b/src/utils/hack-onboarding.ts @@ -0,0 +1,74 @@ +import { getOrCreateDeviceId } from "@/utils/deviceId"; + +export const HACK_ONBOARDING_STEPS = ["version", "selectRom", "agree"] as const; + +export type HackOnboardingStep = (typeof HACK_ONBOARDING_STEPS)[number]; +export type HackOnboardingStepState = "undone" | "passed" | "dismissed"; +export type HackOnboardingState = Record; + +const STORAGE_KEY_PREFIX = "hackOnboarding"; + +export const DEFAULT_HACK_ONBOARDING_STATE: HackOnboardingState = { + version: "undone", + selectRom: "undone", + agree: "undone", +}; + +function storageKey(deviceId: string): string { + return `${STORAGE_KEY_PREFIX}:${deviceId}`; +} + +function isStepState(value: unknown): value is HackOnboardingStepState { + return value === "undone" || value === "passed" || value === "dismissed"; +} + +export function readHackOnboardingState(): HackOnboardingState { + if (typeof localStorage === "undefined") return { ...DEFAULT_HACK_ONBOARDING_STATE }; + + try { + const deviceId = getOrCreateDeviceId(); + if (!deviceId) return { ...DEFAULT_HACK_ONBOARDING_STATE }; + + const stored = JSON.parse(localStorage.getItem(storageKey(deviceId)) ?? "{}") as Record; + return { + version: isStepState(stored.version) ? stored.version : "undone", + selectRom: isStepState(stored.selectRom) ? stored.selectRom : "undone", + agree: isStepState(stored.agree) ? stored.agree : "undone", + }; + } catch { + return { ...DEFAULT_HACK_ONBOARDING_STATE }; + } +} + +export function writeHackOnboardingState(state: HackOnboardingState): void { + if (typeof localStorage === "undefined") return; + + try { + const deviceId = getOrCreateDeviceId(); + if (deviceId) localStorage.setItem(storageKey(deviceId), JSON.stringify(state)); + } catch { + // Onboarding should not interfere with patching when storage is unavailable. + } +} + +interface LeftoverStepsOptions { + hasVersionPicker: boolean; + romReady: boolean; +} + +export function getLeftoverHackOnboardingSteps( + state: HackOnboardingState, + { hasVersionPicker, romReady }: LeftoverStepsOptions, +): HackOnboardingStep[] { + const steps: HackOnboardingStep[] = []; + const selectRomNeeded = !romReady && state.selectRom === "undone"; + + if (hasVersionPicker && state.version === "undone") steps.push("version"); + if (selectRomNeeded) steps.push("selectRom"); + + // A tour that still needs Select always includes Agree, even if Agree was + // already passed or dismissed. Selecting the ROM is the first half of that pair. + if (state.agree === "undone" || selectRomNeeded) steps.push("agree"); + + return steps; +}