Simpl-Resultat/src/components/onboarding/OnboardingStepCard.tsx
le king fu 64a07f359f feat(onboarding): visit steps, eight rows, "Got it" and one-time initialization
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>
2026-10-13 21:32:20 -04:00

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}
/>
);
}