diff --git a/messages/de.json b/messages/de.json index 321cf3dc0..da00a7739 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index 2dd3fb4f3..4a9917e07 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index e5a4a8a3d..d922014dd 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index a9b43fc82..4837f53e2 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index 672aaa46a..a285b0228 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index b4ccb8202..8d176eccc 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/src/app/achievements/page.tsx b/src/app/achievements/page.tsx index f0a9448c3..98dae02ee 100644 --- a/src/app/achievements/page.tsx +++ b/src/app/achievements/page.tsx @@ -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() {

-
diff --git a/src/app/coach/page.tsx b/src/app/coach/page.tsx index 9ffb5a694..49e130fb6 100644 --- a/src/app/coach/page.tsx +++ b/src/app/coach/page.tsx @@ -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={ <> - - ); -} diff --git a/src/components/onboarding/tour-launcher.tsx b/src/components/onboarding/tour-launcher.tsx index c76b6c0d1..02a7e5869 100644 --- a/src/components/onboarding/tour-launcher.tsx +++ b/src/components/onboarding/tour-launcher.tsx @@ -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(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 ( { @@ -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 }); diff --git a/src/components/settings/__tests__/sections.test.tsx b/src/components/settings/__tests__/sections.test.tsx index 34ac19d7e..296ce813a 100644 --- a/src/components/settings/__tests__/sections.test.tsx +++ b/src/components/settings/__tests__/sections.test.tsx @@ -136,15 +136,14 @@ describe("settings sections — SSR smoke", () => { expect(html).not.toContain("settings.sections."); }); - it(" 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(" 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(); - 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(" no longer carries the Restart-tour button", () => { diff --git a/src/components/settings/advanced-section.tsx b/src/components/settings/advanced-section.tsx index b8710d71f..7650cefee 100644 --- a/src/components/settings/advanced-section.tsx +++ b/src/components/settings/advanced-section.tsx @@ -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 // `` 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 (
- {/* v1.18.1 (D1) — "Tour neu starten" relocated here from Account. */} - @@ -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 ( -
-
-
- -
-

- {t("onboarding.tour.restart")} -

-

- {t("onboarding.tour.restartHint")} -

-
-
- -
- {feedback && ( -

- {t(feedback.key)} -

- )} -
- ); -} - /** * 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 / diff --git a/src/lib/onboarding/__tests__/tour-restart.test.ts b/src/lib/onboarding/__tests__/tour-restart.test.ts deleted file mode 100644 index fb5b0c08b..000000000 --- a/src/lib/onboarding/__tests__/tour-restart.test.ts +++ /dev/null @@ -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() - .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() - .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() - .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().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(); - }); -}); diff --git a/src/lib/onboarding/tour-restart.ts b/src/lib/onboarding/tour-restart.ts deleted file mode 100644 index 75c395c8e..000000000 --- a/src/lib/onboarding/tour-restart.ts +++ /dev/null @@ -1,83 +0,0 @@ -/** - * v1.4.48 M6b — shared "restart onboarding tour" worker. - * - * `` and `` both expose a button that - * resets `users.onboarding_tour_completed = false` and arranges for the - * dashboard's `` 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 { - 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" }; - } -}