Two visit steps join the getting-started path: "Browse categories and keywords" (5th) and "Browse the reports" (8th). Nothing in the database counts them: they are done by "Got it" on their card, stored as onboarding_acknowledged_<key> in user_preferences (no migration). - computeChecklist: eight rows; the visit rows are locked until the profile's visit steps are initialized, so a finished path does not flash a "Next step" before they are marked done. - initializeVisitSteps: once per profile, after the first successful load with the Budget entitlement known; acknowledges both steps when every other step is done or skipped, then sets the flag. No transaction (D16): idempotent writes, the flag last. - Reducer: ACKNOWLEDGE wins over an older snapshot like SKIP but keeps the card latch, so the card says "Done." and offers the next step. - StepCard: "Got it" before "Skip this step" when onAcknowledge is given; OnboardingStepCard passes it for a visit step. - Banner counter, card rank and end of path wait for budgetReady && visitStepsInitialized; the list shows the visit rows "pending" until then. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
78 lines
3.2 KiB
TypeScript
78 lines
3.2 KiB
TypeScript
import { useEffect, useRef } from "react";
|
|
import { useLocation, useNavigate } from "react-router-dom";
|
|
import StepCard from "./StepCard";
|
|
import { useOnboarding } from "../../contexts/OnboardingContext";
|
|
import {
|
|
cardNext,
|
|
cardStep,
|
|
readOnboardingNavState,
|
|
showsStepCard,
|
|
type StepCardModel,
|
|
} from "../../utils/onboardingPath";
|
|
import { VISIT_STEP_KEYS } from "../../shared/types";
|
|
|
|
export interface OnboardingStepCardProps {
|
|
/** The page's card model; null for no card yet (the page's own data is not read). */
|
|
model: StepCardModel | null;
|
|
}
|
|
|
|
/**
|
|
* The step card of a page (#398), connected to the getting-started path. Each
|
|
* page that carries a card renders it as its first child, before its header,
|
|
* and passes only its model; this component reads `useOnboarding()`, the only
|
|
* one of the card's pieces to do so.
|
|
*
|
|
* - It renders only in card mode, latched on the current route: the latch,
|
|
* not the next step, keeps it on screen once the page's steps are done.
|
|
* - After an arrival by "Next step" (`readOnboardingNavState(location.state)
|
|
* .focus`), it moves the focus to its title once the card is on screen,
|
|
* then consumes the navigation state, once per arrival: StrictMode runs the
|
|
* effect twice on mount, a guard on the location key keeps it to one focus
|
|
* and one replace. An ordinary visit carries no state and does nothing.
|
|
* - It wires "Skip this step" to the step it shows, "Got it" to
|
|
* `acknowledge` when that step is a visit step (#425), "Close" to `hide`,
|
|
* and the next step's link to the path's next step.
|
|
* - Its rank "Step n of N" and its end wait for the Budget entitlement and
|
|
* the visit steps' initialization (#425): N depends on both.
|
|
*/
|
|
export default function OnboardingStepCard({ model }: OnboardingStepCardProps) {
|
|
const onboarding = useOnboarding();
|
|
const location = useLocation();
|
|
const navigate = useNavigate();
|
|
const titleRef = useRef<HTMLHeadingElement>(null);
|
|
const focusedFor = useRef<string | null>(null);
|
|
|
|
const { pathname } = location;
|
|
const current = onboarding.checklist ? cardStep(onboarding.checklist, pathname) : null;
|
|
const shown =
|
|
model !== null &&
|
|
current !== null &&
|
|
showsStepCard(onboarding.mode, onboarding.cardPage, pathname);
|
|
const focusRequested = readOnboardingNavState(location.state).focus;
|
|
|
|
useEffect(() => {
|
|
if (!shown || !focusRequested || focusedFor.current === location.key) return;
|
|
focusedFor.current = location.key;
|
|
titleRef.current?.focus();
|
|
navigate(pathname, { replace: true, state: null });
|
|
}, [shown, focusRequested, location.key, pathname, navigate]);
|
|
|
|
if (!shown) return null;
|
|
|
|
const { skip, acknowledge, hide } = onboarding;
|
|
const totalKnown = onboarding.budgetReady && onboarding.visitStepsInitialized;
|
|
const isVisitStep = VISIT_STEP_KEYS.includes(current.step.key);
|
|
return (
|
|
<StepCard
|
|
step={current.step}
|
|
position={totalKnown ? { n: current.n, total: current.total } : null}
|
|
text={model.text}
|
|
done={model.done}
|
|
next={cardNext(onboarding.nextStep, pathname, totalKnown)}
|
|
onSkip={() => void skip(current.step.key)}
|
|
onAcknowledge={isVisitStep ? () => void acknowledge(current.step.key) : undefined}
|
|
onHide={() => void hide()}
|
|
titleRef={titleRef}
|
|
/>
|
|
);
|
|
}
|