mirror of
https://github.com/Hackdex-App/hackdex-website.git
synced 2026-10-07 08:19:05 -05:00
Help new visitors find the download path on hack pages (#83)
Some checks failed
Deploy Supabase Migrations to Production / migrate (push) Has been cancelled
Some checks failed
Deploy Supabase Migrations to Production / migrate (push) Has been cancelled
* Help new visitors find the download path on hack pages. Co-authored-by: Cursor <cursoragent@cursor.com> * Keep the download help tab above the mobile action bar shadow. Co-authored-by: Cursor <cursoragent@cursor.com> * Give the version chip a pointer cursor. Co-authored-by: Cursor <cursoragent@cursor.com> * Make the download help tab a bit larger. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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<HackActionsProps> = ({
|
||||
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<File | null>(null);
|
||||
const [status, setStatus] = React.useState<"idle" | "ready" | "patching" | "done" | "downloading">("idle");
|
||||
const [patchProgress, setPatchProgress] = React.useState<number | null>(null);
|
||||
@@ -85,9 +97,18 @@ const HackActions: React.FC<HackActionsProps> = ({
|
||||
const [romErrorModal, setRomErrorModal] = React.useState<BaseRomErrorModalState | null>(null);
|
||||
const [isVerifyingRom, setIsVerifyingRom] = React.useState(false);
|
||||
const [selectedPatchId, setSelectedPatchId] = React.useState<number | null>(patcherSelector.defaultPatchId);
|
||||
const [versionPickerOpen, setVersionPickerOpen] = React.useState(false);
|
||||
const selectedPatchIdRef = React.useRef(selectedPatchId);
|
||||
const fetchSessionIdRef = React.useRef<string | null | undefined>(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<HackActionsProps> = ({
|
||||
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<HackActionsProps> = ({
|
||||
}
|
||||
|
||||
function onVersionChange(nextPatchId: number) {
|
||||
onboarding.onVersionConfirmed();
|
||||
if (nextPatchId === selectedPatchId) return;
|
||||
selectedPatchIdRef.current = nextPatchId;
|
||||
setSelectedPatchId(nextPatchId);
|
||||
@@ -244,6 +272,7 @@ const HackActions: React.FC<HackActionsProps> = ({
|
||||
await importUploadedBlob(f);
|
||||
setFile(f);
|
||||
setStatus("ready");
|
||||
onboarding.onHackPageRomSupplied();
|
||||
} finally {
|
||||
setIsVerifyingRom(false);
|
||||
}
|
||||
@@ -495,6 +524,7 @@ const HackActions: React.FC<HackActionsProps> = ({
|
||||
await discardSink();
|
||||
return;
|
||||
}
|
||||
onboarding.onHackPageRomSupplied();
|
||||
}
|
||||
const linkedFile = await getFileBlob(baseRomId);
|
||||
if (!linkedFile) {
|
||||
@@ -537,6 +567,7 @@ const HackActions: React.FC<HackActionsProps> = ({
|
||||
}
|
||||
|
||||
setStatus("done");
|
||||
onboarding.onPatchSucceeded();
|
||||
|
||||
// Best-effort log applied event for counting and animate badge
|
||||
try {
|
||||
@@ -580,6 +611,17 @@ const HackActions: React.FC<HackActionsProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<StickyActionBar
|
||||
@@ -588,6 +630,11 @@ const HackActions: React.FC<HackActionsProps> = ({
|
||||
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<HackActionsProps> = ({
|
||||
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 && (
|
||||
<HackOnboardingOverlay
|
||||
step={onboarding.currentStep}
|
||||
locked={onboarding.locked}
|
||||
isLastStep={onboarding.isLastStep}
|
||||
hide={onboarding.hideForPicker}
|
||||
onNext={onboarding.next}
|
||||
onGotIt={onboarding.gotIt}
|
||||
onDismiss={onboarding.dismissCard}
|
||||
baseRomName={baseRomName}
|
||||
/>
|
||||
)}
|
||||
{romErrorModal && (
|
||||
<BaseRomErrorModal
|
||||
state={romErrorModal}
|
||||
|
||||
38
src/components/Hack/Onboarding/HackOnboardingGate.tsx
Normal file
38
src/components/Hack/Onboarding/HackOnboardingGate.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { FiChevronDown, FiHelpCircle } from "react-icons/fi";
|
||||
|
||||
interface HackOnboardingGateProps {
|
||||
label: string;
|
||||
onClick: () => 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 (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="group/gate absolute bottom-full left-1/2 z-10 mb-3 -translate-x-1/2 inline-flex cursor-pointer items-center gap-2 whitespace-nowrap rounded-full border border-[var(--border)] bg-[var(--surface-2)]/80 px-5 py-2.5 text-sm font-semibold text-foreground/80 shadow-sm backdrop-blur transition-colors supports-[backdrop-filter]:bg-[color-mix(in_oklab,var(--background)_90%,transparent)] hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] after:absolute after:-inset-x-5 after:-top-4 after:-bottom-3 after:content-[''] md:after:hidden md:bottom-auto md:top-full md:mb-0 md:-mt-px md:rounded-t-none md:rounded-b-xl md:border-t-0 md:px-6 md:pt-4 md:pb-3 md:shadow-none md:supports-[backdrop-filter]:bg-[color-mix(in_oklab,var(--background)_70%,transparent)]"
|
||||
>
|
||||
<FiHelpCircle
|
||||
size={16}
|
||||
aria-hidden
|
||||
className="shrink-0 text-[var(--accent)]"
|
||||
/>
|
||||
{label}
|
||||
<FiChevronDown
|
||||
size={15}
|
||||
aria-hidden
|
||||
className="shrink-0 text-foreground/60 transition-transform duration-200 group-hover/gate:translate-y-0.5"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
253
src/components/Hack/Onboarding/HackOnboardingOverlay.tsx
Normal file
253
src/components/Hack/Onboarding/HackOnboardingOverlay.tsx
Normal file
@@ -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<HackOnboardingStep, { title: string; body: React.ReactNode }> = {
|
||||
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{" "}
|
||||
<Link
|
||||
href="/terms"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline text-foreground/85 hover:text-foreground"
|
||||
>
|
||||
the Terms
|
||||
</Link>{" "}
|
||||
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<Anchor | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!active) return;
|
||||
const bar = document.querySelector<HTMLElement>("[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<HTMLDivElement | null>(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<HTMLElement>("[data-onboarding-spotlight]:not([disabled])"),
|
||||
);
|
||||
const cardControls = Array.from(
|
||||
card.querySelectorAll<HTMLElement>("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 (
|
||||
<>
|
||||
<div
|
||||
aria-hidden
|
||||
className={`fixed inset-0 z-50 ${
|
||||
locked
|
||||
? "bg-[#171717]/25 dark:bg-black/55"
|
||||
: "pointer-events-none bg-[#171717]/8 dark:bg-black/20"
|
||||
}`}
|
||||
/>
|
||||
{anchor && (
|
||||
<div
|
||||
ref={cardRef}
|
||||
role="dialog"
|
||||
aria-modal={locked || undefined}
|
||||
aria-labelledby={titleId}
|
||||
tabIndex={-1}
|
||||
style={cardPosition(anchor, step)}
|
||||
className="anim-pop elevate fixed z-[80] rounded-xl border border-[var(--border)] bg-white p-3.5 outline-none dark:bg-[#141414]"
|
||||
>
|
||||
{(step === "selectRom" || !isLastStep) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDismiss}
|
||||
aria-label="Close"
|
||||
className="absolute right-2 top-2 cursor-pointer rounded-md p-1 text-foreground/50 transition-colors hover:bg-[var(--surface-2)] hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)]"
|
||||
>
|
||||
<FiX size={14} aria-hidden />
|
||||
</button>
|
||||
)}
|
||||
<h4 id={titleId} className={`text-[13px] font-semibold ${step === "selectRom" || !isLastStep ? "pr-7" : ""}`}>
|
||||
{copy.title}
|
||||
</h4>
|
||||
<p className="mt-1 text-xs leading-relaxed text-foreground/70">{copy.body}</p>
|
||||
{step === "selectRom" ? (
|
||||
<p className="mt-3 text-xs font-semibold text-foreground/75">
|
||||
Select a clean {baseRomName || "base"} ROM to continue.
|
||||
</p>
|
||||
) : (
|
||||
<div className="mt-3 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={isLastStep ? onGotIt : onNext}
|
||||
className="inline-flex h-7 cursor-pointer items-center rounded-full bg-[var(--accent)] px-3 text-xs font-bold text-[var(--accent-foreground)] transition-[background-color,transform] hover:bg-[var(--accent-700)] active:scale-[0.98] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--background)]"
|
||||
>
|
||||
{isLastStep ? "Got it" : "Next"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
2
src/components/Hack/Onboarding/index.ts
Normal file
2
src/components/Hack/Onboarding/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as HackOnboardingGate } from "./HackOnboardingGate";
|
||||
export { default as HackOnboardingOverlay, type HackOnboardingStep } from "./HackOnboardingOverlay";
|
||||
@@ -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 (
|
||||
<div className="fixed inset-x-0 bottom-0 z-40 md:sticky md:top-18 md:z-30 flex flex-col gap-2 pb-safe">
|
||||
<div className="mx-auto w-full lg:max-w-screen-lg flex flex-col md:flex-row md:items-center md:justify-between md:gap-4 rounded-t-xl md:rounded-md border border-[var(--border)] bg-[var(--surface-2)]/80 px-4 py-3 pb-[env(safe-area-inset-bottom)] md:pb-3 shadow-[0_-6px_24px_rgba(0,0,0,0.2)] md:shadow-none backdrop-blur supports-[backdrop-filter]:bg-[color-mix(in_oklab,var(--background)_90%,transparent)] md:supports-[backdrop-filter]:bg-[color-mix(in_oklab,var(--background)_70%,transparent)]">
|
||||
<div
|
||||
className={`fixed inset-x-0 bottom-0 md:sticky md:top-18 flex flex-col gap-2 pb-safe ${
|
||||
// Raised above the coach scrim so the lit control stays reachable.
|
||||
onboardingActive ? "z-[60] md:z-[60]" : "z-40 md:z-30"
|
||||
}`}
|
||||
>
|
||||
{/* The error banner occupies the same edge as the gate, so it wins. */}
|
||||
{onboardingGateLabel && onOnboardingGateClick && errorMessage === null && (
|
||||
<HackOnboardingGate label={onboardingGateLabel} onClick={onOnboardingGateClick} />
|
||||
)}
|
||||
<div data-hack-action-bar className="relative mx-auto w-full lg:max-w-screen-lg flex flex-col md:flex-row md:items-center md:justify-between md:gap-4 rounded-t-xl md:rounded-md border border-[var(--border)] bg-[var(--surface-2)]/80 px-4 py-3 pb-[env(safe-area-inset-bottom)] md:pb-3 shadow-[0_-6px_24px_rgba(0,0,0,0.2)] md:shadow-none backdrop-blur supports-[backdrop-filter]:bg-[color-mix(in_oklab,var(--background)_90%,transparent)] md:supports-[backdrop-filter]:bg-[color-mix(in_oklab,var(--background)_70%,transparent)]">
|
||||
{onboardingDimBar && (
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-0 z-[1] rounded-t-xl md:rounded-md bg-[#171717]/25 dark:bg-black/55"
|
||||
/>
|
||||
)}
|
||||
<div className="md:w-fit md:max-w-[40%] lg:max-w-[45%]">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="truncate text-xl font-bold md:text-sm md:font-medium">{title}</div>
|
||||
@@ -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")}`}
|
||||
>
|
||||
<span className="truncate">{version}</span>
|
||||
{versionPickerOpen ? (
|
||||
@@ -119,6 +172,9 @@ export default function StickyActionBar({
|
||||
) : (
|
||||
<FiChevronDown size={13} className="shrink-0 text-foreground/65" aria-hidden />
|
||||
)}
|
||||
{onboardingBeacon && onboardingHighlight === "version" && (
|
||||
<OnboardingBeacon className="-top-[3px] -right-[3px]" />
|
||||
)}
|
||||
</button>
|
||||
) : version && (
|
||||
<span className="shrink-0 rounded-full bg-[var(--surface-2)] ml-auto md:ml-0 px-2 py-0.5 text-[11px] font-medium text-foreground/85 ring-1 ring-[var(--border)]">{version}</span>
|
||||
@@ -127,7 +183,7 @@ export default function StickyActionBar({
|
||||
<div className="truncate text-sm md:text-xs text-foreground/60">By {author}</div>
|
||||
</div>
|
||||
{hasVersionPicker && versionPickerOpen && (
|
||||
<div className="md:hidden mt-3 mb-4 animate-in fade-in slide-in-from-bottom-2 duration-200">
|
||||
<div className="relative z-[2] md:hidden mt-3 mb-4 animate-in fade-in slide-in-from-bottom-2 duration-200">
|
||||
<div className="mb-2">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-foreground/55">Select version</div>
|
||||
</div>
|
||||
@@ -174,22 +230,30 @@ export default function StickyActionBar({
|
||||
disabled={isVerifyingRom}
|
||||
className="hidden"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => uploadInputRef.current?.click()}
|
||||
disabled={isVerifyingRom}
|
||||
className="shine-wrap btn-premium h-11 md:h-9 w-5/6 mx-auto md:w-auto md:mx-0 md:min-w-34 text-base md:text-sm font-semibold cursor-pointer disabled:cursor-not-allowed disabled:opacity-70"
|
||||
>
|
||||
{isVerifyingRom ? (
|
||||
<span>Verifying…</span>
|
||||
) : baseRomName ? (
|
||||
<span>Select <span className="font-bold">{baseRomName}</span> ROM</span>
|
||||
) : baseRomPlatform ? (
|
||||
<span>Select <span className="font-bold">{baseRomPlatform}</span> ROM</span>
|
||||
) : (
|
||||
<span>Select Base ROM</span>
|
||||
{/* The button clips its own overflow for the shine sweep, so the
|
||||
beacon rides on this wrapper instead. */}
|
||||
<span className="relative inline-flex w-5/6 mx-auto md:w-auto md:mx-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => uploadInputRef.current?.click()}
|
||||
disabled={isVerifyingRom}
|
||||
data-onboarding-spotlight={spotlightAttr("selectRom")}
|
||||
className={`shine-wrap btn-premium h-11 md:h-9 w-full md:w-auto md:min-w-34 text-base md:text-sm font-semibold cursor-pointer disabled:cursor-not-allowed disabled:opacity-70${spotlight("selectRom")}`}
|
||||
>
|
||||
{isVerifyingRom ? (
|
||||
<span>Verifying…</span>
|
||||
) : baseRomName ? (
|
||||
<span>Select <span className="font-bold">{baseRomName}</span> ROM</span>
|
||||
) : baseRomPlatform ? (
|
||||
<span>Select <span className="font-bold">{baseRomPlatform}</span> ROM</span>
|
||||
) : (
|
||||
<span>Select Base ROM</span>
|
||||
)}
|
||||
</button>
|
||||
{onboardingBeacon && onboardingHighlight === "selectRom" && (
|
||||
<OnboardingBeacon large className="-top-[3px] right-[2px]" />
|
||||
)}
|
||||
</button>
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
{!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 large className="-top-[3px] -right-[3px]" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onPatch}
|
||||
{/* Wrapper owns the layout slot so the beacon can escape the button's
|
||||
overflow clip. Hiding it here keeps the flex gap collapsed. */}
|
||||
<span
|
||||
data-ready={romReady}
|
||||
disabled={!mounted || !romReady || (status !== "ready" && status !== "done" && status !== "idle") || !patchAgainReady}
|
||||
className={`shine-wrap btn-premium data-[ready=false]:hidden! h-11 md:h-9 w-full md:min-w-46 ${!termsAgreed || status === 'downloading' ? "md:w-32" : "md:w-auto"} text-base md:text-sm font-semibold cursor-pointer disabled:cursor-not-allowed disabled:opacity-70 ${romReady && status !== 'downloading' && status !== 'ready' && termsAgreed ? "mt-6 md:mt-0" : ""}`}
|
||||
className={`relative inline-flex data-[ready=false]:hidden! w-full md:w-auto ${romReady && status !== 'downloading' && status !== 'ready' && termsAgreed ? "mt-6 md:mt-0" : ""}`}
|
||||
>
|
||||
<span>{
|
||||
status === "patching" ? (
|
||||
patchProgress != null && patchProgress > 0
|
||||
? `Patching… (${(patchProgress / (1024 * 1024)).toFixed(0)} MB)`
|
||||
: "Patching…"
|
||||
) :
|
||||
status === "downloading" ? "Downloading…" :
|
||||
status === "done" ? (
|
||||
patchAgainReady ? "Patch Again" : "Patched"
|
||||
) : termsAgreed ? "Retry Patching" : "Agree and Patch"
|
||||
}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={onPatch}
|
||||
data-onboarding-spotlight={spotlightAttr("agree")}
|
||||
disabled={!mounted || !romReady || (status !== "ready" && status !== "done" && status !== "idle") || !patchAgainReady}
|
||||
className={`shine-wrap btn-premium h-11 md:h-9 w-full md:min-w-46 ${!termsAgreed || status === 'downloading' ? "md:w-32" : "md:w-auto"} text-base md:text-sm font-semibold cursor-pointer disabled:cursor-not-allowed disabled:opacity-70${spotlight("agree")}`}
|
||||
>
|
||||
<span>{
|
||||
status === "patching" ? (
|
||||
patchProgress != null && patchProgress > 0
|
||||
? `Patching… (${(patchProgress / (1024 * 1024)).toFixed(0)} MB)`
|
||||
: "Patching…"
|
||||
) :
|
||||
status === "downloading" ? "Downloading…" :
|
||||
status === "done" ? (
|
||||
patchAgainReady ? "Patch Again" : "Patched"
|
||||
) : termsAgreed ? "Retry Patching" : "Agree and Patch"
|
||||
}</span>
|
||||
</button>
|
||||
{onboardingBeacon && onboardingHighlight === "agree" && (
|
||||
<OnboardingBeacon large className="-top-[3px] right-[2px]" />
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{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 (
|
||||
<span
|
||||
aria-hidden
|
||||
className={`onboarding-beacon ${large ? "onboarding-beacon-large" : ""} pointer-events-none absolute! block overflow-visible ${className}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function VersionRadioList({
|
||||
patches,
|
||||
selectedPatchId,
|
||||
|
||||
148
src/hooks/useHackOnboarding.ts
Normal file
148
src/hooks/useHackOnboarding.ts
Normal file
@@ -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<HackOnboardingState>(DEFAULT_HACK_ONBOARDING_STATE);
|
||||
const [hydrated, setHydrated] = React.useState(false);
|
||||
const [tourSteps, setTourSteps] = React.useState<HackOnboardingStep[]>([]);
|
||||
const [locked, setLocked] = React.useState(false);
|
||||
const previousRomReady = React.useRef<boolean | null>(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,
|
||||
};
|
||||
}
|
||||
74
src/utils/hack-onboarding.ts
Normal file
74
src/utils/hack-onboarding.ts
Normal file
@@ -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<HackOnboardingStep, HackOnboardingStepState>;
|
||||
|
||||
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<string, unknown>;
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user