diff --git a/messages/de.json b/messages/de.json index 157eda20b..2afa2b4f1 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index 3092a524f..aa6d4670f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index 1ed59bf88..6f7ad5582 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index 1a06a05de..fee40d516 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index 8c2c5a921..6efa7e6af 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index 0395eafdd..89c0f41ad 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/src/app/onboarding/[step]/page.tsx b/src/app/onboarding/[step]/page.tsx index 6adc3cca3..2308a2586 100644 --- a/src/app/onboarding/[step]/page.tsx +++ b/src/app/onboarding/[step]/page.tsx @@ -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 ( + + + + ); + } + // 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- diff --git a/src/components/onboarding/SourceCardGrid.tsx b/src/components/onboarding/SourceCardGrid.tsx new file mode 100644 index 000000000..d80cc6d9b --- /dev/null +++ b/src/components/onboarding/SourceCardGrid.tsx @@ -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 = [ + { + 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("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 ( +
+
+

+ {t("onboarding.source.title")} +

+

+ {t("onboarding.source.body")} +

+
+ +
+ {SOURCE_CARDS.map((card) => ( + 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")} + /> + ))} +
+ +
+ +
+ + +
+
+
+ ); +} + +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 = ( +
+ +
+

{titleLabel}

+ {badgeLabel ? ( + + {badgeLabel} + + ) : recommendedLabel ? ( + + {recommendedLabel} + + ) : null} +
+
+ ); + + const body = ( +

{bodyLabel}

+ ); + + if (card.state === "disabled") { + return ( +
+ {headerRow} + {body} +

+ {disabledHintLabel} +

+
+ ); + } + + if (card.state === "enabled-oauth") { + return ( +
+ {headerRow} + {body} +
+ +
+
+ ); + } + + // enabled-select (Manual) + return ( + + ); +} + +async function readError(res: Response): Promise { + 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})`; +}