mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
The done screen moves focus to its heading like every other screen; the confirm screen's preference list holds only dt/dd pairs and the settings link stands beside it; Back from Q1 is not offered once the flow has been entered, because the front door would resume past the welcome and bounce straight back; the medication task records no target on the ledger, as the contract says, and keeps the id on the screen; and the two success affordances go through the outcome module rather than a hand-rolled tick. The error-code catalogue lists the answers and restart codes and no longer the retired step ones, and the session-surface guard learns the one new deriving helper the onboarding pages read through.
342 lines
13 KiB
TypeScript
342 lines
13 KiB
TypeScript
"use client";
|
|
|
|
import Link from "next/link";
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import {
|
|
CheckCircle2,
|
|
Compass,
|
|
FileUp,
|
|
PlusCircle,
|
|
Plug,
|
|
Settings2,
|
|
Sparkles,
|
|
UserPlus,
|
|
} from "lucide-react";
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
import { MedicalDisclaimer } from "@/components/common/medical-disclaimer";
|
|
import { SampleBriefingCard } from "@/components/onboarding/sample-briefing-card";
|
|
import { StepHeading } from "@/components/onboarding/step-heading";
|
|
import { useOnboardingAnswer } from "@/components/onboarding/use-onboarding-flow";
|
|
import { useAuth } from "@/hooks/use-auth";
|
|
import { useAccountSwitch } from "@/hooks/use-account-switch";
|
|
import { setTourReferrer } from "@/components/onboarding/tour-launcher";
|
|
import { queryKeys } from "@/lib/query-keys";
|
|
import { apiGet } from "@/lib/api/api-fetch";
|
|
import { useTranslations } from "@/lib/i18n/context";
|
|
import { markChecklistExpanded } from "@/lib/onboarding/checklist-storage";
|
|
import type { OnboardingStateDto } from "@/lib/onboarding/needs";
|
|
import { accountLabel } from "@/lib/sharing/account-access-view";
|
|
|
|
interface AiProviderStatus {
|
|
/** Origin of the provider that would serve this user, if any. */
|
|
managedBy?: "user" | "local" | "server" | null;
|
|
}
|
|
|
|
/**
|
|
* v1.39 (C2) — the setup flow's exit.
|
|
*
|
|
* The dashboard is one click away, already ordered, and the checklist opens
|
|
* on arrival (design spec §The screens, 5). The tour is an OFFER here rather
|
|
* than an automatic launch: pressing "Take a short tour" sets the referrer
|
|
* the shell-level `<TourLauncher>` reads, and nothing else does, so somebody
|
|
* who wants the dashboard gets the dashboard.
|
|
*
|
|
* Two record-shaped exits ride the answer to Q1: "both" is offered the
|
|
* profile for the person they look after, and "someone I look after" — whose
|
|
* profile was created on the confirm screen — is offered that record, through
|
|
* the same switch the account menu uses, so their first entry lands in the
|
|
* right record.
|
|
*
|
|
* The step is marked done on arrival, once. Everything else on this screen
|
|
* is presentational: the module map was derived on confirm, the cookie
|
|
* cleared there too.
|
|
*
|
|
* The AI panel leads with the payoff (a static, clearly-labelled SAMPLE
|
|
* briefing — no model call, no egress, no consent), then the honest
|
|
* local-first ladder and the "useful without AI" release valve. Value-first,
|
|
* never a gate: every exit below stays, and setup is a single optional
|
|
* deep-link.
|
|
*/
|
|
|
|
export function DoneScreen({ state }: { state: OnboardingStateDto }) {
|
|
const { t } = useTranslations();
|
|
const router = useRouter();
|
|
const { user } = useAuth();
|
|
const [sampleOpen, setSampleOpen] = useState(false);
|
|
const answer = useOnboardingAnswer();
|
|
const accountSwitch = useAccountSwitch();
|
|
|
|
// Mark the step once. The write is idempotent, but a strict-mode double
|
|
// mount would still send it twice; the ref keeps it to one.
|
|
const marked = useRef(false);
|
|
const donePending =
|
|
state.steps.find((step) => step.id === "done")?.status === "pending";
|
|
useEffect(() => {
|
|
if (!donePending || marked.current) return;
|
|
marked.current = true;
|
|
answer.mutate({ step: "done" });
|
|
// `answer` is a fresh object every render; the mutation it wraps is not.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [donePending]);
|
|
|
|
const recordTarget = state.needs.recordTarget;
|
|
const managedRecord =
|
|
recordTarget === "someone-else"
|
|
? (user?.accountAccess?.accounts.find(
|
|
(entry) => entry.recordKind === "managed",
|
|
) ?? null)
|
|
: null;
|
|
|
|
function openDashboard() {
|
|
markChecklistExpanded();
|
|
router.push("/");
|
|
}
|
|
|
|
function takeTour() {
|
|
if (user?.id) setTourReferrer(user.id);
|
|
markChecklistExpanded();
|
|
router.push("/");
|
|
}
|
|
|
|
// The shared-key note is the ONLY honest divergence in the panel: on a
|
|
// deployment that ships an operator key, insights already work for the
|
|
// user, so we say so plainly instead of pushing a BYOK/local setup they
|
|
// don't need. Presence-only read; no key material is exposed.
|
|
const { data: aiProvider } = useQuery<AiProviderStatus>({
|
|
queryKey: queryKeys.userAiProvider(),
|
|
queryFn: async () => {
|
|
return apiGet("/api/user/ai-provider");
|
|
},
|
|
enabled: !!user,
|
|
});
|
|
const sharedKeyServes = aiProvider?.managedBy === "server";
|
|
|
|
return (
|
|
<section
|
|
aria-labelledby="onboarding-done-title"
|
|
className="flex flex-col items-center gap-6 py-6 text-center"
|
|
>
|
|
<span
|
|
aria-hidden="true"
|
|
className="bg-primary/10 text-primary flex size-20 items-center justify-center rounded-full"
|
|
>
|
|
<CheckCircle2 className="size-10" />
|
|
</span>
|
|
|
|
<div className="mx-auto max-w-md space-y-2">
|
|
<StepHeading
|
|
id="onboarding-done-title"
|
|
title={t("onboarding.done.title")}
|
|
description={t("onboarding.done.body")}
|
|
/>
|
|
<p className="text-muted-foreground text-sm leading-relaxed">
|
|
{t("onboarding.done.learning")}
|
|
</p>
|
|
</div>
|
|
|
|
{/* v1.28 — the flagship AI value, made reachable at the one screen
|
|
every fresh user passes through. The daily briefing / Coach need
|
|
a provider the rest of onboarding never provisions, so this
|
|
leads with the payoff (a static, clearly-labelled SAMPLE
|
|
briefing — no model call, no egress, no consent), then the
|
|
honest local-first ladder and the "useful without AI" release
|
|
valve. Value-first, never a gate: the three exits below stay,
|
|
and setup is a single optional deep-link. */}
|
|
<section
|
|
aria-labelledby="onboarding-ai-panel-title"
|
|
data-slot="onboarding-ai-panel"
|
|
className="border-border bg-card mx-auto flex w-full max-w-md flex-col gap-4 rounded-xl border p-4 text-left md:p-6"
|
|
>
|
|
<header className="flex items-start gap-3">
|
|
<span
|
|
aria-hidden="true"
|
|
className="from-primary to-brand-pink flex size-9 shrink-0 items-center justify-center rounded-full bg-gradient-to-br"
|
|
>
|
|
<Sparkles className="text-background size-4" />
|
|
</span>
|
|
<div className="space-y-1">
|
|
<h2
|
|
id="onboarding-ai-panel-title"
|
|
className="text-foreground text-base font-semibold tracking-tight"
|
|
>
|
|
{t("onboarding.ai.panelTitle")}
|
|
</h2>
|
|
<p className="text-muted-foreground text-sm leading-relaxed">
|
|
{t("onboarding.ai.panelIntro")}
|
|
</p>
|
|
</div>
|
|
</header>
|
|
|
|
{sampleOpen ? (
|
|
<SampleBriefingCard />
|
|
) : (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="w-full"
|
|
data-slot="onboarding-ai-show-sample"
|
|
onClick={() => setSampleOpen(true)}
|
|
>
|
|
{t("onboarding.ai.showSample")}
|
|
</Button>
|
|
)}
|
|
|
|
{sharedKeyServes ? (
|
|
<p
|
|
data-slot="onboarding-ai-shared-key"
|
|
className="text-foreground bg-primary/5 border-primary/20 rounded-lg border px-3 py-2 text-sm leading-relaxed"
|
|
>
|
|
{t("onboarding.ai.sharedKeyNote")}
|
|
</p>
|
|
) : null}
|
|
|
|
<div className="space-y-2.5">
|
|
<p className="text-foreground text-sm font-medium">
|
|
{t("onboarding.ai.ladderTitle")}
|
|
</p>
|
|
<ul className="space-y-2.5">
|
|
{/* Local first — the calm, private default — then BYOK, then
|
|
the subscription/OAuth path with its training caveat. Same
|
|
ordering + vendor-blind framing as the shipped document-
|
|
provider governance. */}
|
|
<li className="space-y-0.5">
|
|
<p className="text-foreground text-sm font-medium">
|
|
{t("onboarding.ai.ladderLocalTitle")}
|
|
</p>
|
|
<p className="text-muted-foreground text-sm leading-relaxed">
|
|
{t("onboarding.ai.ladderLocalBody")}
|
|
</p>
|
|
</li>
|
|
<li className="space-y-0.5">
|
|
<p className="text-foreground text-sm font-medium">
|
|
{t("onboarding.ai.ladderByokTitle")}
|
|
</p>
|
|
<p className="text-muted-foreground text-sm leading-relaxed">
|
|
{t("onboarding.ai.ladderByokBody")}
|
|
</p>
|
|
</li>
|
|
<li className="space-y-0.5">
|
|
<p className="text-foreground text-sm font-medium">
|
|
{t("onboarding.ai.ladderOauthTitle")}
|
|
</p>
|
|
<p className="text-muted-foreground text-sm leading-relaxed">
|
|
{t("onboarding.ai.ladderOauthBody")}
|
|
</p>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<p
|
|
data-slot="onboarding-ai-keyless"
|
|
className="text-foreground text-sm leading-relaxed"
|
|
>
|
|
{t("onboarding.ai.keylessLine")}
|
|
</p>
|
|
|
|
<MedicalDisclaimer variant="dataPosture" />
|
|
|
|
<Link
|
|
href="/settings/ai"
|
|
className="text-primary text-sm font-medium underline-offset-4 hover:underline"
|
|
>
|
|
{t("onboarding.ai.setupCta")}
|
|
</Link>
|
|
</section>
|
|
|
|
<div className="flex w-full max-w-xs flex-col gap-2">
|
|
<Button
|
|
type="button"
|
|
size="lg"
|
|
onClick={openDashboard}
|
|
className="min-h-11"
|
|
data-slot="onboarding-open-dashboard"
|
|
>
|
|
{t("onboarding.done.returnCta")}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
size="lg"
|
|
variant="outline"
|
|
onClick={takeTour}
|
|
className="inline-flex min-h-11 items-center gap-2"
|
|
data-slot="onboarding-take-tour"
|
|
>
|
|
<Compass className="size-4" />
|
|
{t("onboarding.flow.done.tourCta")}
|
|
</Button>
|
|
{managedRecord ? (
|
|
<Button
|
|
type="button"
|
|
size="lg"
|
|
variant="outline"
|
|
onClick={() => accountSwitch.mutate(managedRecord.accountId)}
|
|
disabled={accountSwitch.isPending}
|
|
className="inline-flex min-h-11 items-center gap-2"
|
|
data-slot="onboarding-open-managed-record"
|
|
>
|
|
<UserPlus className="size-4" />
|
|
{t("onboarding.flow.done.openRecord", {
|
|
name: accountLabel(managedRecord),
|
|
})}
|
|
</Button>
|
|
) : null}
|
|
{recordTarget === "both" ? (
|
|
<Button asChild size="lg" variant="outline">
|
|
<Link
|
|
href="/settings/access"
|
|
className="inline-flex min-h-11 items-center gap-2"
|
|
data-slot="onboarding-create-managed-profile"
|
|
>
|
|
<UserPlus className="size-4" />
|
|
{t("onboarding.flow.done.createProfile")}
|
|
</Link>
|
|
</Button>
|
|
) : null}
|
|
<Button asChild variant="ghost">
|
|
<Link
|
|
href="/settings/integrations"
|
|
className="inline-flex items-center gap-2"
|
|
>
|
|
<Plug className="size-4" />
|
|
{t("onboarding.done.connectCta")}
|
|
</Link>
|
|
</Button>
|
|
<Button asChild variant="ghost">
|
|
<Link href="/measurements" className="inline-flex items-center gap-2">
|
|
<PlusCircle className="size-4" />
|
|
{t("onboarding.done.logCta")}
|
|
</Link>
|
|
</Button>
|
|
</div>
|
|
|
|
{/* 2026-07-17 UX-onboarding audit M5 — the module system (default-on
|
|
set + opt-ins like nutrients / mental health / documents) is
|
|
otherwise undiscoverable during first-run: the wizard never
|
|
mentions it, and the only pointer used to be one tour stop.
|
|
A quiet link here, beside the existing import link, gives every
|
|
new account one honest pointer to Settings → Modules before they
|
|
leave the wizard. */}
|
|
<div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-1.5">
|
|
<Link
|
|
href="/settings/export"
|
|
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1.5 text-sm underline-offset-4 hover:underline"
|
|
>
|
|
<FileUp className="size-3.5" />
|
|
{t("onboarding.done.importCta")}
|
|
</Link>
|
|
<Link
|
|
href="/settings/modules"
|
|
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1.5 text-sm underline-offset-4 hover:underline"
|
|
>
|
|
<Settings2 className="size-3.5" />
|
|
{t("onboarding.done.modulesCta")}
|
|
</Link>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|