mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 23:12:04 +00:00
merge(W4): module-by-module guided tour
Extend the in-house spotlight into a cross-page tour: 15 module stops, each gated on its module, walking the app and teaching what each module does and how it feeds the others. Auto-starts after the anamnesis, resumes from onboarding_tour_progress_json, re-enters per-module via a header trigger, and replays from one Settings card. Adds the tour progress pref + migration 0173 and the onboarding tour checkpoint route. # Conflicts: # src/components/onboarding/DoneScreen.tsx
This commit is contained in:
commit
1d83e10764
46 files changed
+1659
-564
No files matched your search
@@ -8,6 +8,7 @@ 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";
|
||||
@@ -139,20 +140,26 @@ export function CycleView() {
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
<h1
|
||||
data-tour-id="cycle-hero"
|
||||
className="text-2xl font-bold tracking-tight"
|
||||
>
|
||||
{t("cycle.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-xs sm:text-sm">
|
||||
{t("cycle.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => openSheet(today)}
|
||||
className="min-h-11 shrink-0 sm:min-h-9"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("cycle.logToday")}
|
||||
</Button>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<ModuleTourTrigger stopId="cycle" />
|
||||
<Button
|
||||
onClick={() => openSheet(today)}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("cycle.logToday")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop: ring (left) + tabs (right). Single column below lg. */}
|
||||
|
||||
@@ -23,6 +23,7 @@ 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";
|
||||
@@ -96,6 +97,8 @@ 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
|
||||
|
||||
@@ -32,6 +32,7 @@ import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger";
|
||||
|
||||
import { LogDaySheet } from "./log-day-sheet";
|
||||
import { NewEpisodeSheet } from "./new-episode-sheet";
|
||||
@@ -256,20 +257,26 @@ export function IllnessView() {
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
<h1
|
||||
data-tour-id="illness-hero"
|
||||
className="text-2xl font-bold tracking-tight"
|
||||
>
|
||||
{t("illness.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-xs sm:text-sm">
|
||||
{t("illness.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setNewOpen(true)}
|
||||
className="min-h-11 shrink-0 sm:min-h-9"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("illness.newEpisode")}
|
||||
</Button>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<ModuleTourTrigger stopId="illness" />
|
||||
<Button
|
||||
onClick={() => setNewOpen(true)}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("illness.newEpisode")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground text-xs">{t("illness.disclaimer")}</p>
|
||||
|
||||
@@ -19,6 +19,7 @@ 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 {
|
||||
@@ -656,6 +657,7 @@ function InsightsTabStripImpl({
|
||||
return (
|
||||
<nav
|
||||
data-slot="insights-tab-strip"
|
||||
data-tour-id="insights-hero"
|
||||
aria-label={t("insights.navAriaLabel")}
|
||||
// v1.4.28 FB-D3 — `touch-action: pan-y` lets vertical swipes that
|
||||
// begin on the sticky strip scroll the page through it. The
|
||||
@@ -858,6 +860,10 @@ function InsightsTabStripImpl({
|
||||
"from-background/95 pointer-events-none absolute inset-y-0 right-0 w-8 bg-gradient-to-l to-transparent sm:hidden",
|
||||
)}
|
||||
/>
|
||||
{/* 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
|
||||
|
||||
@@ -7,6 +7,7 @@ import { AchievementUnlockNotifier } from "@/components/gamification/achievement
|
||||
import { MaintainershipBanner } from "@/components/i18n/maintainership-banner";
|
||||
import { LayoutCoachFab } from "@/components/insights/layout-coach-fab";
|
||||
import { LayoutCoachMount } from "@/components/insights/layout-coach-mount";
|
||||
import { TourLauncher } from "@/components/onboarding/tour-launcher";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { clearOfflineCachesForSessionEnd } from "@/lib/pwa/query-persister";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
@@ -280,6 +281,13 @@ export function AuthShell({
|
||||
<BottomNav />
|
||||
</div>
|
||||
<LayoutCoachMount />
|
||||
{/* v1.18.6 — the module-tour launcher lives at the shell level so its
|
||||
overlay survives the cross-page `router.push`es the tour makes. It
|
||||
self-gates: it only auto-opens on the dashboard for a user who has
|
||||
not finished/dismissed the tour, and otherwise renders null. Stays
|
||||
out of demo mode (the tour PATCHes `/api/onboarding/tour`, which the
|
||||
proxy blocks for demo visitors). */}
|
||||
{!demoMode && <TourLauncher />}
|
||||
{/* v1.16.13 — the Coach FAB stays hidden in demo mode. Its send hits
|
||||
`/api/insights/chat`, which is not in the proxy's
|
||||
`DEMO_MUTATION_ALLOWLIST`, so a demo visitor who tapped it and sent
|
||||
|
||||
@@ -32,6 +32,7 @@ import { useState } from "react";
|
||||
import { CalendarClock, CheckCircle2, Plus, Settings2 } from "lucide-react";
|
||||
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -370,6 +371,7 @@ export function VorsorgeSection({
|
||||
<div className="min-w-0">
|
||||
<h1
|
||||
id="vorsorge-section-title"
|
||||
data-tour-id="vorsorge-hero"
|
||||
className="text-2xl font-bold tracking-tight"
|
||||
>
|
||||
{t("measurementReminders.sectionTitle")}
|
||||
@@ -379,6 +381,7 @@ export function VorsorgeSection({
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<ModuleTourTrigger stopId="vorsorge" />
|
||||
{addButton}
|
||||
{wrenchButton}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect } from "react";
|
||||
import {
|
||||
CheckCircle2,
|
||||
FileUp,
|
||||
@@ -10,6 +11,8 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { setTourReferrer } from "@/components/onboarding/tour-launcher";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
@@ -24,10 +27,21 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
* through `next/link` so the regular client-side navigation kicks in
|
||||
* (the proxy redirect was cleared with the same step-API write that
|
||||
* landed the user here).
|
||||
*
|
||||
* v1.18.6 — on mount we write the per-user wizard-return marker so the
|
||||
* shell-level `<TourLauncher>` auto-opens the module tour immediately
|
||||
* on the next dashboard arrival (sequenced after the post-wizard
|
||||
* grace), instead of waiting out the 24 h fallback. Whichever exit the
|
||||
* user picks from this screen, the next `/` mount finds the marker.
|
||||
*/
|
||||
|
||||
export function DoneScreen() {
|
||||
const { t } = useTranslations();
|
||||
const { user } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.id) setTourReferrer(user.id);
|
||||
}, [user?.id]);
|
||||
|
||||
return (
|
||||
<section
|
||||
|
||||
@@ -2,23 +2,27 @@ 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
|
||||
// behaviour (keyboard navigation, focus management, target measurement,
|
||||
// cross-page navigation) 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.
|
||||
// initial render shape, ARIA wiring, i18n keys, and that the first step
|
||||
// is the dashboard overview.
|
||||
//
|
||||
// The overlay reads Next's navigation hooks for the cross-page push; we
|
||||
// stub them so the static render resolves.
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
usePathname: () => "/",
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { OnboardingTour } from "../tour";
|
||||
|
||||
function renderTour(includeAchievements = true) {
|
||||
function renderTour(modules?: Record<string, boolean>) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<OnboardingTour
|
||||
includeAchievements={includeAchievements}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
<OnboardingTour modules={modules} onClose={vi.fn()} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
@@ -31,25 +35,23 @@ describe("<OnboardingTour>", () => {
|
||||
expect(html).toContain('aria-labelledby="onboarding-tour-title"');
|
||||
});
|
||||
|
||||
it("starts on the tile-strip step (1 of 5 by default)", () => {
|
||||
it("starts on the dashboard-overview step (1 of 15 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");
|
||||
expect(html).toContain("Step 1 of 15");
|
||||
});
|
||||
|
||||
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("keeps the counter honest when modules are disabled", () => {
|
||||
// cycle + mood + achievements off ⇒ 12 stops.
|
||||
const html = renderTour({
|
||||
cycle: false,
|
||||
mood: false,
|
||||
achievements: false,
|
||||
});
|
||||
expect(html).toContain("Step 1 of 12");
|
||||
});
|
||||
|
||||
it("exposes a skip control with an accessible label", () => {
|
||||
const html = renderTour();
|
||||
// After v1.4.33 F2 the dim panels are purely visual
|
||||
// (pointer-events: none) so skip lives entirely on the explicit
|
||||
// tooltip-footer Skip button. The visible label still has to be
|
||||
// there — both for sighted users and for screen readers.
|
||||
expect(html).toContain("Skip tour");
|
||||
});
|
||||
|
||||
@@ -77,8 +79,7 @@ describe("<OnboardingTour>", () => {
|
||||
<OnboardingTour onClose={vi.fn()} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
expect(html).toContain("Schritt 1 von 5");
|
||||
expect(html).toContain("Deine Tageswerte");
|
||||
expect(html).toContain("Schritt 1 von 15");
|
||||
expect(html).toContain("Tour überspringen");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
"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="<…>-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>
|
||||
);
|
||||
}
|
||||
@@ -47,11 +47,13 @@
|
||||
* manual replay always works.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
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;
|
||||
@@ -90,6 +92,16 @@ export function shouldAutoLaunchTour(args: {
|
||||
onboardingTourCompleted: boolean;
|
||||
onboardingCompletedAt: string | null;
|
||||
nowMs: number;
|
||||
/**
|
||||
* v1.18.6 — true when the user has just returned from the wizard's
|
||||
* DoneScreen this session (the `tourReferrerKey` marker). A fresh
|
||||
* user finishing anamnesis should get the module tour IMMEDIATELY —
|
||||
* the post-wizard grace below still sequences it after the toast.
|
||||
* The old 24 h delay over-corrected and removed first-run capability
|
||||
* education entirely; it now only applies to a user who SKIPPED the
|
||||
* wizard (no referrer marker, completion ≥ 24 h ago).
|
||||
*/
|
||||
justFromWizard?: boolean;
|
||||
}): boolean {
|
||||
if (args.onboardingTourCompleted) return false;
|
||||
if (args.onboardingCompletedAt == null) return false;
|
||||
@@ -98,6 +110,9 @@ export function shouldAutoLaunchTour(args: {
|
||||
// unparseable case as "no completion yet" so a malformed payload
|
||||
// never auto-launches the tour into an unsuspecting brand-new user.
|
||||
if (Number.isNaN(completedMs)) return false;
|
||||
// Fresh-from-anamnesis: launch now (sequenced after the post-wizard
|
||||
// grace by the launcher), no 24 h wait.
|
||||
if (args.justFromWizard) return true;
|
||||
return args.nowMs - completedMs >= TOUR_AUTOLAUNCH_DELAY_MS;
|
||||
}
|
||||
|
||||
@@ -189,6 +204,22 @@ function readAndClearForceLaunch(userId: string): boolean {
|
||||
* does not consult sessionStorage at all (the gating effect waits
|
||||
* for `inputsReady` first).
|
||||
*/
|
||||
/**
|
||||
* v1.18.6 — write the wizard-return marker. Called from the wizard's
|
||||
* `DoneScreen` on mount so the launcher knows the user JUST finished
|
||||
* anamnesis and should get the module tour immediately (sequenced after
|
||||
* the post-wizard grace) rather than after the 24 h fallback delay.
|
||||
* Per-user keyed so a shared browser keeps each identity's state apart.
|
||||
*/
|
||||
export function setTourReferrer(userId: string): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.sessionStorage.setItem(tourReferrerKey(userId), "1");
|
||||
} catch {
|
||||
/* ignore — sandboxed iframes etc. */
|
||||
}
|
||||
}
|
||||
|
||||
function readAndClearJustFromWizard(userId: string): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
try {
|
||||
@@ -233,24 +264,30 @@ function clearSessionDismissed(userId: string) {
|
||||
|
||||
interface TourLauncherProps {
|
||||
/**
|
||||
* The dashboard passes a sentinel — true once analytics has
|
||||
* resolved at least once. Without this gate the spotlight would
|
||||
* try to anchor to tiles that haven't rendered yet, and Recharts
|
||||
* widgets defer mounting too. This is a simple, race-free signal
|
||||
* the dashboard already computes.
|
||||
* v1.18.6 — optional readiness sentinel. The dashboard used to pass
|
||||
* `true` once analytics resolved so the spotlight didn't anchor to a
|
||||
* 0×0 tile. The overlay now polls for its anchor (bounded retry), so
|
||||
* the gate is no longer load-bearing; it defaults to `true` for the
|
||||
* shell-level mount. The launcher still only auto-opens on the
|
||||
* dashboard route, where the first stop's anchor lives.
|
||||
*/
|
||||
ready: boolean;
|
||||
ready?: boolean;
|
||||
}
|
||||
|
||||
export function TourLauncher({ ready }: TourLauncherProps) {
|
||||
export function TourLauncher({ ready = true }: TourLauncherProps) {
|
||||
const { user, isAuthenticated, isLoading } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const pathname = usePathname();
|
||||
|
||||
// `null` = decision not yet made; `true` = render the tour;
|
||||
// `false` = don't render. We only flip null→true ONCE per mount;
|
||||
// closing the tour transitions true→false and we never re-open
|
||||
// unless the restart event fires.
|
||||
const [showTour, setShowTour] = useState<boolean | null>(null);
|
||||
// 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);
|
||||
@@ -275,7 +312,14 @@ export function TourLauncher({ ready }: TourLauncherProps) {
|
||||
// tour on their next page-load — acceptable for a one-shot welcome).
|
||||
const [mountedAtMs] = useState(() => Date.now());
|
||||
|
||||
const inputsReady = !isLoading && isAuthenticated && !!user && ready;
|
||||
// v1.18.6 — the launcher now mounts at the app-shell level so the
|
||||
// overlay survives the cross-page `router.push`es the tour makes. It
|
||||
// must only AUTO-open on the dashboard, where the first stop's anchor
|
||||
// lives. A manual re-entry / restart can fire from any route (handled
|
||||
// by the event listeners below, which don't consult this gate).
|
||||
const onDashboard = pathname === "/";
|
||||
const inputsReady =
|
||||
!isLoading && isAuthenticated && !!user && ready && onDashboard;
|
||||
// The set-state-in-render pattern (mirrors `account-section.tsx`'s
|
||||
// `seededUserId` — React-recommended for "compute initial state
|
||||
// from props once they arrive"). Pure: branches on prop values
|
||||
@@ -289,6 +333,10 @@ export function TourLauncher({ ready }: TourLauncherProps) {
|
||||
decidedFor.flag !== user.onboardingTourCompleted)
|
||||
) {
|
||||
setDecidedFor({ userId: user.id, flag: user.onboardingTourCompleted });
|
||||
// v1.18.6 — consume the wizard-return marker once so the
|
||||
// fresh-from-anamnesis path launches immediately (sequenced after
|
||||
// the post-wizard grace) instead of waiting 24 h.
|
||||
const justFromWizard = readAndClearJustFromWizard(user.id);
|
||||
// sessionStorage reads happen via per-user keyed helpers, so a
|
||||
// browser shared by two HealthLog users (admin impersonation,
|
||||
// family laptop, etc.) keeps each user's tour state independent.
|
||||
@@ -309,17 +357,21 @@ export function TourLauncher({ ready }: TourLauncherProps) {
|
||||
// user expectation ("I clicked replay, now show me the tour").
|
||||
if (readAndClearForceLaunch(user.id)) {
|
||||
setShowTour(true);
|
||||
} else if (!shouldAutoLaunchTour({
|
||||
onboardingTourCompleted: user.onboardingTourCompleted,
|
||||
onboardingCompletedAt: user.onboardingCompletedAt,
|
||||
nowMs: mountedAtMs,
|
||||
})) {
|
||||
} else if (
|
||||
!shouldAutoLaunchTour({
|
||||
onboardingTourCompleted: user.onboardingTourCompleted,
|
||||
onboardingCompletedAt: user.onboardingCompletedAt,
|
||||
nowMs: mountedAtMs,
|
||||
justFromWizard,
|
||||
})
|
||||
) {
|
||||
setShowTour(false);
|
||||
} else if (readSessionDismissed(user.id)) {
|
||||
setShowTour(false);
|
||||
} else if (readAndClearJustFromWizard(user.id)) {
|
||||
// Mark "deferred"; the effect below schedules a fixed-duration
|
||||
// timer + flips `showTour` from inside the timeout callback
|
||||
} else if (justFromWizard) {
|
||||
// Fresh from anamnesis — defer past the post-wizard grace so the
|
||||
// green-tick toast doesn't double-overlay, then open. The effect
|
||||
// below schedules the timer and flips `showTour` in its callback
|
||||
// (which the set-state-in-effect rule allows).
|
||||
setShowTour(false);
|
||||
setDeferredFromWizard(true);
|
||||
@@ -344,13 +396,15 @@ export function TourLauncher({ ready }: TourLauncherProps) {
|
||||
return () => window.clearTimeout(id);
|
||||
}, [deferredFromWizard, user?.id]);
|
||||
|
||||
// Listen for explicit restart events from Settings → Account.
|
||||
// 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);
|
||||
@@ -359,17 +413,55 @@ export function TourLauncher({ ready }: TourLauncherProps) {
|
||||
};
|
||||
}, [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 — stable progress callback so the overlay's checkpoint effect
|
||||
// fires on a real step transition, not on every launcher re-render.
|
||||
const handleProgress = useCallback(
|
||||
(progress: { lastStopId: string | null; completedStopIds: string[]; status: "in_progress" | "completed" | "skipped" }) => {
|
||||
void apiPost("/api/onboarding/tour", {
|
||||
progress: { ...progress, updatedAt: new Date().toISOString() },
|
||||
}).catch(() => {});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
if (showTour !== true) return null;
|
||||
|
||||
// v1.18.0 B5 — drop the achievements tour stop when the account has the
|
||||
// achievements module turned off. Reads the per-user module map the same
|
||||
// way every other client gate does (`modules?.<key> !== false`,
|
||||
// default-on).
|
||||
const includeAchievements = tourIncludesAchievements(user?.modules);
|
||||
// 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);
|
||||
|
||||
return (
|
||||
<OnboardingTour
|
||||
includeAchievements={includeAchievements}
|
||||
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
|
||||
// a convenience, not a correctness invariant.
|
||||
onProgress={handleProgress}
|
||||
onClose={async (outcome) => {
|
||||
// Optimistic: hide the overlay immediately, persist in the
|
||||
// background. The flag is idempotent so re-fires are safe;
|
||||
@@ -378,7 +470,13 @@ export function TourLauncher({ ready }: 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 });
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
* `skipped` to the API + audit log).
|
||||
*/
|
||||
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -43,26 +44,46 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
buildTourStops,
|
||||
currentStop,
|
||||
deriveProgress,
|
||||
initTourState,
|
||||
isTourFinished,
|
||||
nextStep,
|
||||
prevStep,
|
||||
skipTour,
|
||||
stepCounter,
|
||||
type TourModuleMap,
|
||||
type TourState,
|
||||
type TourStop,
|
||||
type TourStopId,
|
||||
} 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.
|
||||
* Resolved module map (`GET /api/auth/me`'s `modules`). Stops whose
|
||||
* `requiresModule` resolves to `false` are dropped; the counter total
|
||||
* tracks the resolved list so "Schritt n/total" stays honest.
|
||||
*/
|
||||
includeAchievements?: boolean;
|
||||
modules?: TourModuleMap;
|
||||
/**
|
||||
* Called when the tour finishes (Done OR Skip OR Esc OR Backdrop
|
||||
* click). Receives the terminal outcome so the launcher can audit
|
||||
* the difference.
|
||||
* v1.18.6 — open at this stop id (the persisted resume point). When
|
||||
* the id is absent from the resolved list the tour starts at the top.
|
||||
*/
|
||||
resumeFromStopId?: string | null;
|
||||
/**
|
||||
* v1.18.6 — narrow the tour to a single module card (the per-page
|
||||
* "Diese Tour zeigen" re-entry). When set, the overlay shows just
|
||||
* that stop with a Done button and no cross-page navigation.
|
||||
*/
|
||||
filterToStop?: TourStopId;
|
||||
/**
|
||||
* v1.18.6 — fire-and-forget progress checkpoint on every step change
|
||||
* + terminal. The launcher PATCHes it so a reload resumes correctly.
|
||||
* Not called for the single-stop re-entry (`filterToStop`).
|
||||
*/
|
||||
onProgress?: (progress: ReturnType<typeof deriveProgress>) => void;
|
||||
/**
|
||||
* Called when the tour finishes (Done OR Skip OR Esc). Receives the
|
||||
* terminal outcome so the launcher can audit the difference.
|
||||
*/
|
||||
onClose: (outcome: "completed" | "skipped") => void;
|
||||
}
|
||||
@@ -216,56 +237,125 @@ const COLLISION_PAD = 8;
|
||||
const SHEET_BREAKPOINT = 480;
|
||||
|
||||
export function OnboardingTour({
|
||||
includeAchievements = true,
|
||||
modules,
|
||||
resumeFromStopId,
|
||||
filterToStop,
|
||||
onProgress,
|
||||
onClose,
|
||||
}: TourProps) {
|
||||
const { t } = useTranslations();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
const stops = useMemo(
|
||||
() => buildTourStops({ includeAchievements }),
|
||||
[includeAchievements],
|
||||
() => buildTourStops({ modules, filterToStop }),
|
||||
[modules, filterToStop],
|
||||
);
|
||||
// Single-stop re-entry never resumes from a checkpoint — it opens on
|
||||
// the one card the page asked for.
|
||||
const [state, setState] = useState<TourState>(() =>
|
||||
initTourState(stops, filterToStop ? null : resumeFromStopId),
|
||||
);
|
||||
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.
|
||||
// v1.18.6 — cross-page step change. A stop on another route triggers a
|
||||
// `router.push` first; the overlay survives the navigation because it is
|
||||
// mounted at the app-shell level (not inside the dashboard). After the
|
||||
// route settles we must wait for the new page's anchor to MOUNT before
|
||||
// measuring — anchoring against a not-yet-rendered (0×0) target would snap
|
||||
// the spotlight to centre-screen. We poll on rAF with a bounded retry, then
|
||||
// give up to the centred fallback so the tour never blocks indefinitely.
|
||||
//
|
||||
// We intentionally *don't* MutationObserver the whole document — the
|
||||
// targets are stable once mounted; bounded rAF polling on step change plus
|
||||
// re-measuring on scroll/resize is plenty.
|
||||
useEffect(() => {
|
||||
if (!stop) return;
|
||||
if (typeof document === "undefined") return;
|
||||
|
||||
let raf = 0;
|
||||
let cancelled = false;
|
||||
const reduceMotion =
|
||||
typeof window !== "undefined" &&
|
||||
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true;
|
||||
|
||||
// Navigate to the stop's route if we're not already there. Same-route
|
||||
// stops (the two dashboard stops while on `/`, or the wrap-up which has
|
||||
// no route) skip the push. `filterToStop` re-entry never navigates — it
|
||||
// runs on the page the user is already viewing.
|
||||
if (!filterToStop && stop.route && stop.route !== pathname) {
|
||||
router.push(stop.route);
|
||||
}
|
||||
|
||||
const measureNow = () => {
|
||||
if (cancelled) return;
|
||||
setRect(measureTarget(stop.targetId));
|
||||
};
|
||||
const measure = () => {
|
||||
cancelAnimationFrame(raf);
|
||||
raf = requestAnimationFrame(() => {
|
||||
setRect(measureTarget(stop.targetId));
|
||||
});
|
||||
raf = requestAnimationFrame(measureNow);
|
||||
};
|
||||
// Bring the target into view BEFORE the first measurement of this
|
||||
// step — anchors low on the page (e.g. the Settings nav item) can
|
||||
// sit entirely below the fold, and positioning against an
|
||||
// off-screen rect put the popover under the viewport bottom where
|
||||
// its Next button was unreachable. `block: "center"` keeps room on
|
||||
// both sides for the flipped placement; the rAF in `measure()`
|
||||
// runs after the instant scroll so we measure post-scroll
|
||||
// coordinates. Optional-chained: jsdom and older engines lack it.
|
||||
if (stop.targetId && typeof document !== "undefined") {
|
||||
document
|
||||
.querySelector<HTMLElement>(
|
||||
`[data-tour-id="${CSS.escape(stop.targetId)}"]`,
|
||||
)
|
||||
?.scrollIntoView?.({ block: "center", inline: "nearest" });
|
||||
}
|
||||
measure();
|
||||
|
||||
// Wait for the anchor to mount (after navigation it isn't in the DOM on
|
||||
// the first frame). Bounded poll: ~40 frames (~650 ms) before falling
|
||||
// back to the centred tooltip via `measureTarget` returning null.
|
||||
let attempts = 0;
|
||||
const MAX_ATTEMPTS = 40;
|
||||
const settleAndMeasure = () => {
|
||||
if (cancelled) return;
|
||||
const el = stop.targetId
|
||||
? document.querySelector<HTMLElement>(
|
||||
`[data-tour-id="${CSS.escape(stop.targetId)}"]`,
|
||||
)
|
||||
: null;
|
||||
if (el) {
|
||||
// Bring the anchor into view BEFORE measuring — anchors below the
|
||||
// fold positioned the popover under the viewport bottom otherwise.
|
||||
// Reduced-motion users get an instant jump, not a smooth scroll.
|
||||
el.scrollIntoView?.({
|
||||
block: "center",
|
||||
inline: "nearest",
|
||||
behavior: reduceMotion ? "auto" : "smooth",
|
||||
});
|
||||
measure();
|
||||
return;
|
||||
}
|
||||
// Centred / route-less stop (wrap-up): measure immediately.
|
||||
if (!stop.targetId) {
|
||||
measureNow();
|
||||
return;
|
||||
}
|
||||
attempts += 1;
|
||||
if (attempts < MAX_ATTEMPTS) {
|
||||
raf = requestAnimationFrame(settleAndMeasure);
|
||||
} else {
|
||||
// Anchor never mounted — centre the tooltip rather than block.
|
||||
measureNow();
|
||||
}
|
||||
};
|
||||
settleAndMeasure();
|
||||
|
||||
window.addEventListener("resize", measure);
|
||||
window.addEventListener("scroll", measure, true);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cancelAnimationFrame(raf);
|
||||
window.removeEventListener("resize", measure);
|
||||
window.removeEventListener("scroll", measure, true);
|
||||
};
|
||||
}, [stop]);
|
||||
// `pathname` is intentionally a dep: after the route settles the effect
|
||||
// re-runs and the anchor poll finds the now-mounted target.
|
||||
}, [stop, filterToStop, router, pathname]);
|
||||
|
||||
// v1.18.6 — fire-and-forget progress checkpoint on every step / terminal
|
||||
// transition so a mid-tour reload resumes at the right module. Skipped for
|
||||
// the single-stop re-entry (it isn't "the tour", just one card).
|
||||
useEffect(() => {
|
||||
if (filterToStop || !onProgress) return;
|
||||
onProgress(deriveProgress(state));
|
||||
}, [state, filterToStop, onProgress]);
|
||||
|
||||
// Focus management — moves focus to the primary action when the
|
||||
// step changes so keyboard users can hit Enter to advance. The
|
||||
|
||||
@@ -63,6 +63,7 @@ function buildUser(disableCoach: boolean): AuthUser {
|
||||
timezone: "Europe/Berlin",
|
||||
onboardingCompletedAt: null,
|
||||
onboardingTourCompleted: true,
|
||||
onboardingTourProgress: null,
|
||||
avatarUrl: null,
|
||||
glucoseUnit: null,
|
||||
unitPreference: "metric",
|
||||
|
||||
@@ -69,6 +69,7 @@ function buildUser(modules: AuthUser["modules"]): AuthUser {
|
||||
timezone: "Europe/Berlin",
|
||||
onboardingCompletedAt: null,
|
||||
onboardingTourCompleted: true,
|
||||
onboardingTourProgress: null,
|
||||
avatarUrl: null,
|
||||
glucoseUnit: null,
|
||||
unitPreference: "metric",
|
||||
|
||||
@@ -119,12 +119,13 @@ describe("settings sections — SSR smoke", () => {
|
||||
expect(html).not.toContain("settings.sections.");
|
||||
});
|
||||
|
||||
it("<AdvancedSection> exposes the Restart-tour button (moved from Account)", () => {
|
||||
// v1.18.1 (D1) — the "Restart onboarding tour" card moved from Settings →
|
||||
// Account into Settings → Advanced (a maintenance/reset action).
|
||||
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).
|
||||
const html = render(<AdvancedSection />);
|
||||
expect(html).toContain('data-testid="settings-restart-tour"');
|
||||
expect(html).toContain("Restart onboarding tour");
|
||||
expect(html).toContain("Restart module tour");
|
||||
// No raw i18n key leaks.
|
||||
expect(html).not.toContain("onboarding.tour.");
|
||||
});
|
||||
@@ -148,27 +149,13 @@ describe("settings sections — SSR smoke", () => {
|
||||
expect(html).not.toContain("settings.about.");
|
||||
});
|
||||
|
||||
it("<AboutSection> exposes a Replay-tour button for the W5 chain gate", () => {
|
||||
it("<AboutSection> no longer carries a tour-replay card (consolidated to Advanced)", () => {
|
||||
// v1.18.6 — the tour replay lives on one home in Settings → Advanced;
|
||||
// the About duplicate was removed. Per-module "Diese Tour zeigen"
|
||||
// affordances cover in-context re-entry.
|
||||
const html = render(<AboutSection />);
|
||||
// v1.4.47 W5 — the spotlight tour now auto-launches only ≥ 24 h
|
||||
// after the wizard finishes (see `shouldAutoLaunchTour` in
|
||||
// `components/onboarding/tour-launcher.tsx`). A first-day user who
|
||||
// *does* want the tour needs a discoverable manual trigger, and
|
||||
// Settings → About is the surface every user can find. Pin both
|
||||
// the data-testid and the visible copy so a future refactor can't
|
||||
// silently drop the button.
|
||||
expect(html).toContain('data-testid="about-replay-tour"');
|
||||
expect(html).toContain("Replay the tour");
|
||||
// No raw i18n key leaks past the i18n layer.
|
||||
expect(html).not.toContain("settings.about.tourReplay");
|
||||
});
|
||||
|
||||
it("<AboutSection> renders the German Replay-tour label end-to-end", () => {
|
||||
// project-voice + i18n parity: the W5 spec explicitly names this
|
||||
// surface "Tour ansehen" in German. Lock the resolved copy so a
|
||||
// future locale-bundle drift doesn't silently fall back to English.
|
||||
const html = render(<AboutSection />, "de");
|
||||
expect(html).toContain("Tour ansehen");
|
||||
expect(html).not.toContain('data-testid="about-replay-tour"');
|
||||
expect(html).not.toContain("Replay the tour");
|
||||
});
|
||||
|
||||
it("<AiSection> renders the AI Insights heading", () => {
|
||||
|
||||
@@ -41,6 +41,7 @@ function buildUser(timeFormat: TimeFormatPreference): AuthUser {
|
||||
timezone: "Europe/Berlin",
|
||||
onboardingCompletedAt: null,
|
||||
onboardingTourCompleted: true,
|
||||
onboardingTourProgress: null,
|
||||
avatarUrl: null,
|
||||
glucoseUnit: null,
|
||||
unitPreference: "metric",
|
||||
|
||||
@@ -44,6 +44,7 @@ function buildUser(unitPreference: "metric" | "imperial"): AuthUser {
|
||||
timezone: "Europe/Berlin",
|
||||
onboardingCompletedAt: null,
|
||||
onboardingTourCompleted: true,
|
||||
onboardingTourProgress: null,
|
||||
avatarUrl: null,
|
||||
glucoseUnit: null,
|
||||
unitPreference,
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
ArrowUpCircle,
|
||||
BookOpen,
|
||||
Compass,
|
||||
ExternalLink,
|
||||
GitBranch,
|
||||
Info,
|
||||
@@ -15,9 +14,7 @@ import {
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { restartOnboardingTour } from "@/lib/onboarding/tour-restart";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
|
||||
|
||||
@@ -118,25 +115,9 @@ export function UpdateBadge({
|
||||
);
|
||||
}
|
||||
|
||||
export interface AboutSectionProps {
|
||||
/**
|
||||
* v1.18.1 E5 — suppress the "Replay tour" card. The admin → Über
|
||||
* surface reuses this component but the onboarding tour is a
|
||||
* user-facing affordance with no place in the admin console, so the
|
||||
* admin renderer passes `hideTourReplay`. The Settings → About surface
|
||||
* leaves it on.
|
||||
*/
|
||||
hideTourReplay?: boolean;
|
||||
}
|
||||
|
||||
export function AboutSection({ hideTourReplay = false }: AboutSectionProps = {}) {
|
||||
export function AboutSection() {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
// Pull the auth user — only needed so the "Replay the tour" button
|
||||
// can stamp a per-user force-launch marker into sessionStorage. The
|
||||
// hook is cheap (a cached `/api/auth/me` read) and every settings
|
||||
// page already lives behind the same auth boundary.
|
||||
const { user } = useAuth();
|
||||
|
||||
const { data: version, isLoading } = useQuery({
|
||||
queryKey: queryKeys.apiVersion(),
|
||||
@@ -147,45 +128,6 @@ export function AboutSection({ hideTourReplay = false }: AboutSectionProps = {})
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
// v1.4.47 W5 — onboarding chain-gate: the spotlight tour now auto-
|
||||
// launches only ≥ 24 h after the wizard finishes (see
|
||||
// `shouldAutoLaunchTour` in `components/onboarding/tour-launcher.tsx`).
|
||||
// The carousel + tour no longer stack into ~90 s of forced onboarding
|
||||
// on first visit. The tradeoff: a first-day user who *does* want the
|
||||
// tour needs a discoverable manual trigger. Settings → About is the
|
||||
// surface every user can find (every locale puts "About" right on the
|
||||
// settings shell), so it carries the replay button next to the
|
||||
// sources/docs links. Settings → Account still has its own
|
||||
// "Restart onboarding tour" — both buttons share the same flow.
|
||||
const [replayingTour, setReplayingTour] = useState(false);
|
||||
// v1.4.48 M6c — text + type collapsed into one discriminated state so
|
||||
// the two values can never drift (a "success" message rendered in the
|
||||
// destructive colour was the latent bug class).
|
||||
const [tourFeedback, setTourFeedback] = useState<{
|
||||
text: string;
|
||||
type: "success" | "error";
|
||||
} | null>(null);
|
||||
|
||||
async function handleReplayTour() {
|
||||
setReplayingTour(true);
|
||||
setTourFeedback(null);
|
||||
// v1.4.48 M6b — both Settings → Account and Settings → About now
|
||||
// delegate to the shared `restartOnboardingTour()` worker so the
|
||||
// server flip + force-launch marker + window event live in one
|
||||
// place. The helper returns a discriminated result; this surface
|
||||
// only owns the translation + feedback rendering.
|
||||
const result = await restartOnboardingTour(user?.id);
|
||||
if (result.ok) {
|
||||
setTourFeedback({
|
||||
text: t("onboarding.tour.restartConfirmation"),
|
||||
type: "success",
|
||||
});
|
||||
} else {
|
||||
setTourFeedback({ text: t(result.messageKey), type: "error" });
|
||||
}
|
||||
setReplayingTour(false);
|
||||
}
|
||||
|
||||
// v1.4.36 W4f — the explicit "Check for updates" button is gone;
|
||||
// only the 24 h auto-check stays. When the auto-check reports
|
||||
// `newer_available`, a subtle upward-arrow badge appears next to
|
||||
@@ -364,63 +306,10 @@ export function AboutSection({ hideTourReplay = false }: AboutSectionProps = {})
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* v1.4.47 W5 — Replay tour card. The dashboard spotlight
|
||||
tour now auto-launches only ≥ 24 h after the wizard finishes,
|
||||
so a first-day user who wants to see it still needs a
|
||||
discoverable manual trigger. About is the surface every user
|
||||
can find (it's the standard "where am I?" stop in the
|
||||
settings shell), so the button lives here next to version +
|
||||
links. Mirrors the stack-on-mobile / right-align-on-desktop
|
||||
contract used by the Account section's "Restart onboarding
|
||||
tour" card so the two surfaces feel consistent.
|
||||
|
||||
v1.18.1 E5 — hidden on the admin → Über surface (`hideTourReplay`):
|
||||
the onboarding tour is a user-facing affordance with no place in
|
||||
the admin console. */}
|
||||
{!hideTourReplay && (
|
||||
<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("settings.about.tourReplay")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.about.tourReplayHint")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleReplayTour}
|
||||
disabled={replayingTour}
|
||||
data-testid="about-replay-tour"
|
||||
className="w-full shrink-0 sm:w-auto"
|
||||
>
|
||||
{replayingTour ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Compass className="h-4 w-4" />
|
||||
)}
|
||||
{t("settings.about.tourReplay")}
|
||||
</Button>
|
||||
</div>
|
||||
{tourFeedback && (
|
||||
<p
|
||||
role="alert"
|
||||
className={`mt-2 pl-7 text-xs ${
|
||||
tourFeedback.type === "success"
|
||||
? "text-success"
|
||||
: "text-destructive"
|
||||
}`}
|
||||
>
|
||||
{tourFeedback.text}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* v1.18.6 — the tour-replay card consolidated onto a single home in
|
||||
Settings → Advanced ("Modul-Tour neu starten"). The About surface
|
||||
no longer duplicates it; per-module "Diese Tour zeigen" affordances
|
||||
on every module page cover in-context re-entry. */}
|
||||
|
||||
{/* v1.4.36 W4f — the dedicated "Updates" card with the
|
||||
manual "Check for updates" button is gone. The 24 h auto-
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
import { HealthRecordExportPanel } from "@/components/settings/health-record-export-panel";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger";
|
||||
|
||||
export function GesundheitsakteSection() {
|
||||
const { t } = useTranslations();
|
||||
@@ -28,13 +29,18 @@ export function GesundheitsakteSection() {
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* v1.18.1 (D0) — section blurb dropped for consistent top alignment. */}
|
||||
<header>
|
||||
<header className="flex items-center justify-end">
|
||||
<h1
|
||||
id="settings-section-gesundheitsakte-title"
|
||||
className="sr-only"
|
||||
>
|
||||
{t("settings.sections.gesundheitsakte.title")}
|
||||
</h1>
|
||||
{/* v1.18.6 — guided-tour re-entry for the doctor-report module + a
|
||||
real spotlight anchor (the section h1 is sr-only). */}
|
||||
<span data-tour-id="export-hero">
|
||||
<ModuleTourTrigger stopId="export" />
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<HealthRecordExportPanel />
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
|
||||
import { ConnectionsPanel } from "@/components/settings/integrations/connections-panel";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger";
|
||||
|
||||
export {
|
||||
parseOAuthOutcome,
|
||||
@@ -32,10 +33,16 @@ export function IntegrationsSection() {
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* v1.18.1 (D0) — section blurb dropped for consistent top alignment. */}
|
||||
<header>
|
||||
<header className="flex items-center justify-end">
|
||||
<h1 id="settings-section-integrations-title" className="sr-only">
|
||||
{t("settings.sections.integrations.title")}
|
||||
</h1>
|
||||
{/* v1.18.6 — guided-tour re-entry for the integrations module. The
|
||||
visible affordance also gives the spotlight a real anchor (the
|
||||
section h1 is sr-only). */}
|
||||
<span data-tour-id="integrations-hero">
|
||||
<ModuleTourTrigger stopId="integrations" />
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<ConnectionsPanel />
|
||||
|
||||
Reference in new issue
Block a user