mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
feat(onboarding): source 4-card grid with Apple Health coming-soon
This commit is contained in:
1 parent
c7b1fdb022
commit
888efd6cf4
8 files changed
+432
-6
No files matched your search
+17
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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})`;
|
||||
}
|
||||
Reference in new issue
Block a user