mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
fix(onboarding): make the guided tour first-time-only, drop every re-entry trigger
The guided tour now exists only as the first-time auto-start after onboarding. Remove every in-app trigger that re-opened it: the per-module "show this tour" button on the dashboard, measurements, mood, medications, labs, illness, cycle, achievements, insights, and coach surfaces; and the Settings replay card. Delete the now-orphaned ModuleTourTrigger component, the tour-restart worker, the healthlog:module-tour and healthlog:tour-restart event listeners, the single-stop overlay path, and the force-launch session marker. The first-time auto-launch gate and the tour overlay itself are unchanged. Prune the orphaned i18n keys across all locales and update the affected tests.
This commit is contained in:
1 parent
f67542d29e
commit
3ba542f797
24 files changed
+41
-575
No files matched your search
+1
-5
@@ -3552,9 +3552,6 @@
|
||||
"back": "Zurück",
|
||||
"skip": "Tour überspringen",
|
||||
"done": "Verstanden",
|
||||
"restart": "Modul-Tour neu starten",
|
||||
"restartHint": "Zeigt die geführte Modul-Tour beim nächsten Dashboard-Besuch von vorne.",
|
||||
"restartConfirmation": "Die Modul-Tour startet bei deinem nächsten Dashboard-Besuch von vorne.",
|
||||
"steps": {
|
||||
"dashboardOverview": {
|
||||
"title": "Dein Dashboard",
|
||||
@@ -3616,8 +3613,7 @@
|
||||
"title": "Das war die Tour",
|
||||
"body": "Du kannst jedes Modul jederzeit einzeln erneut ansehen („Diese Tour zeigen“ auf jeder Seite) oder die komplette Tour unter Einstellungen → Erweitert neu starten."
|
||||
}
|
||||
},
|
||||
"moduleTrigger": "Diese Tour zeigen"
|
||||
}
|
||||
},
|
||||
"shell": {
|
||||
"back": "Zurück",
|
||||
|
||||
+1
-5
@@ -3552,9 +3552,6 @@
|
||||
"back": "Back",
|
||||
"skip": "Skip tour",
|
||||
"done": "Got it",
|
||||
"restart": "Restart module tour",
|
||||
"restartHint": "Replays the guided module tour from the start on your next dashboard visit.",
|
||||
"restartConfirmation": "The module tour will restart from the beginning on your next dashboard visit.",
|
||||
"steps": {
|
||||
"dashboardOverview": {
|
||||
"title": "Your dashboard",
|
||||
@@ -3616,8 +3613,7 @@
|
||||
"title": "That’s the tour",
|
||||
"body": "You can revisit any module on its own at any time (“Show this tour” on every page) or restart the whole tour under Settings → Advanced."
|
||||
}
|
||||
},
|
||||
"moduleTrigger": "Show this tour"
|
||||
}
|
||||
},
|
||||
"shell": {
|
||||
"back": "Back",
|
||||
|
||||
+1
-5
@@ -3552,9 +3552,6 @@
|
||||
"back": "Atrás",
|
||||
"skip": "Saltar tour",
|
||||
"done": "Entendido",
|
||||
"restart": "Reiniciar la visita de módulos",
|
||||
"restartHint": "Vuelve a reproducir la visita guiada de módulos desde el principio en tu próxima visita al panel.",
|
||||
"restartConfirmation": "La visita de módulos se reiniciará desde el principio en tu próxima visita al panel.",
|
||||
"steps": {
|
||||
"dashboardOverview": {
|
||||
"title": "Tu panel",
|
||||
@@ -3616,8 +3613,7 @@
|
||||
"title": "Esto es la visita",
|
||||
"body": "Puedes volver a ver cualquier módulo por separado cuando quieras («Mostrar esta visita» en cada página) o reiniciar la visita completa en Ajustes → Avanzado."
|
||||
}
|
||||
},
|
||||
"moduleTrigger": "Mostrar esta visita"
|
||||
}
|
||||
},
|
||||
"shell": {
|
||||
"back": "Atrás",
|
||||
|
||||
+1
-5
@@ -3552,9 +3552,6 @@
|
||||
"back": "Précédent",
|
||||
"skip": "Passer la visite",
|
||||
"done": "Compris",
|
||||
"restart": "Relancer la visite des modules",
|
||||
"restartHint": "Rejoue la visite guidée des modules depuis le début lors de votre prochaine ouverture du tableau de bord.",
|
||||
"restartConfirmation": "La visite des modules redémarrera depuis le début à votre prochaine ouverture du tableau de bord.",
|
||||
"steps": {
|
||||
"dashboardOverview": {
|
||||
"title": "Votre tableau de bord",
|
||||
@@ -3616,8 +3613,7 @@
|
||||
"title": "C’est la fin de la visite",
|
||||
"body": "Vous pouvez revoir chaque module séparément à tout moment (« Afficher cette visite » sur chaque page) ou relancer la visite complète dans Réglages → Avancé."
|
||||
}
|
||||
},
|
||||
"moduleTrigger": "Afficher cette visite"
|
||||
}
|
||||
},
|
||||
"shell": {
|
||||
"back": "Retour",
|
||||
|
||||
+1
-5
@@ -3552,9 +3552,6 @@
|
||||
"back": "Indietro",
|
||||
"skip": "Salta tour",
|
||||
"done": "Ho capito",
|
||||
"restart": "Riavvia la visita ai moduli",
|
||||
"restartHint": "Riproduce la visita guidata ai moduli dall’inizio al tuo prossimo accesso alla dashboard.",
|
||||
"restartConfirmation": "La visita ai moduli ripartirà dall’inizio al tuo prossimo accesso alla dashboard.",
|
||||
"steps": {
|
||||
"dashboardOverview": {
|
||||
"title": "La tua dashboard",
|
||||
@@ -3616,8 +3613,7 @@
|
||||
"title": "Questa è la visita guidata",
|
||||
"body": "Puoi rivedere ogni modulo singolarmente in qualsiasi momento («Mostra questa visita» su ogni pagina) o riavviare l’intera visita da Impostazioni → Avanzate."
|
||||
}
|
||||
},
|
||||
"moduleTrigger": "Mostra questa visita"
|
||||
}
|
||||
},
|
||||
"shell": {
|
||||
"back": "Indietro",
|
||||
|
||||
+1
-5
@@ -3552,9 +3552,6 @@
|
||||
"back": "Wstecz",
|
||||
"skip": "Pomiń tour",
|
||||
"done": "Rozumiem",
|
||||
"restart": "Uruchom przewodnik modułów ponownie",
|
||||
"restartHint": "Odtwarza przewodnik po modułach od początku przy następnej wizycie na pulpicie.",
|
||||
"restartConfirmation": "Przewodnik po modułach uruchomi się od początku przy następnej wizycie na pulpicie.",
|
||||
"steps": {
|
||||
"dashboardOverview": {
|
||||
"title": "Twój pulpit",
|
||||
@@ -3616,8 +3613,7 @@
|
||||
"title": "To koniec przewodnika",
|
||||
"body": "Każdy moduł możesz obejrzeć osobno w dowolnej chwili („Pokaż ten przewodnik” na każdej stronie) lub uruchomić cały przewodnik ponownie w Ustawienia → Zaawansowane."
|
||||
}
|
||||
},
|
||||
"moduleTrigger": "Pokaż ten przewodnik"
|
||||
}
|
||||
},
|
||||
"shell": {
|
||||
"back": "Wstecz",
|
||||
|
||||
@@ -34,7 +34,6 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { useAchievementsQuery } from "@/lib/queries/use-achievements-query";
|
||||
import {
|
||||
@@ -331,7 +330,6 @@ export default function AchievementsPage() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<ModuleTourTrigger stopId="achievements" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import { Minimize2 } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CoachConversation } from "@/components/insights/coach-panel/coach-conversation";
|
||||
import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
|
||||
import { useFeatureFlags } from "@/hooks/use-feature-flags";
|
||||
@@ -100,7 +99,6 @@ export default function CoachPage() {
|
||||
)}
|
||||
leadingHeaderActions={
|
||||
<>
|
||||
<ModuleTourTrigger stopId="coach" />
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
|
||||
@@ -16,7 +16,6 @@ import { useMounted } from "@/hooks/use-mounted";
|
||||
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger";
|
||||
|
||||
export default function LabsPage() {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
@@ -68,7 +67,6 @@ export default function LabsPage() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<ModuleTourTrigger stopId="labs" />
|
||||
{/* v1.18.6 (MOD-01) — the wrench is the module's customize entry
|
||||
point, left of the primary Add and linking to the Labs settings
|
||||
page (view, sort order, biomarker CRUD + reorder). Mirrors the
|
||||
|
||||
@@ -16,7 +16,6 @@ import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { Plus, Loader2 } from "lucide-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger";
|
||||
|
||||
export default function MeasurementsPage() {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
@@ -125,7 +124,6 @@ export default function MeasurementsPage() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<ModuleTourTrigger stopId="measurements" />
|
||||
<Button
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => setDialogOpen(true)}
|
||||
|
||||
@@ -7,7 +7,6 @@ import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger";
|
||||
import { MedicationWizardDialog } from "@/components/medications/wizard/MedicationWizardDialog";
|
||||
import { MedicationCard } from "@/components/medications/medication-card";
|
||||
import { Glp1MedicationCard } from "@/components/medications/glp1-medication-card";
|
||||
@@ -342,7 +341,6 @@ export default function MedicationsPage() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<ModuleTourTrigger stopId="medications" />
|
||||
{/* v1.16.11 (#316) — "Alle fälligen einnehmen". Contextual, not a
|
||||
permanent fixture: it renders only while at least TWO
|
||||
medications are currently due (a single due dose is the
|
||||
|
||||
@@ -15,7 +15,6 @@ import { Plus, Loader2, Wrench } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger";
|
||||
|
||||
export default function MoodPage() {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
@@ -79,7 +78,6 @@ export default function MoodPage() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<ModuleTourTrigger stopId="mood" />
|
||||
{/* v1.17 — the wrench is the one customize entry point: it
|
||||
links to /settings/mood, which owns the tag groups, custom
|
||||
tags, visibility, and picker order. Same glyph, slot (left
|
||||
|
||||
@@ -8,7 +8,6 @@ import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CycleRing } from "./cycle-ring";
|
||||
import { BbtChart } from "./bbt-chart";
|
||||
@@ -151,7 +150,6 @@ export function CycleView() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<ModuleTourTrigger stopId="cycle" />
|
||||
<Button
|
||||
onClick={() => openSheet(today)}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { getHourForTimeZone } from "@/components/dashboard/range-display";
|
||||
@@ -97,8 +96,6 @@ export function DashboardHeader({
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{/* v1.18.6 — re-enter the guided module tour from the dashboard. */}
|
||||
<ModuleTourTrigger stopId="dashboardOverview" />
|
||||
{/* Customize shortcut to the dashboard-customization settings
|
||||
(the tile/layout editor at /settings/dashboard). Sits to
|
||||
the left of the add button as a monochrome ghost icon, with
|
||||
|
||||
@@ -33,7 +33,6 @@ import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { applyOrder, useModuleListPrefs } from "@/lib/module-list-prefs";
|
||||
import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger";
|
||||
|
||||
import { LogDaySheet } from "./log-day-sheet";
|
||||
import { NewEpisodeSheet } from "./new-episode-sheet";
|
||||
@@ -380,7 +379,6 @@ export function IllnessView() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<ModuleTourTrigger stopId="illness" />
|
||||
{/* v1.18.6 (MOD-01) — wrench left of the primary Add, linking to the
|
||||
Illness settings page (view + reorder). */}
|
||||
<Button
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
|
||||
import {
|
||||
@@ -863,7 +862,6 @@ function InsightsTabStripImpl({
|
||||
{/* v1.18.6 — per-module tour re-entry, travelling with the
|
||||
top-right control cluster. Anchors to this strip's
|
||||
`insights-hero` and opens the Insights tour card on the spot. */}
|
||||
<ModuleTourTrigger stopId="insights" />
|
||||
{/* v1.15.18 — customise cog. Sits immediately LEFT of the
|
||||
regenerate button and links to the Insights settings section
|
||||
(overview arrange + pill sort), matching the Dashboard cog
|
||||
|
||||
@@ -10,20 +10,15 @@
|
||||
* The fix turns the tour into "the second-visit thing": auto-launch
|
||||
* is suppressed until `onboardingCompletedAt + 24 h`. This file pins
|
||||
* the pure decision helper so a future refactor doesn't silently
|
||||
* regress the gate. The "manual restart from Settings" path bypasses
|
||||
* the gate entirely and lives in `<TourLauncher>`'s
|
||||
* `healthlog:tour-restart` window event listener — exercised by the
|
||||
* sections SSR smoke test (`settings/__tests__/sections.test.tsx`) +
|
||||
* the e2e suite, not by this file.
|
||||
* regress the gate. v1.18.6.1 — the tour is first-time-auto-start
|
||||
* only; the former "manual restart from Settings" bypass was removed.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
setTourForceLaunch,
|
||||
shouldAutoLaunchTour,
|
||||
tourIncludesAchievements,
|
||||
TOUR_AUTOLAUNCH_DELAY_MS,
|
||||
tourForceLaunchKey,
|
||||
} from "../tour-launcher";
|
||||
|
||||
const NOW = Date.parse("2026-05-21T12:00:00Z");
|
||||
@@ -153,68 +148,3 @@ describe("tourIncludesAchievements() — v1.18.0 B5 module gate", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setTourForceLaunch() — v1.4.47 W5 manual replay bypass", () => {
|
||||
// (c) of the W5 acceptance: the manual "launch tour" trigger still
|
||||
// works. The launcher's render-phase gate is exercised by
|
||||
// shouldAutoLaunchTour() above; the *bypass* is a sessionStorage
|
||||
// marker the Settings buttons drop and the launcher reads-and-
|
||||
// clears on its next mount. Vitest runs in the Node environment by
|
||||
// default — stub a minimal window so the helper's window-guard +
|
||||
// try/catch wrappers can be exercised end-to-end.
|
||||
let storage: Map<string, string>;
|
||||
const originalWindow = (globalThis as { window?: unknown }).window;
|
||||
|
||||
beforeEach(() => {
|
||||
storage = new Map<string, string>();
|
||||
(globalThis as { window?: unknown }).window = {
|
||||
sessionStorage: {
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
storage.set(key, value);
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
storage.delete(key);
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalWindow === undefined) {
|
||||
delete (globalThis as { window?: unknown }).window;
|
||||
} else {
|
||||
(globalThis as { window?: unknown }).window = originalWindow;
|
||||
}
|
||||
});
|
||||
|
||||
it("writes the per-user force-launch marker into sessionStorage", () => {
|
||||
// The Settings → About + Settings → Account "Replay tour" buttons
|
||||
// call this exact helper after the API round-trip succeeds. The
|
||||
// launcher reads-and-clears the marker on its next dashboard
|
||||
// mount, bypassing the 24 h auto-launch gate so a same-day user
|
||||
// who explicitly asked for the tour actually gets it.
|
||||
setTourForceLaunch("u_replay_demo");
|
||||
expect(storage.get(tourForceLaunchKey("u_replay_demo"))).toBe("1");
|
||||
});
|
||||
|
||||
it("scopes the marker per user (two users do not bleed into each other)", () => {
|
||||
setTourForceLaunch("u_alice");
|
||||
setTourForceLaunch("u_bob");
|
||||
expect(storage.get(tourForceLaunchKey("u_alice"))).toBe("1");
|
||||
expect(storage.get(tourForceLaunchKey("u_bob"))).toBe("1");
|
||||
// The two keys are independent slots — clearing one (which the
|
||||
// launcher does on mount) must not affect the other.
|
||||
storage.delete(tourForceLaunchKey("u_alice"));
|
||||
expect(storage.get(tourForceLaunchKey("u_bob"))).toBe("1");
|
||||
});
|
||||
|
||||
it("does not throw when window is undefined (SSR safety)", () => {
|
||||
// The launcher itself is "use client" but the helper is exported
|
||||
// from the same module and could be imported by a server component
|
||||
// in the future. The window-guard inside the helper has to keep
|
||||
// SSR happy — verify it stays a silent no-op.
|
||||
delete (globalThis as { window?: unknown }).window;
|
||||
expect(() => setTourForceLaunch("u_x")).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -12,16 +12,12 @@
|
||||
* accept that one-off tax in exchange for the multi-tenant
|
||||
* correctness.
|
||||
*
|
||||
* v1.4.47 W5 adds a third key, `tourForceLaunchKey`, that carries
|
||||
* the "user just clicked Replay in Settings → About/Account" intent
|
||||
* across the navigation back to the dashboard. The launcher reads
|
||||
* and clears it on its next mount, bypassing the new 24 h
|
||||
* auto-launch gate so a first-day user who *wants* the tour
|
||||
* actually gets it.
|
||||
* v1.18.6.1 — the tour is first-time-auto-start only; the former
|
||||
* `tourForceLaunchKey` replay-bypass key was removed along with every
|
||||
* in-app tour re-entry trigger.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
tourForceLaunchKey,
|
||||
tourReferrerKey,
|
||||
tourSessionDismissedKey,
|
||||
} from "../tour-launcher";
|
||||
@@ -37,20 +33,11 @@ describe("tour-launcher sessionStorage key scoping", () => {
|
||||
expect(tourReferrerKey("u_abc")).toBe("healthlog-tour-referrer:u_abc");
|
||||
});
|
||||
|
||||
it("scopes the force-launch key by user id (v1.4.47 W5)", () => {
|
||||
expect(tourForceLaunchKey("u_abc")).toBe(
|
||||
"healthlog-tour-force-launch:u_abc",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns distinct keys for distinct users", () => {
|
||||
expect(tourSessionDismissedKey("u_alice")).not.toBe(
|
||||
tourSessionDismissedKey("u_bob"),
|
||||
);
|
||||
expect(tourReferrerKey("u_alice")).not.toBe(tourReferrerKey("u_bob"));
|
||||
expect(tourForceLaunchKey("u_alice")).not.toBe(
|
||||
tourForceLaunchKey("u_bob"),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not collide with the legacy un-scoped keys", () => {
|
||||
@@ -58,20 +45,17 @@ describe("tour-launcher sessionStorage key scoping", () => {
|
||||
"healthlog-tour-session-dismissed",
|
||||
);
|
||||
expect(tourReferrerKey("u_x")).not.toBe("healthlog-tour-referrer");
|
||||
expect(tourForceLaunchKey("u_x")).not.toBe("healthlog-tour-force-launch");
|
||||
});
|
||||
|
||||
it("uses three distinct namespaces so the keys can never alias each other", () => {
|
||||
it("uses distinct namespaces so the keys can never alias each other", () => {
|
||||
// Belt-and-braces: if a future refactor accidentally collapses
|
||||
// two key builders onto the same prefix, the launcher's gating
|
||||
// logic would conflate "session-dismissed" with "force-launch"
|
||||
// and produce a one-shot tour-loop on every dashboard mount.
|
||||
// logic would conflate "session-dismissed" with "referrer".
|
||||
const userId = "u_test";
|
||||
const keys = new Set([
|
||||
tourSessionDismissedKey(userId),
|
||||
tourReferrerKey(userId),
|
||||
tourForceLaunchKey(userId),
|
||||
]);
|
||||
expect(keys.size).toBe(3);
|
||||
expect(keys.size).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -1,53 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.18.6 — per-module "Diese Tour zeigen" re-entry.
|
||||
*
|
||||
* A small ghost button each module page drops into its header action
|
||||
* cluster. It dispatches the `healthlog:module-tour` window event with
|
||||
* the page's stop id; the shell-level `<TourLauncher>` picks it up and
|
||||
* opens the spotlight narrowed to that single module card on the
|
||||
* current page (no cross-page navigation, no completion flip).
|
||||
*
|
||||
* Anchors to the page's `data-tour-id="<…>-hero"` element via the launcher
|
||||
* → overlay path. The button itself carries no anchor — it only fires
|
||||
* the event.
|
||||
*/
|
||||
|
||||
import { Compass } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { TourStopId } from "@/lib/onboarding/tour-state";
|
||||
|
||||
export function ModuleTourTrigger({ stopId }: { stopId: TourStopId }) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
function show() {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("healthlog:module-tour", { detail: { stopId } }),
|
||||
);
|
||||
} catch {
|
||||
/* ignore — sandboxed iframes etc. */
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={show}
|
||||
// 44px mobile tap floor, matching the rest of the module headers.
|
||||
className="text-muted-foreground hover:text-foreground min-h-11 sm:min-h-9"
|
||||
data-testid={`module-tour-trigger-${stopId}`}
|
||||
>
|
||||
<Compass className="h-4 w-4" />
|
||||
<span className="sr-only sm:not-sr-only">
|
||||
{t("onboarding.tour.moduleTrigger")}
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -5,8 +5,9 @@
|
||||
*
|
||||
* Decides *when* to start the spotlight tour. Mounted on the
|
||||
* dashboard; opens the tour exactly once for new users and never
|
||||
* again unless they explicitly request a replay from
|
||||
* Settings → Account or Settings → About.
|
||||
* again. v1.18.6.1 — the tour exists ONLY as this first-time
|
||||
* auto-start: every in-app re-entry trigger (the per-module "show
|
||||
* this tour" buttons and the Settings replay card) was removed.
|
||||
*
|
||||
* Gating rules:
|
||||
*
|
||||
@@ -34,17 +35,8 @@
|
||||
* before the user gets to touch the dashboard. The tour now
|
||||
* auto-launches only after the wizard has been finished at
|
||||
* least 24 h ago. Brand-new users (`onboardingCompletedAt ==
|
||||
* null`) never see the auto-launch — they can still trigger
|
||||
* the tour manually from Settings → About or Settings →
|
||||
* Account.
|
||||
*
|
||||
* Replay flow: the Settings → Account "Restart" button (and the
|
||||
* Settings → About "Replay" button added in v1.4.47 W5) POST
|
||||
* `{ completed: false }` to /api/onboarding/tour, then dispatch a
|
||||
* `healthlog:tour-restart` window event. This launcher listens for
|
||||
* that event and force-opens the tour ignoring the auto-launch gate
|
||||
* (24 h delay, session-dismissed flag, post-wizard grace) so the
|
||||
* manual replay always works.
|
||||
* null`) never see the auto-launch on the same session they
|
||||
* finished onboarding.
|
||||
*/
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
@@ -53,7 +45,6 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { apiPost } from "@/lib/api/api-fetch";
|
||||
import type { TourStopId } from "@/lib/onboarding/tour-state";
|
||||
import { OnboardingTour } from "./tour";
|
||||
|
||||
const POST_WIZARD_GRACE_MS = 1500;
|
||||
@@ -148,48 +139,6 @@ export function tourReferrerKey(userId: string): string {
|
||||
return `healthlog-tour-referrer:${userId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.47 W5 — sessionStorage key used by the Settings → About
|
||||
* "Replay the tour" + Settings → Account "Restart onboarding tour"
|
||||
* buttons to bypass the 24 h auto-launch gate on the next dashboard
|
||||
* mount. Without this, a first-day user who manually clicks the
|
||||
* replay button would still hit the 24 h block after navigating to
|
||||
* the dashboard (because the launcher mounts fresh and re-evaluates
|
||||
* the auto-launch gate). Exported for the unit-test suite + so the
|
||||
* key construction stays pinned across refactors.
|
||||
*/
|
||||
export function tourForceLaunchKey(userId: string): string {
|
||||
return `healthlog-tour-force-launch:${userId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the force-launch marker. Called by both Settings → Account
|
||||
* and Settings → About when the user explicitly requests the tour.
|
||||
* Exported so the Settings buttons can call it without re-implementing
|
||||
* the sessionStorage dance (and so the test suite can stub it).
|
||||
*/
|
||||
export function setTourForceLaunch(userId: string): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.sessionStorage.setItem(tourForceLaunchKey(userId), "1");
|
||||
} catch {
|
||||
/* ignore — sandboxed iframes etc. */
|
||||
}
|
||||
}
|
||||
|
||||
function readAndClearForceLaunch(userId: string): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
try {
|
||||
const key = tourForceLaunchKey(userId);
|
||||
const value = window.sessionStorage.getItem(key);
|
||||
if (value !== "1") return false;
|
||||
window.sessionStorage.removeItem(key);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the user just navigated here from `/onboarding`. Read
|
||||
* once at mount via a `useState` lazy initialiser (which runs in a
|
||||
@@ -253,15 +202,6 @@ function writeSessionDismissed(userId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function clearSessionDismissed(userId: string) {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.sessionStorage.removeItem(tourSessionDismissedKey(userId));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
interface TourLauncherProps {
|
||||
/**
|
||||
* v1.18.6 — optional readiness sentinel. The dashboard used to pass
|
||||
@@ -287,7 +227,6 @@ export function TourLauncher({ ready = true }: TourLauncherProps) {
|
||||
// v1.18.6 — when the tour opens for a single-module re-entry
|
||||
// ("Diese Tour zeigen" on a module page) this carries the stop id so
|
||||
// the overlay renders just that card. `null` = the full tour.
|
||||
const [singleStop, setSingleStop] = useState<TourStopId | null>(null);
|
||||
// Track post-wizard deferral as a boolean so the render path stays
|
||||
// pure (no Date.now() comparisons). The effect below reads / clears it.
|
||||
const [deferredFromWizard, setDeferredFromWizard] = useState(false);
|
||||
@@ -347,17 +286,9 @@ export function TourLauncher({ ready = true }: TourLauncherProps) {
|
||||
// render body). The `decidedFor` tuple above further guarantees
|
||||
// this branch runs at most once per (user, flag) transition.
|
||||
//
|
||||
// Force-launch bypass: when the user explicitly requests the tour
|
||||
// from Settings → About / Settings → Account, those buttons set a
|
||||
// per-user sessionStorage marker via `setTourForceLaunch()`. The
|
||||
// launcher reads-and-clears the marker here so the tour launches
|
||||
// on the very next dashboard mount — independent of the 24 h gate,
|
||||
// the session-dismissed flag, and the post-wizard grace. The
|
||||
// marker survives navigation but not a page reload, which matches
|
||||
// user expectation ("I clicked replay, now show me the tour").
|
||||
if (readAndClearForceLaunch(user.id)) {
|
||||
setShowTour(true);
|
||||
} else if (
|
||||
// v1.18.6.1 — the tour is now first-time-auto-start only; there is
|
||||
// no in-app replay trigger and therefore no force-launch bypass.
|
||||
if (
|
||||
!shouldAutoLaunchTour({
|
||||
onboardingTourCompleted: user.onboardingTourCompleted,
|
||||
onboardingCompletedAt: user.onboardingCompletedAt,
|
||||
@@ -396,39 +327,10 @@ export function TourLauncher({ ready = true }: TourLauncherProps) {
|
||||
return () => window.clearTimeout(id);
|
||||
}, [deferredFromWizard, user?.id]);
|
||||
|
||||
// Listen for explicit restart events from Settings → Advanced. A
|
||||
// restart opens the FULL tour from the top (singleStop cleared).
|
||||
// setState inside a window-event listener is fine under the
|
||||
// set-state-in-effect rule.
|
||||
const userIdForRestart = user?.id;
|
||||
useEffect(() => {
|
||||
function onRestart() {
|
||||
if (userIdForRestart) clearSessionDismissed(userIdForRestart);
|
||||
setSingleStop(null);
|
||||
setShowTour(true);
|
||||
}
|
||||
window.addEventListener("healthlog:tour-restart", onRestart);
|
||||
return () => {
|
||||
window.removeEventListener("healthlog:tour-restart", onRestart);
|
||||
};
|
||||
}, [userIdForRestart]);
|
||||
|
||||
// v1.18.6 — per-module "Diese Tour zeigen" re-entry. A module page
|
||||
// dispatches `healthlog:module-tour` with `{ stopId }`; the launcher
|
||||
// opens the overlay narrowed to that single card on the current page
|
||||
// (no cross-page navigation, no completion flip).
|
||||
useEffect(() => {
|
||||
function onModuleTour(e: Event) {
|
||||
const detail = (e as CustomEvent<{ stopId?: string }>).detail;
|
||||
if (!detail?.stopId) return;
|
||||
setSingleStop(detail.stopId as TourStopId);
|
||||
setShowTour(true);
|
||||
}
|
||||
window.addEventListener("healthlog:module-tour", onModuleTour);
|
||||
return () => {
|
||||
window.removeEventListener("healthlog:module-tour", onModuleTour);
|
||||
};
|
||||
}, []);
|
||||
// v1.18.6.1 — the guided tour exists ONLY as the first-time auto-start
|
||||
// after onboarding. The per-module "show this tour" re-entry and the
|
||||
// Settings replay card were removed, so there is no longer any in-app
|
||||
// trigger that re-opens the overlay.
|
||||
|
||||
// v1.18.6 — stable progress callback so the overlay's checkpoint effect
|
||||
// fires on a real step transition, not on every launcher re-render.
|
||||
@@ -446,20 +348,15 @@ export function TourLauncher({ ready = true }: TourLauncherProps) {
|
||||
// The resolved module map gates which stops appear (default-on) and
|
||||
// keeps the "Schritt n/total" counter honest.
|
||||
const modules = user?.modules;
|
||||
// v1.18.6 — resume the full tour from the persisted checkpoint. The
|
||||
// single-stop re-entry ignores the resume point (it opens its card).
|
||||
const resumeFromStopId = singleStop
|
||||
? null
|
||||
: (user?.onboardingTourProgress?.lastStopId ?? null);
|
||||
// v1.18.6 — resume the full tour from the persisted checkpoint.
|
||||
const resumeFromStopId = user?.onboardingTourProgress?.lastStopId ?? null;
|
||||
|
||||
return (
|
||||
<OnboardingTour
|
||||
modules={modules}
|
||||
resumeFromStopId={resumeFromStopId}
|
||||
filterToStop={singleStop ?? undefined}
|
||||
// Fire-and-forget checkpoint so a mid-tour reload resumes at the
|
||||
// right module. Skipped implicitly by the overlay for the single-
|
||||
// stop re-entry. A failed write is non-fatal — the resume point is
|
||||
// right module. A failed write is non-fatal — the resume point is
|
||||
// a convenience, not a correctness invariant.
|
||||
onProgress={handleProgress}
|
||||
onClose={async (outcome) => {
|
||||
@@ -470,13 +367,7 @@ export function TourLauncher({ ready = true }: TourLauncherProps) {
|
||||
// for the rest of the session, and the next refetch of
|
||||
// `/api/auth/me` (which the user gets on any nav) will
|
||||
// surface any divergence.
|
||||
const wasSingleStop = singleStop !== null;
|
||||
setShowTour(false);
|
||||
setSingleStop(null);
|
||||
// A single-stop re-entry must NOT flip the global completion
|
||||
// flag or write the session-dismiss guard — it's one card, not
|
||||
// "the tour". Just close.
|
||||
if (wasSingleStop) return;
|
||||
if (user?.id) writeSessionDismissed(user.id);
|
||||
try {
|
||||
await apiPost("/api/onboarding/tour", { completed: true, outcome });
|
||||
|
||||
@@ -136,15 +136,14 @@ describe("settings sections — SSR smoke", () => {
|
||||
expect(html).not.toContain("settings.sections.");
|
||||
});
|
||||
|
||||
it("<AdvancedSection> exposes the single Restart-tour card", () => {
|
||||
// v1.18.1 (D1) — the tour-replay card moved from Account into Advanced.
|
||||
// v1.18.6 — relabelled "Restart module tour" and consolidated as the one
|
||||
// full-tour replay home (the About duplicate was removed).
|
||||
it("<AdvancedSection> no longer carries a tour-replay card", () => {
|
||||
// v1.18.6.1 — the guided tour exists only as the first-time auto-start
|
||||
// after onboarding. Every in-app replay / re-entry trigger was removed,
|
||||
// including this Advanced card.
|
||||
const html = render(<AdvancedSection />);
|
||||
expect(html).toContain('data-testid="settings-restart-tour"');
|
||||
expect(html).toContain("Restart module tour");
|
||||
// No raw i18n key leaks.
|
||||
expect(html).not.toContain("onboarding.tour.");
|
||||
expect(html).not.toContain('data-testid="settings-restart-tour"');
|
||||
expect(html).not.toContain("Restart module tour");
|
||||
expect(html).not.toContain("onboarding.tour.restart");
|
||||
});
|
||||
|
||||
it("<AccountSection> no longer carries the Restart-tour button", () => {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { BookOpenCheck, Compass, Loader2, Trash2 } from "lucide-react";
|
||||
import { BookOpenCheck, Loader2, Trash2 } from "lucide-react";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -20,9 +20,7 @@ import { Switch } from "@/components/ui/switch";
|
||||
import { ResearchModeAcknowledgmentDialog } from "@/components/medications/ResearchModeAcknowledgmentDialog";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { restartOnboardingTour } from "@/lib/onboarding/tour-restart";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
type ResearchModeStatus,
|
||||
@@ -53,10 +51,11 @@ import { apiFetchRaw } from "@/lib/api/api-fetch";
|
||||
export function AdvancedSection() {
|
||||
// v1.18.6 (W9) — the visible heading + subtitle now come from the shared
|
||||
// `<SettingsSectionFrame>` in the route; this body is the advanced cards.
|
||||
// v1.18.6.1 — the guided tour replay card was removed: the tour now exists
|
||||
// only as the first-time auto-start after onboarding. This page is the
|
||||
// research-mode + data/account destructive controls.
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* v1.18.1 (D1) — "Tour neu starten" relocated here from Account. */}
|
||||
<TourReplayCard />
|
||||
<ResearchModeCard />
|
||||
<DataResetCard />
|
||||
<AccountDeleteCard />
|
||||
@@ -64,85 +63,6 @@ export function AdvancedSection() {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.18.1 (D1) — onboarding-tour replay, relocated from Settings → Account.
|
||||
*
|
||||
* Resets `users.onboarding_tour_completed` on the server and dispatches a
|
||||
* `healthlog:tour-restart` window event so a dashboard already in the
|
||||
* background reopens the spotlight tour immediately. Delegates to the shared
|
||||
* `restartOnboardingTour()` worker (the same one Settings → About uses) and
|
||||
* refetches the auth payload so the launcher's `onboardingTourCompleted` flag
|
||||
* matches the server flip without a navigation.
|
||||
*/
|
||||
function TourReplayCard() {
|
||||
const { t } = useTranslations();
|
||||
const { user, refetch } = useAuth();
|
||||
const [restarting, setRestarting] = useState(false);
|
||||
const [feedback, setFeedback] = useState<{
|
||||
key: string;
|
||||
type: "success" | "error";
|
||||
} | null>(null);
|
||||
|
||||
async function handleRestartTour() {
|
||||
setRestarting(true);
|
||||
setFeedback(null);
|
||||
const result = await restartOnboardingTour(user?.id);
|
||||
if (result.ok) {
|
||||
await refetch();
|
||||
setFeedback({
|
||||
key: "onboarding.tour.restartConfirmation",
|
||||
type: "success",
|
||||
});
|
||||
} else {
|
||||
setFeedback({ key: result.messageKey, type: "error" });
|
||||
}
|
||||
setRestarting(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<Compass className="text-muted-foreground mt-0.5 h-5 w-5 shrink-0" />
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("onboarding.tour.restart")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("onboarding.tour.restartHint")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleRestartTour}
|
||||
disabled={restarting}
|
||||
data-testid="settings-restart-tour"
|
||||
className="w-full shrink-0 sm:w-auto"
|
||||
>
|
||||
{restarting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Compass className="h-4 w-4" />
|
||||
)}
|
||||
{t("onboarding.tour.restart")}
|
||||
</Button>
|
||||
</div>
|
||||
{feedback && (
|
||||
<p
|
||||
role="alert"
|
||||
className={`mt-2 text-xs ${
|
||||
feedback.type === "success" ? "text-success" : "text-destructive"
|
||||
}`}
|
||||
>
|
||||
{t(feedback.key)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.25 W21 Fix-N (simp-H3) — pull the nested ternary that decided
|
||||
* the toggle's caption out of the JSX. The four-way ladder (loading /
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Stub the launcher import so the helper test never has to instantiate
|
||||
// the @dnd-kit / launcher render tree just to exercise its
|
||||
// sessionStorage write side-effect. We assert the helper *calls* it on
|
||||
// the success branch and skips it when no user-id was passed in.
|
||||
const setTourForceLaunchSpy = vi.fn();
|
||||
vi.mock("@/components/onboarding/tour-launcher", () => ({
|
||||
setTourForceLaunch: (userId: string) => setTourForceLaunchSpy(userId),
|
||||
}));
|
||||
|
||||
import { restartOnboardingTour } from "../tour-restart";
|
||||
|
||||
describe("restartOnboardingTour()", () => {
|
||||
beforeEach(() => {
|
||||
setTourForceLaunchSpy.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("success branch — flips the server flag, fires the force-launch marker, returns { ok: true }", async () => {
|
||||
const fetchImpl = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockResolvedValue(new Response("{}", { status: 200 }));
|
||||
|
||||
const result = await restartOnboardingTour("user-42", { fetchImpl });
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = fetchImpl.mock.calls[0];
|
||||
expect(url).toBe("/api/onboarding/tour");
|
||||
expect(init?.method).toBe("POST");
|
||||
expect(init?.headers).toEqual({ "Content-Type": "application/json" });
|
||||
expect(init?.body).toBe(JSON.stringify({ completed: false }));
|
||||
// Per-user force-launch marker fired on success.
|
||||
expect(setTourForceLaunchSpy).toHaveBeenCalledWith("user-42");
|
||||
});
|
||||
|
||||
it("success branch — skips the force-launch marker when no userId was supplied", async () => {
|
||||
const fetchImpl = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockResolvedValue(new Response("{}", { status: 200 }));
|
||||
|
||||
const result = await restartOnboardingTour(undefined, { fetchImpl });
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(setTourForceLaunchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("error-network branch — fetch rejects, returns { ok: false } with common.networkError", async () => {
|
||||
const fetchImpl = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockRejectedValue(new TypeError("network down"));
|
||||
|
||||
const result = await restartOnboardingTour("user-42", { fetchImpl });
|
||||
|
||||
expect(result).toEqual({ ok: false, messageKey: "common.networkError" });
|
||||
// No force-launch marker should fire on the error branch — a failed
|
||||
// server flip MUST NOT arm the launcher.
|
||||
expect(setTourForceLaunchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("error-server-422 branch — non-ok response returns { ok: false } with settings.savingError", async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(
|
||||
new Response(JSON.stringify({ error: "Unprocessable" }), {
|
||||
status: 422,
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await restartOnboardingTour("user-42", { fetchImpl });
|
||||
|
||||
expect(result).toEqual({ ok: false, messageKey: "settings.savingError" });
|
||||
expect(setTourForceLaunchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,83 +0,0 @@
|
||||
/**
|
||||
* v1.4.48 M6b — shared "restart onboarding tour" worker.
|
||||
*
|
||||
* `<AccountSection>` and `<AboutSection>` both expose a button that
|
||||
* resets `users.onboarding_tour_completed = false` and arranges for the
|
||||
* dashboard's `<TourLauncher>` to re-open the spotlight on the user's
|
||||
* next navigation. Until v1.4.48 the two handlers were 90 % identical
|
||||
* copy-paste — a known drift hazard. This helper owns the surface so
|
||||
* both call sites narrow to a single mutate-then-route step:
|
||||
*
|
||||
* const result = await restartOnboardingTour(user?.id);
|
||||
* if (result.ok) toast(t("onboarding.tour.restartConfirmation"));
|
||||
* else toast(t(result.messageKey));
|
||||
*
|
||||
* Side-effects this helper owns:
|
||||
*
|
||||
* - PUT `/api/onboarding/tour` with `{ completed: false }` (the same
|
||||
* server flip both Account and About already drove).
|
||||
* - On success — write the per-user `setTourForceLaunch(userId)`
|
||||
* marker into sessionStorage so the next dashboard mount opens
|
||||
* the tour even when the 24 h auto-launch gate would suppress it.
|
||||
* - Fire a `healthlog:tour-restart` window event so a dashboard
|
||||
* mounted in another tab / background reopens the spotlight
|
||||
* immediately. The dispatch is best-effort; SSR or sandboxed
|
||||
* iframes that can't construct CustomEvent are tolerated.
|
||||
*
|
||||
* Translation + toast rendering are deliberately NOT owned here — the
|
||||
* helper returns a discriminated `{ ok }` result and lets the React
|
||||
* component pick the right `useTranslations()` instance. Keeps the
|
||||
* helper pure of i18n state and easy to unit-test under the default
|
||||
* vitest "node" environment.
|
||||
*/
|
||||
|
||||
import { setTourForceLaunch } from "@/components/onboarding/tour-launcher";
|
||||
|
||||
export type RestartTourResult =
|
||||
| { ok: true }
|
||||
| { ok: false; messageKey: string };
|
||||
|
||||
export interface RestartTourOptions {
|
||||
/**
|
||||
* Fetch implementation injected for tests. Defaults to the global
|
||||
* `fetch` so production code paths read exactly as before.
|
||||
*/
|
||||
fetchImpl?: typeof fetch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the tour-replay request server-side and arm the launcher
|
||||
* for the user's next dashboard navigation.
|
||||
*
|
||||
* `userId` may be undefined when the auth payload hasn't landed yet —
|
||||
* the server flip still fires, and the force-launch marker is simply
|
||||
* skipped (the launcher's secondary "tour-completed = false" path
|
||||
* still picks the change up on the next `/api/auth/me` read).
|
||||
*/
|
||||
export async function restartOnboardingTour(
|
||||
userId: string | undefined,
|
||||
opts: RestartTourOptions = {},
|
||||
): Promise<RestartTourResult> {
|
||||
const fetchImpl = opts.fetchImpl ?? fetch;
|
||||
try {
|
||||
const res = await fetchImpl("/api/onboarding/tour", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ completed: false }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
return { ok: false, messageKey: "settings.savingError" };
|
||||
}
|
||||
if (userId) setTourForceLaunch(userId);
|
||||
try {
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent("healthlog:tour-restart"));
|
||||
}
|
||||
} catch {
|
||||
/* ignore — only matters if a dashboard is already mounted */
|
||||
}
|
||||
return { ok: true };
|
||||
} catch {
|
||||
return { ok: false, messageKey: "common.networkError" };
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user