diff --git a/messages/de.json b/messages/de.json index 7d0bc2052..3d0681aa0 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1330,7 +1330,6 @@ "loadError": "Zielwerte konnten nicht geladen werden" }, "onboarding": { - "welcome": "Willkommen bei HealthLog!", "setupDescription": "Richte dein Profil ein, um loszulegen. Du kannst das auch später in den Einstellungen ändern.", "completeSetup": "Einrichtung abschließen", "skip": "Überspringen", @@ -1429,9 +1428,28 @@ "step1of4": "Schritt 1 von 4", "step2of4": "Schritt 2 von 4", "step3of4": "Schritt 3 von 4", - "step4of4": "Schritt 4 von 4", - "welcomeTitle": "Willkommen bei HealthLog", - "welcomeBody": "Vier kurze Schritte zum Einstieg. Jeden Schritt kannst du später in den Einstellungen wiederholen." + "step4of4": "Schritt 4 von 4" + }, + "welcome": { + "title": "Willkommen bei HealthLog", + "carouselLabel": "Willkommen – drei Folien", + "slideOf": "Folie {current} von {total}", + "gotoSlide": "Zu Folie {index} wechseln", + "prevSlide": "Vorherige Folie", + "nextSlide": "Nächste Folie", + "slide1": { + "title": "Deine Gesundheitsdaten gehören dir", + "body": "HealthLog speichert deine Messwerte auf deiner Infrastruktur. Keine Werbung, keine Drittnutzung, kein Account-Zwang." + }, + "slide2": { + "title": "Eine Ansicht für jede Quelle", + "body": "Apple Health, Withings und manuelle Einträge laufen in einem konsistenten Verlauf zusammen – inklusive Quellenpriorität pro Metrik." + }, + "slide3": { + "title": "Coach und Insights mit Substanz", + "body": "Die KI-Auswertung stützt sich ausschließlich auf deine Werte und nennt jede Aussage mit Beleg. Bring-your-own-Key, keine Trainingsdaten." + }, + "cta": "Loslegen" }, "goals": { "title": "Was möchtest du erfassen?", diff --git a/messages/en.json b/messages/en.json index 6c9733150..dff0df6dc 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1330,7 +1330,6 @@ "loadError": "Could not load thresholds" }, "onboarding": { - "welcome": "Welcome to HealthLog!", "setupDescription": "Set up your profile to get started. You can change this later in Settings.", "completeSetup": "Complete setup", "skip": "Skip", @@ -1429,9 +1428,28 @@ "step1of4": "Step 1 of 4", "step2of4": "Step 2 of 4", "step3of4": "Step 3 of 4", - "step4of4": "Step 4 of 4", - "welcomeTitle": "Welcome to HealthLog", - "welcomeBody": "Set up the basics in four short steps. You can revisit any of them later from Settings." + "step4of4": "Step 4 of 4" + }, + "welcome": { + "title": "Welcome to HealthLog", + "carouselLabel": "Welcome — three slides", + "slideOf": "Slide {current} of {total}", + "gotoSlide": "Go to slide {index}", + "prevSlide": "Previous slide", + "nextSlide": "Next slide", + "slide1": { + "title": "Your health data, owned by you", + "body": "HealthLog stores every reading on your own infrastructure. No ads, no third-party sharing, no account lock-in." + }, + "slide2": { + "title": "One view across every source", + "body": "Apple Health, Withings and manual entries merge into a single consistent history — with per-metric source priority you control." + }, + "slide3": { + "title": "Coach and Insights with substance", + "body": "Evidence-grounded analysis built strictly from your numbers, every claim citable. Bring-your-own-key, no training on your data." + }, + "cta": "Get started" }, "goals": { "title": "What would you like to track?", diff --git a/messages/es.json b/messages/es.json index 49f588c10..7047899a2 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1330,7 +1330,6 @@ "loadError": "Could not load thresholds" }, "onboarding": { - "welcome": "Welcome to HealthLog!", "setupDescription": "Set up your profile to get started. You can change this later in Settings.", "completeSetup": "Complete setup", "skip": "Omitir", @@ -1429,9 +1428,28 @@ "step1of4": "Paso 1 de 4", "step2of4": "Paso 2 de 4", "step3of4": "Paso 3 de 4", - "step4of4": "Paso 4 de 4", - "welcomeTitle": "Te damos la bienvenida a HealthLog", - "welcomeBody": "Configura lo esencial en cuatro pasos cortos. Puedes revisar cualquiera más tarde desde Ajustes." + "step4of4": "Paso 4 de 4" + }, + "welcome": { + "title": "Te damos la bienvenida a HealthLog", + "carouselLabel": "Bienvenida — tres diapositivas", + "slideOf": "Diapositiva {current} de {total}", + "gotoSlide": "Ir a la diapositiva {index}", + "prevSlide": "Diapositiva anterior", + "nextSlide": "Diapositiva siguiente", + "slide1": { + "title": "Tus datos de salud, en tus manos", + "body": "HealthLog guarda cada medición en tu propia infraestructura. Sin publicidad, sin compartir con terceros, sin depender de una cuenta." + }, + "slide2": { + "title": "Una sola vista para cada fuente", + "body": "Apple Health, Withings y la entrada manual se unen en un historial consistente, con prioridad de fuente por métrica que controlas tú." + }, + "slide3": { + "title": "Coach e Insights con sustancia", + "body": "Un análisis basado solo en tus cifras, con cada afirmación verificable. Trae tu propia clave; tus datos no se usan para entrenar." + }, + "cta": "Empezar" }, "goals": { "title": "¿Qué te gustaría registrar?", diff --git a/messages/fr.json b/messages/fr.json index 60e310622..903822987 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1330,7 +1330,6 @@ "loadError": "Could not load thresholds" }, "onboarding": { - "welcome": "Welcome to HealthLog!", "setupDescription": "Set up your profile to get started. You can change this later in Settings.", "completeSetup": "Complete setup", "skip": "Ignorer", @@ -1429,9 +1428,28 @@ "step1of4": "Étape 1 sur 4", "step2of4": "Étape 2 sur 4", "step3of4": "Étape 3 sur 4", - "step4of4": "Étape 4 sur 4", - "welcomeTitle": "Bienvenue sur HealthLog", - "welcomeBody": "Configurez l'essentiel en quatre courtes étapes. Vous pourrez revenir sur chacune depuis les paramètres." + "step4of4": "Étape 4 sur 4" + }, + "welcome": { + "title": "Bienvenue sur HealthLog", + "carouselLabel": "Bienvenue — trois diapositives", + "slideOf": "Diapositive {current} sur {total}", + "gotoSlide": "Aller à la diapositive {index}", + "prevSlide": "Diapositive précédente", + "nextSlide": "Diapositive suivante", + "slide1": { + "title": "Vos données de santé vous appartiennent", + "body": "HealthLog conserve chaque mesure sur votre propre infrastructure. Sans publicité, sans partage à des tiers, sans dépendance à un compte." + }, + "slide2": { + "title": "Une seule vue pour toutes vos sources", + "body": "Apple Health, Withings et la saisie manuelle se rejoignent dans un historique cohérent, avec une priorité de source par métrique que vous contrôlez." + }, + "slide3": { + "title": "Un coach et des insights pertinents", + "body": "Une analyse fondée sur vos chiffres, chaque conclusion étant traçable. Apportez votre propre clé, aucun entraînement sur vos données." + }, + "cta": "Commencer" }, "goals": { "title": "Que souhaitez-vous suivre ?", diff --git a/messages/it.json b/messages/it.json index b75abacd3..e16223e5e 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1330,7 +1330,6 @@ "loadError": "Could not load thresholds" }, "onboarding": { - "welcome": "Welcome to HealthLog!", "setupDescription": "Set up your profile to get started. You can change this later in Settings.", "completeSetup": "Complete setup", "skip": "Salta", @@ -1429,9 +1428,28 @@ "step1of4": "Passo 1 di 4", "step2of4": "Passo 2 di 4", "step3of4": "Passo 3 di 4", - "step4of4": "Passo 4 di 4", - "welcomeTitle": "Benvenuto su HealthLog", - "welcomeBody": "Imposta l'essenziale in quattro brevi passi. Puoi rivederli tutti più tardi dalle impostazioni." + "step4of4": "Passo 4 di 4" + }, + "welcome": { + "title": "Benvenuto su HealthLog", + "carouselLabel": "Benvenuto — tre slide", + "slideOf": "Slide {current} di {total}", + "gotoSlide": "Vai alla slide {index}", + "prevSlide": "Slide precedente", + "nextSlide": "Slide successiva", + "slide1": { + "title": "I tuoi dati di salute, di tua proprietà", + "body": "HealthLog conserva ogni misurazione sulla tua infrastruttura. Niente pubblicità, nessuna condivisione con terzi, nessun vincolo di account." + }, + "slide2": { + "title": "Un'unica vista per ogni sorgente", + "body": "Apple Health, Withings e gli inserimenti manuali confluiscono in una cronologia coerente, con priorità di sorgente per metrica decisa da te." + }, + "slide3": { + "title": "Coach e Insights con sostanza", + "body": "Un'analisi basata solo sui tuoi dati, con ogni affermazione tracciabile. Bring-your-own-key: nessun addestramento sui tuoi dati." + }, + "cta": "Iniziamo" }, "goals": { "title": "Cosa vuoi monitorare?", diff --git a/messages/pl.json b/messages/pl.json index 685acbe6e..0135e7081 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1330,7 +1330,6 @@ "loadError": "Could not load thresholds" }, "onboarding": { - "welcome": "Welcome to HealthLog!", "setupDescription": "Set up your profile to get started. You can change this later in Settings.", "completeSetup": "Complete setup", "skip": "Pomiń", @@ -1429,9 +1428,28 @@ "step1of4": "Krok 1 z 4", "step2of4": "Krok 2 z 4", "step3of4": "Krok 3 z 4", - "step4of4": "Krok 4 z 4", - "welcomeTitle": "Witaj w HealthLog", - "welcomeBody": "Skonfiguruj podstawy w czterech krótkich krokach. Każdy z nich możesz później powtórzyć z poziomu ustawień." + "step4of4": "Krok 4 z 4" + }, + "welcome": { + "title": "Witaj w HealthLog", + "carouselLabel": "Powitanie — trzy slajdy", + "slideOf": "Slajd {current} z {total}", + "gotoSlide": "Przejdź do slajdu {index}", + "prevSlide": "Poprzedni slajd", + "nextSlide": "Następny slajd", + "slide1": { + "title": "Twoje dane zdrowotne należą do Ciebie", + "body": "HealthLog przechowuje każdy pomiar na Twojej infrastrukturze. Bez reklam, bez udostępniania osobom trzecim, bez przywiązywania do konta." + }, + "slide2": { + "title": "Jeden widok dla wszystkich źródeł", + "body": "Apple Health, Withings i wpisy ręczne łączą się w spójną historię — z priorytetem źródła dla każdej metryki, który ustalasz sam." + }, + "slide3": { + "title": "Coach i Insights z treścią", + "body": "Analiza oparta wyłącznie na Twoich liczbach, każde stwierdzenie jest weryfikowalne. Bring-your-own-key: Twoje dane nie służą do trenowania." + }, + "cta": "Zaczynamy" }, "goals": { "title": "Co chcesz śledzić?", diff --git a/src/app/onboarding/[step]/page.tsx b/src/app/onboarding/[step]/page.tsx index 596584632..e22e2d55b 100644 --- a/src/app/onboarding/[step]/page.tsx +++ b/src/app/onboarding/[step]/page.tsx @@ -1,6 +1,7 @@ import { redirect, notFound } from "next/navigation"; import { OnboardingShell } from "@/components/onboarding/OnboardingShell"; +import { WelcomeCarousel } from "@/components/onboarding/WelcomeCarousel"; import { getSession } from "@/lib/auth/session"; import { getServerTranslator } from "@/lib/i18n/server-translator"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; @@ -72,22 +73,23 @@ export default async function OnboardingStepPage({ params }: PageProps) { const locale = await resolveServerLocale({ userLocale: user.locale }); const { t } = getServerTranslator(locale); - // Foundation scaffold body — placeholder per-step copy. The Content - // agent replaces each branch's body with the real carousel / - // goal-chips / source-cards / baseline-form / done-screen UI. - // - // Welcome copy lives under `onboarding.shell.welcome{Title,Body}` - // because the carousel is part of the chrome; every other step - // owns its own namespace (`onboarding.goals.*`, `.source.*`, - // `.baseline.*`, `.done.*`). - const bodyTitle = - requested === 0 - ? t("onboarding.shell.welcomeTitle") - : t(`onboarding.${stepKey(requested)}.title`); - const bodyText = - requested === 0 - ? t("onboarding.shell.welcomeBody") - : t(`onboarding.${stepKey(requested)}.body`); + // Welcome (step 0) renders the client-side value-prop carousel. + // The carousel owns its own primary CTA (POST /api/onboarding/step + // with step:1) so the shell drops `nextHref` to avoid a double CTA. + if (requested === 0) { + 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- + // step component lands. + const bodyTitle = t(`onboarding.${stepKey(requested)}.title`); + const bodyText = t(`onboarding.${stepKey(requested)}.body`); // Wire shell navigation by step. Step 0 has no Back; the wizard // never lets the user navigate forward past their current step, but diff --git a/src/components/onboarding/WelcomeCarousel.tsx b/src/components/onboarding/WelcomeCarousel.tsx new file mode 100644 index 000000000..1466a8d06 --- /dev/null +++ b/src/components/onboarding/WelcomeCarousel.tsx @@ -0,0 +1,295 @@ +"use client"; + +import { useCallback, useEffect, useId, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; +import { toast } from "sonner"; +import { useQueryClient } from "@tanstack/react-query"; +import { + ChevronLeft, + ChevronRight, + HeartPulse, + Plug, + Sparkles, + 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 welcome carousel. + * + * Three value-prop slides shown on `/onboarding/0` before any data is + * asked of the user. Mirrors the Withings / Strava pattern surfaced in + * the W14b research file (§2.1, §2.5): pitch the product, then ask. + * + * Slides: + * 1. Tagline — "Your health data, owned by you." + * 2. Multi-source — Apple Health + Withings + manual, one view. + * 3. AI Coach + Insights — evidence-grounded, your data only. + * + * The carousel is a CSS scroll-snap rail; arrow buttons + dot pager + * mirror the active slide. `prefers-reduced-motion` disables the smooth + * scroll behaviour (per WCAG 2.3.3 / research §4.6). + * + * The primary CTA ("Get started") POSTs `/api/onboarding/step` with + * `{ step: 1 }`. On success it invalidates the `auth` query (so the + * user-shape's `onboardingStep` re-reads) and pushes to `/onboarding/1`. + */ + +interface CarouselSlide { + id: "tagline" | "sources" | "coach"; + Icon: LucideIcon; + titleKey: string; + bodyKey: string; +} + +const SLIDES: ReadonlyArray = [ + { + id: "tagline", + Icon: HeartPulse, + titleKey: "onboarding.welcome.slide1.title", + bodyKey: "onboarding.welcome.slide1.body", + }, + { + id: "sources", + Icon: Plug, + titleKey: "onboarding.welcome.slide2.title", + bodyKey: "onboarding.welcome.slide2.body", + }, + { + id: "coach", + Icon: Sparkles, + titleKey: "onboarding.welcome.slide3.title", + bodyKey: "onboarding.welcome.slide3.body", + }, +]; + +export function WelcomeCarousel() { + const { t } = useTranslations(); + const router = useRouter(); + const queryClient = useQueryClient(); + + const railRef = useRef(null); + const liveRegionId = useId(); + const [active, setActive] = useState(0); + const [advancing, setAdvancing] = useState(false); + + // Track which slide is centred in the rail. We observe each slide + // with an IntersectionObserver — simpler and more reliable than + // computing scroll-positions, and it handles touch flings naturally. + useEffect(() => { + const rail = railRef.current; + if (!rail) return; + + const slides = Array.from( + rail.querySelectorAll("[data-slide-index]"), + ); + if (slides.length === 0) return; + + const io = new IntersectionObserver( + (entries) => { + // Pick the entry whose intersection ratio is highest — that's + // the slide currently snapped into view. + let best: { idx: number; ratio: number } | null = null; + for (const e of entries) { + const idx = Number((e.target as HTMLElement).dataset.slideIndex); + if (!Number.isFinite(idx)) continue; + if (!best || e.intersectionRatio > best.ratio) { + best = { idx, ratio: e.intersectionRatio }; + } + } + if (best && best.ratio > 0.5) { + setActive(best.idx); + } + }, + { root: rail, threshold: [0.5, 0.75, 1] }, + ); + + for (const s of slides) io.observe(s); + return () => io.disconnect(); + }, []); + + const scrollToSlide = useCallback((idx: number) => { + const rail = railRef.current; + if (!rail) return; + const target = rail.querySelector( + `[data-slide-index="${idx}"]`, + ); + if (!target) return; + const reduceMotion = window.matchMedia( + "(prefers-reduced-motion: reduce)", + ).matches; + target.scrollIntoView({ + behavior: reduceMotion ? "auto" : "smooth", + inline: "start", + block: "nearest", + }); + }, []); + + async function handleGetStarted() { + if (advancing) return; + setAdvancing(true); + try { + const res = await fetch("/api/onboarding/step", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ step: 1 }), + }); + if (!res.ok) throw new Error(await readError(res)); + await queryClient.invalidateQueries({ queryKey: ["auth"] }); + router.push("/onboarding/1"); + } catch (err) { + const message = + err instanceof Error ? err.message : t("onboarding.errorGeneric"); + toast.error(message); + setAdvancing(false); + } + } + + return ( +
+

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

+ +
+
+ {SLIDES.map((slide, idx) => ( +
+ +

+ {t(slide.titleKey)} +

+

+ {t(slide.bodyKey)} +

+
+ ))} +
+ + {/* Live region announces slide changes to AT users — matches the + tour's pattern (tour.tsx:374) so the focus contract is + consistent across the onboarding surface. */} +

+ {t("onboarding.welcome.slideOf", { + current: active + 1, + total: SLIDES.length, + })} +

+
+ +
+ + +
+ {SLIDES.map((slide, idx) => ( +
+ + +
+ +
+ +
+
+ ); +} + +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})`; +}