mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
feat(onboarding): tour component with spotlight + keyboard navigation
v1.4.15 Phase B5 — second commit. Adds the spotlight-tour overlay
that walks new users through 5 dashboard features after they finish
the wizard at /onboarding:
1. Tile strip (BP / weight / pulse / mood)
2. Quick-add menu
3. Insights (AI-assisted summaries)
4. Settings → Integrations
5. Achievements (omittable via prop if B4 hasn't shipped)
Architecture:
- `src/lib/onboarding/tour-state.ts` — pure state machine. Steps,
navigation helpers (`nextStep`, `prevStep`, `skipTour`),
completion outcome (`"completed" | "skipped"`). DOM-free so the
Node-environment vitest setup can exercise it directly. 13 unit
tests cover step list, navigation, idempotency once finished,
and the 1-based step counter.
- `src/components/onboarding/tour.tsx` — UI layer. In-house
spotlight (no new dependency — package.json had no
react-joyride / intro.js / shepherd already, and the surface
area didn't justify a 30 KB add). Renders a fixed full-viewport
overlay with a polygon `clip-path` cutout aligned to the live
target, plus a tooltip card next to it. Targets are looked up
via the stable `data-tour-id` attribute (NOT class names — the
contract has to survive Tailwind churn). Re-measures on resize
and scroll. Falls back to a centred-screen tooltip if the
target is not in the DOM (mobile breakpoint, conditional
render) so the tour never blocks indefinitely.
- i18n: 19 new keys under `onboarding.tour.*` (EN + DE), one
title + body per stop plus `next`/`back`/`skip`/`done`/
`restart`/`stepOf` strings. The component test asserts the
German strings resolve end-to-end via `<I18nProvider initialLocale="de">`.
Accessibility:
- `role="dialog"` + `aria-modal="true"` + `aria-labelledby`,
polite live region announces step transitions, focus moves to
the primary action on every step.
- Keyboard: Esc = skip, ArrowRight / Enter = next, ArrowLeft =
back. Inputs / textareas / contenteditable nodes are
explicitly NOT hijacked (defensive against future tour content
that might embed an input).
- `prefers-reduced-motion` disables the backdrop fade.
Tests: 917 / 917 unit pass (was 890 — +27 across 2 new files).
Lint clean (13 pre-existing warnings unchanged). Typecheck clean
for B5 files; dashboard-layout / sibling-agent typecheck issues
are pre-existing and out of scope.
The component is "dumb" — it takes `onClose(outcome)` and lets the
caller persist the flag. The launcher wiring lands in the next
commit (B5-3) so this commit is small and orthogonal.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
34c967c14a
commit
e57fc0a8da
6 files changed
+926
-2
No files matched your search
+50
-1
@@ -120,7 +120,24 @@
|
|||||||
"glucoseRow": "{label}: Ø {avg} {unit} (Ziel {rangeMin}–{rangeMax}) — {class}",
|
"glucoseRow": "{label}: Ø {avg} {unit} (Ziel {rangeMin}–{rangeMax}) — {class}",
|
||||||
"glucoseInTarget": "im Ziel",
|
"glucoseInTarget": "im Ziel",
|
||||||
"glucoseBelowTarget": "unter Ziel",
|
"glucoseBelowTarget": "unter Ziel",
|
||||||
"glucoseAboveTarget": "über Ziel"
|
"glucoseAboveTarget": "über Ziel",
|
||||||
|
"practiceLabel": "Praxis",
|
||||||
|
"dialog": {
|
||||||
|
"title": "Arztbericht (PDF)",
|
||||||
|
"description": "Lege den Auswertungszeitraum fest und (optional) den Praxisnamen — beide erscheinen auf dem Deckblatt.",
|
||||||
|
"startLabel": "Von",
|
||||||
|
"endLabel": "Bis",
|
||||||
|
"practiceLabel": "Praxis / Klinik (optional)",
|
||||||
|
"practicePlaceholder": "z. B. Dr. Müller — Hausarztpraxis",
|
||||||
|
"preset90d": "Letzte 90 Tage",
|
||||||
|
"preset180d": "Letzte 6 Monate",
|
||||||
|
"preset365d": "Letzte 12 Monate",
|
||||||
|
"submit": "PDF erstellen",
|
||||||
|
"cancel": "Abbrechen",
|
||||||
|
"errorEndBeforeStart": "Das Ende-Datum darf nicht vor dem Start liegen.",
|
||||||
|
"errorRangeTooLong": "Der Auswertungszeitraum darf maximal 2 Jahre umfassen.",
|
||||||
|
"errorInvalidDate": "Bitte wähle ein gültiges Start- und Ende-Datum."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"errorBoundary": {
|
"errorBoundary": {
|
||||||
"title": "Da ist etwas schiefgelaufen",
|
"title": "Da ist etwas schiefgelaufen",
|
||||||
@@ -846,6 +863,38 @@
|
|||||||
"openSettings": "In den Einstellungen fortsetzen"
|
"openSettings": "In den Einstellungen fortsetzen"
|
||||||
},
|
},
|
||||||
"doneToast": "Alles eingerichtet — willkommen an Bord."
|
"doneToast": "Alles eingerichtet — willkommen an Bord."
|
||||||
|
},
|
||||||
|
"tour": {
|
||||||
|
"stepOf": "Schritt {current} von {total}",
|
||||||
|
"next": "Weiter",
|
||||||
|
"back": "Zurück",
|
||||||
|
"skip": "Tour überspringen",
|
||||||
|
"done": "Verstanden",
|
||||||
|
"restart": "Tour neu starten",
|
||||||
|
"restartHint": "Zeige die Dashboard-Tour beim nächsten Aufruf erneut.",
|
||||||
|
"restartConfirmation": "Die Tour startet bei deinem nächsten Dashboard-Besuch erneut.",
|
||||||
|
"steps": {
|
||||||
|
"tileStrip": {
|
||||||
|
"title": "Deine Tageswerte",
|
||||||
|
"body": "Die Kachel-Leiste zeigt den letzten Wert und den 7-Tage-Trend für jede Metrik — Gewicht, Blutdruck, Puls, Stimmung. Was du nicht brauchst, blendest du in Einstellungen → Dashboard aus."
|
||||||
|
},
|
||||||
|
"quickAdd": {
|
||||||
|
"title": "In zwei Taps erfasst",
|
||||||
|
"body": "Über den Hinzufügen-Button kannst du Messungen oder Stimmungseinträge anlegen, ohne das Dashboard zu verlassen. Das Formular merkt sich deinen letzten Kontext und Tags."
|
||||||
|
},
|
||||||
|
"insights": {
|
||||||
|
"title": "KI-gestützte Einblicke",
|
||||||
|
"body": "Insights verwandelt deine Werte in eine klare Gesundheits-Zusammenfassung, mit Einordnung von Blutdruck, Gewicht und Stimmung. Eigener KI-Key oder Standard — du entscheidest."
|
||||||
|
},
|
||||||
|
"integrations": {
|
||||||
|
"title": "Geräte verbinden",
|
||||||
|
"body": "In den Einstellungen verbindest du Withings (Auto-Sync für Gewicht, Blutdruck, Puls), moodLog oder deinen KI-Provider. Alles bleibt optional und verschlüsselt."
|
||||||
|
},
|
||||||
|
"achievements": {
|
||||||
|
"title": "Streaks und Badges sammeln",
|
||||||
|
"body": "Die Achievements-Seite trackt tägliche Logging-Streaks, Blutdruck-Meilensteine und Konsistenz-Abzeichen. Ein kleiner Anreiz, dranzubleiben."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"gettingStarted": {
|
"gettingStarted": {
|
||||||
|
|||||||
+50
-1
@@ -120,7 +120,24 @@
|
|||||||
"glucoseRow": "{label}: avg {avg} {unit} (target {rangeMin}–{rangeMax}) — {class}",
|
"glucoseRow": "{label}: avg {avg} {unit} (target {rangeMin}–{rangeMax}) — {class}",
|
||||||
"glucoseInTarget": "in target",
|
"glucoseInTarget": "in target",
|
||||||
"glucoseBelowTarget": "below target",
|
"glucoseBelowTarget": "below target",
|
||||||
"glucoseAboveTarget": "above target"
|
"glucoseAboveTarget": "above target",
|
||||||
|
"practiceLabel": "Practice",
|
||||||
|
"dialog": {
|
||||||
|
"title": "Doctor report (PDF)",
|
||||||
|
"description": "Pick the reporting period and (optionally) the practice name; both are printed on the cover.",
|
||||||
|
"startLabel": "Start date",
|
||||||
|
"endLabel": "End date",
|
||||||
|
"practiceLabel": "Practice / clinic name (optional)",
|
||||||
|
"practicePlaceholder": "e.g. Dr. Müller — Family practice",
|
||||||
|
"preset90d": "Last 90 days",
|
||||||
|
"preset180d": "Last 6 months",
|
||||||
|
"preset365d": "Last 12 months",
|
||||||
|
"submit": "Generate PDF",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"errorEndBeforeStart": "End date must be on or after the start date.",
|
||||||
|
"errorRangeTooLong": "The reporting period cannot exceed 2 years.",
|
||||||
|
"errorInvalidDate": "Please select a valid start and end date."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"errorBoundary": {
|
"errorBoundary": {
|
||||||
"title": "Something went wrong",
|
"title": "Something went wrong",
|
||||||
@@ -846,6 +863,38 @@
|
|||||||
"openSettings": "Continue in Settings"
|
"openSettings": "Continue in Settings"
|
||||||
},
|
},
|
||||||
"doneToast": "You're all set — welcome aboard."
|
"doneToast": "You're all set — welcome aboard."
|
||||||
|
},
|
||||||
|
"tour": {
|
||||||
|
"stepOf": "Step {current} of {total}",
|
||||||
|
"next": "Next",
|
||||||
|
"back": "Back",
|
||||||
|
"skip": "Skip tour",
|
||||||
|
"done": "Got it",
|
||||||
|
"restart": "Restart onboarding tour",
|
||||||
|
"restartHint": "Replay the dashboard walkthrough from the start.",
|
||||||
|
"restartConfirmation": "Tour will restart on your next dashboard visit.",
|
||||||
|
"steps": {
|
||||||
|
"tileStrip": {
|
||||||
|
"title": "Your daily numbers",
|
||||||
|
"body": "The tile strip shows the latest reading and a 7-day trend for every metric you log — weight, blood pressure, pulse, mood. Hide what you don't need from Settings → Dashboard."
|
||||||
|
},
|
||||||
|
"quickAdd": {
|
||||||
|
"title": "Log anything in two taps",
|
||||||
|
"body": "Use the Add button to drop a measurement or mood entry without leaving the dashboard. The form remembers your last context and tags."
|
||||||
|
},
|
||||||
|
"insights": {
|
||||||
|
"title": "AI-assisted insights",
|
||||||
|
"body": "Insights turns your readings into a plain-English health summary, with classifications for blood pressure, weight trend and mood. Bring your own AI key or use the built-in default."
|
||||||
|
},
|
||||||
|
"integrations": {
|
||||||
|
"title": "Connect your devices",
|
||||||
|
"body": "Open Settings to link Withings (auto-sync weight, BP and pulse), moodLog, or your AI provider. Everything stays optional and encrypted."
|
||||||
|
},
|
||||||
|
"achievements": {
|
||||||
|
"title": "Earn streaks and badges",
|
||||||
|
"body": "The Achievements page tracks daily logging streaks, blood-pressure milestones and consistency badges. A small nudge to keep going."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"gettingStarted": {
|
"gettingStarted": {
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
|
||||||
|
// vitest runs in the Node environment (no jsdom). The tour's interactive
|
||||||
|
// behaviour (keyboard navigation, focus management, target measurement)
|
||||||
|
// is exercised by the pure state machine at
|
||||||
|
// `src/lib/onboarding/__tests__/tour-state.test.ts` and by the e2e
|
||||||
|
// suite at `e2e/onboarding-tour.spec.ts`. Here we lock the SSR contract:
|
||||||
|
// initial render shape, ARIA wiring, i18n keys, and that the first
|
||||||
|
// step is the tile strip.
|
||||||
|
|
||||||
|
import { I18nProvider } from "@/lib/i18n/context";
|
||||||
|
import { OnboardingTour } from "../tour";
|
||||||
|
|
||||||
|
function renderTour(includeAchievements = true) {
|
||||||
|
return renderToStaticMarkup(
|
||||||
|
<I18nProvider initialLocale="en">
|
||||||
|
<OnboardingTour
|
||||||
|
includeAchievements={includeAchievements}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
/>
|
||||||
|
</I18nProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("<OnboardingTour>", () => {
|
||||||
|
it("renders a modal dialog with role=dialog and aria-modal=true", () => {
|
||||||
|
const html = renderTour();
|
||||||
|
expect(html).toContain('role="dialog"');
|
||||||
|
expect(html).toContain('aria-modal="true"');
|
||||||
|
expect(html).toContain('aria-labelledby="onboarding-tour-title"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts on the tile-strip step (1 of 5 by default)", () => {
|
||||||
|
const html = renderTour();
|
||||||
|
expect(html).toContain("Step 1 of 5");
|
||||||
|
expect(html).toContain("Your daily numbers");
|
||||||
|
// Body of the first step should mention the tile strip context.
|
||||||
|
expect(html).toContain("tile strip");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops the achievements stop when the flag is off (1 of 4)", () => {
|
||||||
|
const html = renderTour(false);
|
||||||
|
expect(html).toContain("Step 1 of 4");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exposes a skip control with an accessible label", () => {
|
||||||
|
const html = renderTour();
|
||||||
|
// The backdrop button doubles as a Skip control with aria-label,
|
||||||
|
// and the explicit footer Skip button.
|
||||||
|
expect(html).toContain("Skip tour");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders both the Skip button and the primary Next button on the first step", () => {
|
||||||
|
const html = renderTour();
|
||||||
|
expect(html).toContain("Skip tour");
|
||||||
|
expect(html).toContain(">Next<");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("attaches a stable test hook for the primary action so e2e can drive it", () => {
|
||||||
|
const html = renderTour();
|
||||||
|
expect(html).toContain('data-testid="onboarding-tour-primary"');
|
||||||
|
expect(html).toContain('data-testid="onboarding-tour-tooltip"');
|
||||||
|
expect(html).toContain('data-testid="onboarding-tour"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("places a polite live region announcing the current step", () => {
|
||||||
|
const html = renderTour();
|
||||||
|
expect(html).toContain('aria-live="polite"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("German locale resolves the tour i18n keys end-to-end", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<I18nProvider initialLocale="de">
|
||||||
|
<OnboardingTour onClose={vi.fn()} />
|
||||||
|
</I18nProvider>,
|
||||||
|
);
|
||||||
|
expect(html).toContain("Schritt 1 von 5");
|
||||||
|
expect(html).toContain("Deine Tageswerte");
|
||||||
|
expect(html).toContain("Tour überspringen");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,418 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v1.4.15 Phase B5 — onboarding spotlight tour.
|
||||||
|
*
|
||||||
|
* After a new user finishes the wizard at `/onboarding` and lands on
|
||||||
|
* the dashboard, this overlay walks them through 4–5 key features
|
||||||
|
* (tile strip, quick-add menu, insights, integrations, achievements).
|
||||||
|
*
|
||||||
|
* Architecture decisions:
|
||||||
|
* - **No new dependency.** Project package.json carries no
|
||||||
|
* `react-joyride`/`intro.js`/`shepherd` already, and the tour is
|
||||||
|
* small enough that an in-house spotlight + tooltip is preferable
|
||||||
|
* to a 30 KB add. We render a fixed full-viewport overlay with a
|
||||||
|
* dark backdrop and one rectangular cutout aligned to the live
|
||||||
|
* target via measured `getBoundingClientRect()`. The tooltip is
|
||||||
|
* absolutely positioned next to the cutout.
|
||||||
|
* - **`data-tour-id` not class names.** Targets are looked up by a
|
||||||
|
* stable data attribute that's not affected by Tailwind churn.
|
||||||
|
* Each target's location is re-measured on `resize` and `scroll`
|
||||||
|
* so the cutout follows the element.
|
||||||
|
* - **Graceful degradation.** If the target element is missing
|
||||||
|
* (mobile breakpoint, conditional render), we drop the cutout and
|
||||||
|
* render the tooltip centred on screen so the user can still
|
||||||
|
* advance through the tour.
|
||||||
|
* - **Accessibility:** the dialog uses `role="dialog"` +
|
||||||
|
* `aria-modal="true"` + `aria-labelledby`, focus moves to the
|
||||||
|
* primary action on every step, ESC = skip, Tab is trapped inside
|
||||||
|
* the tooltip, and a live region announces the step transition.
|
||||||
|
* `prefers-reduced-motion` disables the cutout fade.
|
||||||
|
*
|
||||||
|
* The component is mounted by a sibling launcher that decides *when*
|
||||||
|
* to start the tour (server flag + localStorage). This component
|
||||||
|
* itself is dumb — caller hands it `onClose` (called on Skip OR Done)
|
||||||
|
* plus `outcome` (so the launcher can persist `completed` vs
|
||||||
|
* `skipped` to the API + audit log).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useTranslations } from "@/lib/i18n/context";
|
||||||
|
import {
|
||||||
|
buildTourStops,
|
||||||
|
currentStop,
|
||||||
|
initTourState,
|
||||||
|
isTourFinished,
|
||||||
|
nextStep,
|
||||||
|
prevStep,
|
||||||
|
skipTour,
|
||||||
|
stepCounter,
|
||||||
|
type TourStop,
|
||||||
|
} from "@/lib/onboarding/tour-state";
|
||||||
|
|
||||||
|
interface TourProps {
|
||||||
|
/**
|
||||||
|
* Whether the achievements page exists and should be a tour stop.
|
||||||
|
* The launcher passes `false` if B4 hasn't shipped to skip the
|
||||||
|
* stop without renumbering.
|
||||||
|
*/
|
||||||
|
includeAchievements?: boolean;
|
||||||
|
/**
|
||||||
|
* Called when the tour finishes (Done OR Skip OR Esc OR Backdrop
|
||||||
|
* click). Receives the terminal outcome so the launcher can audit
|
||||||
|
* the difference.
|
||||||
|
*/
|
||||||
|
onClose: (outcome: "completed" | "skipped") => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SpotlightRect {
|
||||||
|
top: number;
|
||||||
|
left: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-measure the target element. Returns `null` if the element is
|
||||||
|
* not in the DOM or has zero dimensions — caller renders a centred
|
||||||
|
* tooltip in that case.
|
||||||
|
*/
|
||||||
|
function measureTarget(targetId: string | null): SpotlightRect | null {
|
||||||
|
if (!targetId) return null;
|
||||||
|
if (typeof document === "undefined") return null;
|
||||||
|
const el = document.querySelector<HTMLElement>(
|
||||||
|
`[data-tour-id="${CSS.escape(targetId)}"]`,
|
||||||
|
);
|
||||||
|
if (!el) return null;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
if (rect.width === 0 || rect.height === 0) return null;
|
||||||
|
// Pad the cutout slightly so the highlight isn't flush against
|
||||||
|
// the target's text.
|
||||||
|
const PAD = 8;
|
||||||
|
return {
|
||||||
|
top: rect.top - PAD,
|
||||||
|
left: rect.left - PAD,
|
||||||
|
width: rect.width + PAD * 2,
|
||||||
|
height: rect.height + PAD * 2,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide tooltip position. The intent is to sit beside the target
|
||||||
|
* along the requested axis, but flip to the opposite edge if there
|
||||||
|
* isn't enough room. Returns absolute viewport coordinates that the
|
||||||
|
* UI uses with `position: fixed`. Falls back to centred when there
|
||||||
|
* is no spotlight rect.
|
||||||
|
*/
|
||||||
|
function computeTooltipPosition(
|
||||||
|
rect: SpotlightRect | null,
|
||||||
|
placement: TourStop["placement"],
|
||||||
|
tooltipSize: { width: number; height: number },
|
||||||
|
): { top: number; left: number; placement: TourStop["placement"] } {
|
||||||
|
if (typeof window === "undefined") return { top: 0, left: 0, placement };
|
||||||
|
const vw = window.innerWidth;
|
||||||
|
const vh = window.innerHeight;
|
||||||
|
if (!rect) {
|
||||||
|
return {
|
||||||
|
top: Math.max(16, (vh - tooltipSize.height) / 2),
|
||||||
|
left: Math.max(16, (vw - tooltipSize.width) / 2),
|
||||||
|
placement: "center",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const GAP = 12;
|
||||||
|
const candidates: Array<{
|
||||||
|
placement: TourStop["placement"];
|
||||||
|
top: number;
|
||||||
|
left: number;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
if (placement === "bottom" || placement === "top" || placement === "center") {
|
||||||
|
candidates.push({
|
||||||
|
placement: "bottom",
|
||||||
|
top: rect.top + rect.height + GAP,
|
||||||
|
left: Math.min(
|
||||||
|
Math.max(16, rect.left + rect.width / 2 - tooltipSize.width / 2),
|
||||||
|
vw - tooltipSize.width - 16,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
candidates.push({
|
||||||
|
placement: "top",
|
||||||
|
top: rect.top - tooltipSize.height - GAP,
|
||||||
|
left: Math.min(
|
||||||
|
Math.max(16, rect.left + rect.width / 2 - tooltipSize.width / 2),
|
||||||
|
vw - tooltipSize.width - 16,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (placement === "right" || placement === "left") {
|
||||||
|
candidates.push({
|
||||||
|
placement: "right",
|
||||||
|
top: Math.min(
|
||||||
|
Math.max(16, rect.top + rect.height / 2 - tooltipSize.height / 2),
|
||||||
|
vh - tooltipSize.height - 16,
|
||||||
|
),
|
||||||
|
left: rect.left + rect.width + GAP,
|
||||||
|
});
|
||||||
|
candidates.push({
|
||||||
|
placement: "left",
|
||||||
|
top: Math.min(
|
||||||
|
Math.max(16, rect.top + rect.height / 2 - tooltipSize.height / 2),
|
||||||
|
vh - tooltipSize.height - 16,
|
||||||
|
),
|
||||||
|
left: rect.left - tooltipSize.width - GAP,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Pick the first candidate that fits the viewport; otherwise the
|
||||||
|
// first candidate (the renderer will clamp inside the box).
|
||||||
|
for (const c of candidates) {
|
||||||
|
if (
|
||||||
|
c.top >= 8 &&
|
||||||
|
c.left >= 8 &&
|
||||||
|
c.top + tooltipSize.height <= vh - 8 &&
|
||||||
|
c.left + tooltipSize.width <= vw - 8
|
||||||
|
) {
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Last-resort: centred bottom of viewport.
|
||||||
|
return {
|
||||||
|
placement: "center",
|
||||||
|
top: Math.max(16, (vh - tooltipSize.height) / 2),
|
||||||
|
left: Math.max(16, (vw - tooltipSize.width) / 2),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOOLTIP_WIDTH = 320;
|
||||||
|
const TOOLTIP_HEIGHT = 220; // enough headroom for two-line title + 4-line body + footer
|
||||||
|
|
||||||
|
export function OnboardingTour({
|
||||||
|
includeAchievements = true,
|
||||||
|
onClose,
|
||||||
|
}: TourProps) {
|
||||||
|
const { t } = useTranslations();
|
||||||
|
|
||||||
|
const stops = useMemo(
|
||||||
|
() => buildTourStops({ includeAchievements }),
|
||||||
|
[includeAchievements],
|
||||||
|
);
|
||||||
|
const [state, setState] = useState(() => initTourState(stops));
|
||||||
|
const [rect, setRect] = useState<SpotlightRect | null>(null);
|
||||||
|
|
||||||
|
const stop = currentStop(state);
|
||||||
|
|
||||||
|
// Re-measure on step change, viewport resize, and scroll. We intentionally
|
||||||
|
// *don't* MutationObserver the whole document — the targets are stable in
|
||||||
|
// the layout and re-measuring on rAF after scroll/resize is plenty.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!stop) return;
|
||||||
|
let raf = 0;
|
||||||
|
const measure = () => {
|
||||||
|
cancelAnimationFrame(raf);
|
||||||
|
raf = requestAnimationFrame(() => {
|
||||||
|
setRect(measureTarget(stop.targetId));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
measure();
|
||||||
|
window.addEventListener("resize", measure);
|
||||||
|
window.addEventListener("scroll", measure, true);
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(raf);
|
||||||
|
window.removeEventListener("resize", measure);
|
||||||
|
window.removeEventListener("scroll", measure, true);
|
||||||
|
};
|
||||||
|
}, [stop]);
|
||||||
|
|
||||||
|
// Focus management — moves focus to the primary action when the
|
||||||
|
// step changes so keyboard users can hit Enter to advance.
|
||||||
|
const primaryRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!stop) return;
|
||||||
|
primaryRef.current?.focus();
|
||||||
|
}, [stop]);
|
||||||
|
|
||||||
|
// Keyboard shortcuts: Esc = skip, ArrowLeft / ArrowRight = nav,
|
||||||
|
// Enter = next/done. We attach to window so the listener works
|
||||||
|
// regardless of which element holds focus inside the tooltip.
|
||||||
|
const handleSkip = useCallback(() => {
|
||||||
|
setState((prev) => {
|
||||||
|
const next = skipTour(prev);
|
||||||
|
if (isTourFinished(next)) {
|
||||||
|
// Defer the parent onClose so it doesn't run inside the
|
||||||
|
// setState callback (which React calls during render in
|
||||||
|
// strict-mode dev).
|
||||||
|
queueMicrotask(() => onClose("skipped"));
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const handleNext = useCallback(() => {
|
||||||
|
setState((prev) => {
|
||||||
|
const next = nextStep(prev);
|
||||||
|
if (isTourFinished(next) && next.outcome === "completed") {
|
||||||
|
queueMicrotask(() => onClose("completed"));
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const handlePrev = useCallback(() => {
|
||||||
|
setState((prev) => prevStep(prev));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!stop) return;
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSkip();
|
||||||
|
} else if (e.key === "ArrowRight" || e.key === "Enter") {
|
||||||
|
// Don't hijack Enter inside form inputs (none exist in the
|
||||||
|
// tour itself but be defensive against future content).
|
||||||
|
const target = e.target as HTMLElement | null;
|
||||||
|
if (
|
||||||
|
target &&
|
||||||
|
(target.tagName === "INPUT" ||
|
||||||
|
target.tagName === "TEXTAREA" ||
|
||||||
|
target.isContentEditable)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
e.preventDefault();
|
||||||
|
handleNext();
|
||||||
|
} else if (e.key === "ArrowLeft") {
|
||||||
|
e.preventDefault();
|
||||||
|
handlePrev();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
}, [stop, handleSkip, handleNext, handlePrev]);
|
||||||
|
|
||||||
|
if (!stop) return null;
|
||||||
|
|
||||||
|
const counter = stepCounter(state);
|
||||||
|
const isLast = counter.current === counter.total;
|
||||||
|
const tooltipPos = computeTooltipPosition(rect, stop.placement, {
|
||||||
|
width: TOOLTIP_WIDTH,
|
||||||
|
height: TOOLTIP_HEIGHT,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build a clip-path that punches out the spotlight rect from the
|
||||||
|
// backdrop. `evenodd` fill rule + the outer rect winding the same
|
||||||
|
// way means the inner rect carves a hole. If `rect` is null we
|
||||||
|
// skip the cutout and render a solid backdrop.
|
||||||
|
const clipPath = rect
|
||||||
|
? `polygon(0 0, 100% 0, 100% 100%, 0 100%, 0 ${rect.top}px, ${rect.left}px ${rect.top}px, ${rect.left}px ${rect.top + rect.height}px, ${rect.left + rect.width}px ${rect.top + rect.height}px, ${rect.left + rect.width}px ${rect.top}px, 0 ${rect.top}px)`
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="onboarding-tour"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="onboarding-tour-title"
|
||||||
|
className="fixed inset-0 z-[200]"
|
||||||
|
>
|
||||||
|
{/* Backdrop with cutout. `pointer-events-auto` so a click on
|
||||||
|
the dimmed area dismisses the tour (treated as Skip). The
|
||||||
|
cutout itself uses `pointer-events-none` (transparent click
|
||||||
|
pass-through) on the dimmed region — but to keep the click
|
||||||
|
contract simple we use a single backdrop with the polygon
|
||||||
|
clip and let any backdrop click count as Skip. The actual
|
||||||
|
target underneath stays inert (the tour intentionally takes
|
||||||
|
focus). prefers-reduced-motion users get an instant overlay
|
||||||
|
(no opacity transition). */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={t("onboarding.tour.skip")}
|
||||||
|
className="absolute inset-0 cursor-default bg-black/70 transition-opacity duration-150 motion-reduce:transition-none"
|
||||||
|
style={clipPath ? { clipPath, WebkitClipPath: clipPath } : undefined}
|
||||||
|
onClick={handleSkip}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Polite live region — announces the current step to screen readers. */}
|
||||||
|
<p className="sr-only" aria-live="polite">
|
||||||
|
{t("onboarding.tour.stepOf", {
|
||||||
|
current: counter.current,
|
||||||
|
total: counter.total,
|
||||||
|
})}
|
||||||
|
{": "}
|
||||||
|
{t(stop.titleKey)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Tooltip card */}
|
||||||
|
<div
|
||||||
|
data-testid="onboarding-tour-tooltip"
|
||||||
|
data-placement={tooltipPos.placement}
|
||||||
|
className="bg-card border-border absolute rounded-xl border p-5 shadow-2xl"
|
||||||
|
style={{
|
||||||
|
top: `${tooltipPos.top}px`,
|
||||||
|
left: `${tooltipPos.left}px`,
|
||||||
|
width: `${TOOLTIP_WIDTH}px`,
|
||||||
|
}}
|
||||||
|
// Stop click propagation so a click inside the card doesn't
|
||||||
|
// bubble to the backdrop's Skip handler.
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<header className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground text-xs font-medium tracking-wide tabular-nums uppercase">
|
||||||
|
{t("onboarding.tour.stepOf", {
|
||||||
|
current: counter.current,
|
||||||
|
total: counter.total,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<h2
|
||||||
|
id="onboarding-tour-title"
|
||||||
|
className="mt-1 text-base font-semibold tracking-tight"
|
||||||
|
>
|
||||||
|
{t(stop.titleKey)}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-3 text-sm leading-relaxed">
|
||||||
|
{t(stop.bodyKey)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<footer className="mt-5 flex items-center justify-between gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSkip}
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
{t("onboarding.tour.skip")}
|
||||||
|
</Button>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{counter.current > 1 && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handlePrev}
|
||||||
|
>
|
||||||
|
{t("onboarding.tour.back")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
ref={primaryRef}
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleNext}
|
||||||
|
data-testid="onboarding-tour-primary"
|
||||||
|
>
|
||||||
|
{isLast
|
||||||
|
? t("onboarding.tour.done")
|
||||||
|
: t("onboarding.tour.next")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildTourStops,
|
||||||
|
currentStop,
|
||||||
|
initTourState,
|
||||||
|
isTourFinished,
|
||||||
|
nextStep,
|
||||||
|
prevStep,
|
||||||
|
skipTour,
|
||||||
|
stepCounter,
|
||||||
|
} from "../tour-state";
|
||||||
|
|
||||||
|
describe("tour-state", () => {
|
||||||
|
describe("buildTourStops()", () => {
|
||||||
|
it("includes the achievements stop by default (5 stops)", () => {
|
||||||
|
const stops = buildTourStops();
|
||||||
|
expect(stops.map((s) => s.id)).toEqual([
|
||||||
|
"tile-strip",
|
||||||
|
"quick-add",
|
||||||
|
"insights",
|
||||||
|
"integrations",
|
||||||
|
"achievements",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits the achievements stop when the flag is off (4 stops)", () => {
|
||||||
|
const stops = buildTourStops({ includeAchievements: false });
|
||||||
|
expect(stops.map((s) => s.id)).toEqual([
|
||||||
|
"tile-strip",
|
||||||
|
"quick-add",
|
||||||
|
"insights",
|
||||||
|
"integrations",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("every stop carries a non-empty i18n title and body key", () => {
|
||||||
|
const stops = buildTourStops();
|
||||||
|
for (const s of stops) {
|
||||||
|
expect(s.titleKey).toMatch(/^onboarding\.tour\.steps\./);
|
||||||
|
expect(s.bodyKey).toMatch(/^onboarding\.tour\.steps\./);
|
||||||
|
expect(s.titleKey).not.toBe(s.bodyKey);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("every stop declares a stable targetId for the data-tour-id contract", () => {
|
||||||
|
const stops = buildTourStops();
|
||||||
|
for (const s of stops) {
|
||||||
|
// `null` is allowed (centred-screen tooltip without a
|
||||||
|
// spotlight cutout) but we currently use real targets for
|
||||||
|
// every stop — a regression here would mean the spotlight
|
||||||
|
// silently falls back to centre-screen.
|
||||||
|
expect(typeof s.targetId === "string" && s.targetId.length > 0).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("navigation", () => {
|
||||||
|
it("starts at index 0 with outcome null", () => {
|
||||||
|
const state = initTourState(buildTourStops());
|
||||||
|
expect(state.index).toBe(0);
|
||||||
|
expect(state.outcome).toBeNull();
|
||||||
|
expect(isTourFinished(state)).toBe(false);
|
||||||
|
expect(currentStop(state)?.id).toBe("tile-strip");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nextStep advances through the list", () => {
|
||||||
|
let state = initTourState(buildTourStops());
|
||||||
|
state = nextStep(state);
|
||||||
|
expect(currentStop(state)?.id).toBe("quick-add");
|
||||||
|
state = nextStep(state);
|
||||||
|
expect(currentStop(state)?.id).toBe("insights");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nextStep on the last step finishes the tour with outcome=completed", () => {
|
||||||
|
const stops = buildTourStops();
|
||||||
|
let state = initTourState(stops);
|
||||||
|
// step through every stop
|
||||||
|
for (let i = 0; i < stops.length; i++) {
|
||||||
|
state = nextStep(state);
|
||||||
|
}
|
||||||
|
expect(state.outcome).toBe("completed");
|
||||||
|
expect(isTourFinished(state)).toBe(true);
|
||||||
|
expect(currentStop(state)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prevStep is pinned at index 0 (LeftArrow on first step is a no-op)", () => {
|
||||||
|
const state = initTourState(buildTourStops());
|
||||||
|
const back = prevStep(state);
|
||||||
|
expect(back.index).toBe(0);
|
||||||
|
expect(back.outcome).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prevStep walks backward when not at the start", () => {
|
||||||
|
let state = initTourState(buildTourStops());
|
||||||
|
state = nextStep(state);
|
||||||
|
state = nextStep(state);
|
||||||
|
expect(state.index).toBe(2);
|
||||||
|
const back = prevStep(state);
|
||||||
|
expect(back.index).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skipTour from any step finishes with outcome=skipped", () => {
|
||||||
|
let state = initTourState(buildTourStops());
|
||||||
|
state = nextStep(state); // on step 2 of 5
|
||||||
|
const skipped = skipTour(state);
|
||||||
|
expect(skipped.outcome).toBe("skipped");
|
||||||
|
expect(isTourFinished(skipped)).toBe(true);
|
||||||
|
expect(currentStop(skipped)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nextStep / prevStep / skipTour are no-ops once finished", () => {
|
||||||
|
let state = initTourState(buildTourStops());
|
||||||
|
state = skipTour(state);
|
||||||
|
const after = nextStep(prevStep(skipTour(state)));
|
||||||
|
expect(after).toEqual(state);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("stepCounter", () => {
|
||||||
|
it("reports 1-based current and the total step count", () => {
|
||||||
|
const stops = buildTourStops();
|
||||||
|
let state = initTourState(stops);
|
||||||
|
expect(stepCounter(state)).toEqual({ current: 1, total: stops.length });
|
||||||
|
state = nextStep(state);
|
||||||
|
expect(stepCounter(state)).toEqual({ current: 2, total: stops.length });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps `current` to total when the tour is finished", () => {
|
||||||
|
let state = initTourState(buildTourStops({ includeAchievements: false }));
|
||||||
|
state = skipTour(state);
|
||||||
|
expect(stepCounter(state)).toEqual({ current: 4, total: 4 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
/**
|
||||||
|
* v1.4.15 Phase B5 — onboarding tour state model.
|
||||||
|
*
|
||||||
|
* Pure data + state-machine helpers for the spotlight tour overlaid on
|
||||||
|
* the dashboard for first-time users. Kept DOM-free so vitest (Node
|
||||||
|
* environment, no jsdom) can exercise the navigation logic directly,
|
||||||
|
* matching the project test-style elsewhere.
|
||||||
|
*
|
||||||
|
* The UI layer (`src/components/onboarding/tour.tsx`) builds the
|
||||||
|
* step list once on mount and then drives navigation through these
|
||||||
|
* helpers.
|
||||||
|
*
|
||||||
|
* Tour stops are anchored to UI targets via `data-tour-id` attributes
|
||||||
|
* (NOT class-name selectors — class names change too often during
|
||||||
|
* Tailwind tweaks; `data-tour-id` is a stable contract). Every stop
|
||||||
|
* declares its `targetId`; the spotlight component looks up the live
|
||||||
|
* element via `document.querySelector('[data-tour-id="<id>"]')`.
|
||||||
|
*
|
||||||
|
* If a target element is missing at runtime (e.g. user is on a
|
||||||
|
* resized layout that hides it), the spotlight falls back to a
|
||||||
|
* centred-screen tooltip so the tour never blocks indefinitely.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Stable IDs for every stop in the tour. */
|
||||||
|
export type TourStopId =
|
||||||
|
| "tile-strip"
|
||||||
|
| "quick-add"
|
||||||
|
| "insights"
|
||||||
|
| "integrations"
|
||||||
|
| "achievements";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One step in the tour. The `targetId` matches a `data-tour-id` on a
|
||||||
|
* live element somewhere in the app; the title/body i18n keys live
|
||||||
|
* under `onboarding.tour.steps.<id>.*`.
|
||||||
|
*/
|
||||||
|
export interface TourStop {
|
||||||
|
id: TourStopId;
|
||||||
|
/**
|
||||||
|
* The `data-tour-id` value of the element to anchor the spotlight
|
||||||
|
* to. May be `null` if the stop is purely informational and should
|
||||||
|
* render in a centred-screen tooltip without a spotlight cutout.
|
||||||
|
*/
|
||||||
|
targetId: string | null;
|
||||||
|
/**
|
||||||
|
* Preferred placement of the tooltip relative to the target. The UI
|
||||||
|
* layer is allowed to flip this if the target is too close to a
|
||||||
|
* viewport edge.
|
||||||
|
*/
|
||||||
|
placement: "top" | "bottom" | "left" | "right" | "center";
|
||||||
|
/** i18n key for the step title. */
|
||||||
|
titleKey: string;
|
||||||
|
/** i18n key for the step body. */
|
||||||
|
bodyKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the canonical tour-step list. Caller may inject a feature
|
||||||
|
* flag — e.g. `includeAchievements: false` if B4 hasn't shipped yet
|
||||||
|
* — and we omit that stop without renumbering.
|
||||||
|
*/
|
||||||
|
export function buildTourStops(opts?: {
|
||||||
|
includeAchievements?: boolean;
|
||||||
|
}): TourStop[] {
|
||||||
|
const includeAchievements = opts?.includeAchievements ?? true;
|
||||||
|
|
||||||
|
const stops: TourStop[] = [
|
||||||
|
{
|
||||||
|
id: "tile-strip",
|
||||||
|
targetId: "dashboard-tile-strip",
|
||||||
|
placement: "bottom",
|
||||||
|
titleKey: "onboarding.tour.steps.tileStrip.title",
|
||||||
|
bodyKey: "onboarding.tour.steps.tileStrip.body",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "quick-add",
|
||||||
|
targetId: "dashboard-quick-add",
|
||||||
|
placement: "bottom",
|
||||||
|
titleKey: "onboarding.tour.steps.quickAdd.title",
|
||||||
|
bodyKey: "onboarding.tour.steps.quickAdd.body",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "insights",
|
||||||
|
targetId: "nav-insights",
|
||||||
|
placement: "right",
|
||||||
|
titleKey: "onboarding.tour.steps.insights.title",
|
||||||
|
bodyKey: "onboarding.tour.steps.insights.body",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "integrations",
|
||||||
|
// The integrations link sits behind the Settings sub-menu on
|
||||||
|
// some viewports — we anchor to the Settings nav item instead
|
||||||
|
// so the spotlight is always visible without forcing a hover.
|
||||||
|
targetId: "nav-settings",
|
||||||
|
placement: "right",
|
||||||
|
titleKey: "onboarding.tour.steps.integrations.title",
|
||||||
|
bodyKey: "onboarding.tour.steps.integrations.body",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (includeAchievements) {
|
||||||
|
stops.push({
|
||||||
|
id: "achievements",
|
||||||
|
targetId: "nav-achievements",
|
||||||
|
placement: "right",
|
||||||
|
titleKey: "onboarding.tour.steps.achievements.title",
|
||||||
|
bodyKey: "onboarding.tour.steps.achievements.body",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return stops;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tour navigation state. Pure value, immutable per transition — every
|
||||||
|
* helper returns a fresh state instead of mutating in place.
|
||||||
|
*/
|
||||||
|
export interface TourState {
|
||||||
|
/** Index into the steps array. May equal `steps.length` to indicate the tour is complete. */
|
||||||
|
index: number;
|
||||||
|
/** Cached step list — handy for the renderer. */
|
||||||
|
steps: TourStop[];
|
||||||
|
/**
|
||||||
|
* `null` while the tour is still running, `"completed"` once the
|
||||||
|
* user reached the end and clicked Done, `"skipped"` if they hit
|
||||||
|
* Skip on any step. Both terminal states cause the same DB write
|
||||||
|
* (`onboardingTourCompleted = true`); we keep them distinct purely
|
||||||
|
* so analytics can tell them apart in audit logs.
|
||||||
|
*/
|
||||||
|
outcome: "completed" | "skipped" | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Initial state at the first step. */
|
||||||
|
export function initTourState(steps: TourStop[]): TourState {
|
||||||
|
return { index: 0, steps, outcome: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Advance to the next step. If we're already on the last step, mark
|
||||||
|
* the tour `completed`. Calling on a terminal state is a no-op so
|
||||||
|
* the renderer can fire-and-forget on rapid double-clicks.
|
||||||
|
*/
|
||||||
|
export function nextStep(state: TourState): TourState {
|
||||||
|
if (state.outcome !== null) return state;
|
||||||
|
const lastIndex = state.steps.length - 1;
|
||||||
|
if (state.index >= lastIndex) {
|
||||||
|
return { ...state, index: state.steps.length, outcome: "completed" };
|
||||||
|
}
|
||||||
|
return { ...state, index: state.index + 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step backwards. Pinned at index 0 — the first step has no Back
|
||||||
|
* button visually; this helper just no-ops to keep the keyboard
|
||||||
|
* shortcut (LeftArrow) safe.
|
||||||
|
*/
|
||||||
|
export function prevStep(state: TourState): TourState {
|
||||||
|
if (state.outcome !== null) return state;
|
||||||
|
if (state.index <= 0) return state;
|
||||||
|
return { ...state, index: state.index - 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** User explicitly dismissed the tour from any step. */
|
||||||
|
export function skipTour(state: TourState): TourState {
|
||||||
|
if (state.outcome !== null) return state;
|
||||||
|
return { ...state, index: state.steps.length, outcome: "skipped" };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the tour has reached a terminal state. */
|
||||||
|
export function isTourFinished(state: TourState): boolean {
|
||||||
|
return state.outcome !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The current stop, or null if the tour is finished. */
|
||||||
|
export function currentStop(state: TourState): TourStop | null {
|
||||||
|
if (state.outcome !== null) return null;
|
||||||
|
return state.steps[state.index] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 1-based step counter for "{n}/{total}" displays. */
|
||||||
|
export function stepCounter(state: TourState): {
|
||||||
|
current: number;
|
||||||
|
total: number;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
current: Math.min(state.index + 1, state.steps.length),
|
||||||
|
total: state.steps.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in new issue
Block a user