mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
feat(onboarding): value-prop carousel on welcome step
This commit is contained in:
1 parent
cf27df49c6
commit
983c2ff26a
8 files changed
+445
-40
No files matched your search
+22
-4
@@ -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?",
|
||||
|
||||
+22
-4
@@ -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?",
|
||||
|
||||
+22
-4
@@ -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?",
|
||||
|
||||
+22
-4
@@ -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 ?",
|
||||
|
||||
+22
-4
@@ -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?",
|
||||
|
||||
+22
-4
@@ -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ć?",
|
||||
|
||||
@@ -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 (
|
||||
<OnboardingShell step={0} userLocale={user.locale ?? null}>
|
||||
<WelcomeCarousel />
|
||||
</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-
|
||||
// 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
|
||||
|
||||
@@ -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<CarouselSlide> = [
|
||||
{
|
||||
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<HTMLDivElement | null>(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<HTMLElement>("[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<HTMLElement>(
|
||||
`[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 (
|
||||
<section
|
||||
aria-labelledby="onboarding-welcome-title"
|
||||
className="space-y-6"
|
||||
>
|
||||
<h1
|
||||
id="onboarding-welcome-title"
|
||||
tabIndex={-1}
|
||||
className="text-2xl font-semibold tracking-tight"
|
||||
>
|
||||
{t("onboarding.welcome.title")}
|
||||
</h1>
|
||||
|
||||
<div className="relative">
|
||||
<div
|
||||
ref={railRef}
|
||||
role="region"
|
||||
aria-roledescription="carousel"
|
||||
aria-label={t("onboarding.welcome.carouselLabel")}
|
||||
className={cn(
|
||||
"flex w-full snap-x snap-mandatory gap-4 overflow-x-auto",
|
||||
// Hide native scrollbar; the dot pager is the visible affordance.
|
||||
"scrollbar-none [-ms-overflow-style:none] [scrollbar-width:none]",
|
||||
"[&::-webkit-scrollbar]:hidden",
|
||||
"scroll-smooth motion-reduce:scroll-auto",
|
||||
)}
|
||||
>
|
||||
{SLIDES.map((slide, idx) => (
|
||||
<article
|
||||
key={slide.id}
|
||||
data-slide-index={idx}
|
||||
role="group"
|
||||
aria-roledescription="slide"
|
||||
aria-label={t("onboarding.welcome.slideOf", {
|
||||
current: idx + 1,
|
||||
total: SLIDES.length,
|
||||
})}
|
||||
className={cn(
|
||||
"bg-card border-border flex w-full shrink-0 snap-start flex-col items-center gap-4",
|
||||
"rounded-xl border p-6 text-center sm:p-8",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="bg-primary/10 text-primary flex size-16 items-center justify-center rounded-full"
|
||||
>
|
||||
<slide.Icon className="size-8" />
|
||||
</span>
|
||||
<h2 className="text-lg font-semibold tracking-tight">
|
||||
{t(slide.titleKey)}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
{t(slide.bodyKey)}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 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. */}
|
||||
<p id={liveRegionId} aria-live="polite" className="sr-only">
|
||||
{t("onboarding.welcome.slideOf", {
|
||||
current: active + 1,
|
||||
total: SLIDES.length,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => scrollToSlide(Math.max(0, active - 1))}
|
||||
disabled={active === 0}
|
||||
aria-label={t("onboarding.welcome.prevSlide")}
|
||||
aria-controls={liveRegionId}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
</Button>
|
||||
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label={t("onboarding.welcome.carouselLabel")}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{SLIDES.map((slide, idx) => (
|
||||
<button
|
||||
key={slide.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active === idx}
|
||||
aria-label={t("onboarding.welcome.gotoSlide", {
|
||||
index: idx + 1,
|
||||
})}
|
||||
onClick={() => scrollToSlide(idx)}
|
||||
className={cn(
|
||||
"size-2.5 rounded-full transition-colors duration-150 ease-out motion-reduce:transition-none",
|
||||
active === idx ? "bg-primary" : "bg-muted hover:bg-muted-foreground/30",
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() =>
|
||||
scrollToSlide(Math.min(SLIDES.length - 1, active + 1))
|
||||
}
|
||||
disabled={active === SLIDES.length - 1}
|
||||
aria-label={t("onboarding.welcome.nextSlide")}
|
||||
aria-controls={liveRegionId}
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
onClick={handleGetStarted}
|
||||
disabled={advancing}
|
||||
>
|
||||
{t("onboarding.welcome.cta")}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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})`;
|
||||
}
|
||||
Reference in new issue
Block a user