feat(onboarding): source 4-card grid with Apple Health coming-soon

This commit is contained in:
Marc-André Bombeck committed 2026-05-14 20:03:17 +02:00
1 parent c7b1fdb022
commit 888efd6cf4
8 files changed
+432 -6

No files matched your search

+17 -1
View File
@@ -1468,7 +1468,23 @@
"source": {
"title": "Quelle verbinden",
"body": "Werte automatisch übernehmen oder weiter manuell eintragen – deine Entscheidung.",
"placeholder": "Quellenauswahl folgt."
"placeholder": "Quellenauswahl folgt.",
"recommended": "Empfohlen",
"manual": {
"title": "Manuelle Eingabe",
"body": "Werte direkt erfassen, wann es dir passt. Funktioniert auf jedem Gerät, kein Konto bei Dritten nötig."
},
"withings": {
"title": "Withings",
"body": "Waagen, Blutdruckmesser und Tracker von Withings synchronisieren automatisch.",
"cta": "Withings verbinden"
},
"appleHealth": {
"title": "Apple Health",
"body": "iPhone- und Apple-Watch-Daten werden über die HealthLog-iOS-App eingelesen.",
"badge": "Mit v1.5 (iOS)",
"disabledHint": "Verfügbar, sobald die iOS-App ausgeliefert ist."
}
},
"baseline": {
"title": "Deine erste Messung",
+17 -1
View File
@@ -1468,7 +1468,23 @@
"source": {
"title": "Connect a source",
"body": "Bring in measurements automatically, or stay on manual entry — your call.",
"placeholder": "Source picker coming soon."
"placeholder": "Source picker coming soon.",
"recommended": "Recommended",
"manual": {
"title": "Manual entry",
"body": "Log readings yourself, whenever it fits. Works on any device, no third-party account required."
},
"withings": {
"title": "Withings",
"body": "Scales, blood-pressure cuffs and trackers from Withings sync automatically.",
"cta": "Connect Withings"
},
"appleHealth": {
"title": "Apple Health",
"body": "iPhone and Apple Watch data flow in via the HealthLog iOS app.",
"badge": "Coming with v1.5 (iOS)",
"disabledHint": "Available once the iOS app ships."
}
},
"baseline": {
"title": "Your first reading",
+17 -1
View File
@@ -1468,7 +1468,23 @@
"source": {
"title": "Conectar una fuente",
"body": "Importa las mediciones automáticamente o sigue con la entrada manual, tú decides.",
"placeholder": "Selector de fuente próximamente."
"placeholder": "Selector de fuente próximamente.",
"recommended": "Recomendado",
"manual": {
"title": "Entrada manual",
"body": "Registra tus mediciones tú mismo cuando te venga bien. Funciona en cualquier dispositivo, sin cuenta de terceros."
},
"withings": {
"title": "Withings",
"body": "Las básculas, los tensiómetros y los trackers de Withings se sincronizan automáticamente.",
"cta": "Conectar Withings"
},
"appleHealth": {
"title": "Apple Health",
"body": "Los datos del iPhone y el Apple Watch llegan a través de la app de HealthLog para iOS.",
"badge": "Con la v1.5 (iOS)",
"disabledHint": "Disponible cuando se publique la app de iOS."
}
},
"baseline": {
"title": "Tu primera medición",
+17 -1
View File
@@ -1468,7 +1468,23 @@
"source": {
"title": "Connecter une source",
"body": "Importez les mesures automatiquement ou conservez la saisie manuelle, c'est vous qui décidez.",
"placeholder": "Sélection de la source à venir."
"placeholder": "Sélection de la source à venir.",
"recommended": "Recommandé",
"manual": {
"title": "Saisie manuelle",
"body": "Enregistrez vos mesures vous-même, quand cela vous convient. Fonctionne sur tout appareil, aucun compte tiers requis."
},
"withings": {
"title": "Withings",
"body": "Balances, tensiomètres et trackers Withings se synchronisent automatiquement.",
"cta": "Connecter Withings"
},
"appleHealth": {
"title": "Apple Health",
"body": "Les données de l'iPhone et de l'Apple Watch arrivent via l'application iOS HealthLog.",
"badge": "Avec la v1.5 (iOS)",
"disabledHint": "Disponible dès la sortie de l'application iOS."
}
},
"baseline": {
"title": "Votre première mesure",
+17 -1
View File
@@ -1468,7 +1468,23 @@
"source": {
"title": "Connetti una sorgente",
"body": "Importa le misurazioni in automatico oppure resta sull'inserimento manuale, decidi tu.",
"placeholder": "Selettore di sorgente in arrivo."
"placeholder": "Selettore di sorgente in arrivo.",
"recommended": "Consigliato",
"manual": {
"title": "Inserimento manuale",
"body": "Registra tu stesso le misurazioni, quando ti è comodo. Funziona su qualsiasi dispositivo, senza account di terzi."
},
"withings": {
"title": "Withings",
"body": "Bilance, misuratori di pressione e tracker Withings si sincronizzano automaticamente.",
"cta": "Connetti Withings"
},
"appleHealth": {
"title": "Apple Health",
"body": "I dati di iPhone e Apple Watch arrivano tramite l'app HealthLog per iOS.",
"badge": "Con la v1.5 (iOS)",
"disabledHint": "Disponibile al rilascio dell'app iOS."
}
},
"baseline": {
"title": "La tua prima misurazione",
+17 -1
View File
@@ -1468,7 +1468,23 @@
"source": {
"title": "Połącz źródło",
"body": "Pobieraj pomiary automatycznie lub zostań przy wpisywaniu ręcznym — Twój wybór.",
"placeholder": "Wybór źródła wkrótce."
"placeholder": "Wybór źródła wkrótce.",
"recommended": "Polecane",
"manual": {
"title": "Wpis ręczny",
"body": "Rejestruj pomiary samodzielnie, kiedy Ci pasuje. Działa na każdym urządzeniu, bez konta u podmiotu trzeciego."
},
"withings": {
"title": "Withings",
"body": "Wagi, ciśnieniomierze i trackery Withings synchronizują się automatycznie.",
"cta": "Połącz Withings"
},
"appleHealth": {
"title": "Apple Health",
"body": "Dane z iPhone'a i Apple Watch trafiają tu poprzez aplikację HealthLog na iOS.",
"badge": "Wraz z v1.5 (iOS)",
"disabledHint": "Dostępne po wydaniu aplikacji iOS."
}
},
"baseline": {
"title": "Twój pierwszy pomiar",
+13
View File
@@ -3,6 +3,7 @@ import { redirect, notFound } from "next/navigation";
import { OnboardingShell } from "@/components/onboarding/OnboardingShell";
import { WelcomeCarousel } from "@/components/onboarding/WelcomeCarousel";
import { GoalsChipPicker } from "@/components/onboarding/GoalsChipPicker";
import { SourceCardGrid } from "@/components/onboarding/SourceCardGrid";
import { getSession } from "@/lib/auth/session";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
@@ -98,6 +99,18 @@ export default async function OnboardingStepPage({ params }: PageProps) {
);
}
// Source (step 2) — four-card grid. Manual is the implicit default;
// Withings opens OAuth in a new tab; Apple Health is rendered as a
// "coming with v1.5" announce card; Garmin is omitted per the
// W14b-Content brief (not on the roadmap yet).
if (requested === 2) {
return (
<OnboardingShell step={2} userLocale={user.locale ?? null}>
<SourceCardGrid />
</OnboardingShell>
);
}
// Foundation placeholder branches — replaced one step at a time by
// the W14b-Content agent. Each step's body resolves to the real i18n
// copy already, so the page reads end-to-end even before the per-
@@ -0,0 +1,317 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
Apple,
ClipboardCheck,
Watch,
Wifi,
type LucideIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
/**
* v1.4.25 W14b-Content — onboarding step 2 (source).
*
* Four-card grid presenting the data sources HealthLog supports today
* plus the announce-only Apple Health card so users land on v1.5 with
* the right expectation.
*
* Cards:
* 1. Manual entry (enabled, recommended)
* 2. Withings (enabled — opens OAuth `/api/withings/connect` in a new
* tab; the popup hands control back via the existing callback)
* 3. Apple Health (DISABLED — "Coming with v1.5 iOS app")
* 4. Garmin — omitted entirely. Not on the v1.5 roadmap so showing
* a disabled card adds noise without conveying a real promise.
*
* Selection is non-binding — Manual is the implicit default and the
* Next CTA always advances. The Withings card opens the OAuth flow in
* a new tab; the user returns to this step to click Next when they're
* back (callback handler flips the connection server-side, no client
* polling needed for the wizard contract).
*/
interface SourceCard {
slug: "manual" | "withings" | "apple-health";
Icon: LucideIcon;
titleKey: string;
bodyKey: string;
/**
* One of:
* - "enabled-select": tap selects the card (Manual)
* - "enabled-oauth": tap opens OAuth in a new tab (Withings)
* - "disabled": semi-transparent, badge, no-op (Apple Health)
*/
state: "enabled-select" | "enabled-oauth" | "disabled";
/** External href for `enabled-oauth`. */
href?: string;
/** Optional badge under the title (e.g. "Coming with v1.5"). */
badgeKey?: string;
/** Optional recommended badge ("Most popular"). */
recommendedKey?: string;
}
const SOURCE_CARDS: ReadonlyArray<SourceCard> = [
{
slug: "manual",
Icon: ClipboardCheck,
titleKey: "onboarding.source.manual.title",
bodyKey: "onboarding.source.manual.body",
state: "enabled-select",
recommendedKey: "onboarding.source.recommended",
},
{
slug: "withings",
Icon: Watch,
titleKey: "onboarding.source.withings.title",
bodyKey: "onboarding.source.withings.body",
state: "enabled-oauth",
href: "/api/withings/connect",
},
{
slug: "apple-health",
Icon: Apple,
titleKey: "onboarding.source.appleHealth.title",
bodyKey: "onboarding.source.appleHealth.body",
state: "disabled",
badgeKey: "onboarding.source.appleHealth.badge",
},
];
export function SourceCardGrid() {
const { t } = useTranslations();
const router = useRouter();
const queryClient = useQueryClient();
const [selected, setSelected] = useState<SourceCard["slug"]>("manual");
const [advancing, setAdvancing] = useState(false);
async function advance() {
if (advancing) return;
setAdvancing(true);
try {
const res = await fetch("/api/onboarding/step", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ step: 3 }),
});
if (!res.ok) throw new Error(await readError(res));
await queryClient.invalidateQueries({ queryKey: ["auth"] });
router.push("/onboarding/3");
} catch (err) {
const message =
err instanceof Error ? err.message : t("onboarding.errorGeneric");
toast.error(message);
setAdvancing(false);
}
}
return (
<section
aria-labelledby="onboarding-source-title"
className="space-y-6"
>
<header className="space-y-2">
<h1
id="onboarding-source-title"
tabIndex={-1}
className="text-2xl font-semibold tracking-tight"
>
{t("onboarding.source.title")}
</h1>
<p className="text-muted-foreground text-base leading-relaxed">
{t("onboarding.source.body")}
</p>
</header>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{SOURCE_CARDS.map((card) => (
<SourceCardItem
key={card.slug}
card={card}
selected={selected === card.slug}
onSelect={() => setSelected(card.slug)}
titleLabel={t(card.titleKey)}
bodyLabel={t(card.bodyKey)}
recommendedLabel={
card.recommendedKey ? t(card.recommendedKey) : null
}
badgeLabel={card.badgeKey ? t(card.badgeKey) : null}
withingsCtaLabel={t("onboarding.source.withings.cta")}
disabledHintLabel={t("onboarding.source.appleHealth.disabledHint")}
/>
))}
</div>
<div className="flex items-center justify-between gap-2 pt-2">
<Button asChild variant="ghost" size="sm">
<Link href="/onboarding/1">{t("onboarding.shell.back")}</Link>
</Button>
<div className="flex items-center gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={advance}
disabled={advancing}
>
{t("onboarding.shell.skip")}
</Button>
<Button type="button" onClick={advance} disabled={advancing}>
{t("onboarding.shell.next")}
</Button>
</div>
</div>
</section>
);
}
interface SourceCardItemProps {
card: SourceCard;
selected: boolean;
onSelect: () => void;
titleLabel: string;
bodyLabel: string;
recommendedLabel: string | null;
badgeLabel: string | null;
withingsCtaLabel: string;
disabledHintLabel: string;
}
function SourceCardItem({
card,
selected,
onSelect,
titleLabel,
bodyLabel,
recommendedLabel,
badgeLabel,
withingsCtaLabel,
disabledHintLabel,
}: SourceCardItemProps) {
const Icon = card.Icon;
const baseClasses = cn(
"relative flex flex-col gap-3 rounded-lg border p-4 text-left transition-colors duration-150 ease-out motion-reduce:transition-none",
"bg-card border-border",
);
const headerRow = (
<div className="flex items-start gap-3">
<span
aria-hidden="true"
className={cn(
"flex size-10 shrink-0 items-center justify-center rounded-md",
selected && card.state !== "disabled"
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground",
)}
>
<Icon className="size-5" />
</span>
<div className="min-w-0 flex-1">
<h2 className="text-base font-semibold tracking-tight">{titleLabel}</h2>
{badgeLabel ? (
<span
className="bg-muted text-muted-foreground mt-1 inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide"
data-testid={`source-badge-${card.slug}`}
>
{badgeLabel}
</span>
) : recommendedLabel ? (
<span className="bg-primary/10 text-primary mt-1 inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide">
{recommendedLabel}
</span>
) : null}
</div>
</div>
);
const body = (
<p className="text-muted-foreground text-sm leading-relaxed">{bodyLabel}</p>
);
if (card.state === "disabled") {
return (
<div
aria-disabled="true"
className={cn(baseClasses, "cursor-not-allowed opacity-60")}
data-testid={`source-card-${card.slug}`}
>
{headerRow}
{body}
<p className="text-muted-foreground text-xs italic">
{disabledHintLabel}
</p>
</div>
);
}
if (card.state === "enabled-oauth") {
return (
<div
className={cn(
baseClasses,
selected && "border-primary bg-primary/5",
"hover:border-primary/50 focus-within:border-primary/50",
)}
data-testid={`source-card-${card.slug}`}
>
{headerRow}
{body}
<div className="flex items-center gap-2 pt-1">
<Button asChild variant="outline" size="sm">
<a
href={card.href}
target="_blank"
rel="noopener"
onClick={onSelect}
className="inline-flex items-center gap-1.5"
>
<Wifi className="size-3.5" />
{withingsCtaLabel}
</a>
</Button>
</div>
</div>
);
}
// enabled-select (Manual)
return (
<button
type="button"
onClick={onSelect}
aria-pressed={selected}
className={cn(
baseClasses,
"cursor-pointer hover:border-primary/50",
selected && "border-primary bg-primary/5",
)}
data-testid={`source-card-${card.slug}`}
>
{headerRow}
{body}
</button>
);
}
async function readError(res: Response): Promise<string> {
try {
const json = (await res.json()) as { error?: string };
if (typeof json.error === "string" && json.error.length > 0) {
return json.error;
}
} catch {
/* fall through */
}
return `Request failed (${res.status})`;
}