feat: post-signup onboarding wizard (dietary/allergen prefs, notifications, skippable) (v0.77.0)

New accounts land on a 3-step wizard after signup — welcome, dietary preferences & allergens, notification opt-in — skippable at every step, shown once via a users.onboardingCompletedAt gate in the (app) layout. Existing accounts are backfilled as already-onboarded.

Also gives the allergen-preferences step a real write path: user_allergens previously only had a GDPR-export read.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Arnaud
2026-07-24 11:46:07 +02:00
parent 003e8abe22
commit 4aa47ca61d
17 changed files with 6628 additions and 6 deletions
@@ -0,0 +1,161 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { PushSubscribeButton } from "@/components/pwa/push-subscribe-button";
const DIETARY_TAGS = ["vegan", "vegetarian", "glutenFree", "dairyFree", "nutFree", "halal", "kosher"] as const;
const ALLERGEN_TAGS = ["peanuts", "treeNuts", "dairy", "eggs", "shellfish", "fish", "soy", "gluten"] as const;
type DietaryTag = (typeof DIETARY_TAGS)[number];
type AllergenTag = (typeof ALLERGEN_TAGS)[number];
const DIET_LABEL_KEYS: Record<DietaryTag, string> = {
vegan: "dietVegan", vegetarian: "dietVegetarian", glutenFree: "dietGlutenFree", dairyFree: "dietDairyFree",
nutFree: "dietNutFree", halal: "dietHalal", kosher: "dietKosher",
};
const ALLERGEN_LABEL_KEYS: Record<AllergenTag, string> = {
peanuts: "allergenPeanuts", treeNuts: "allergenTreeNuts", dairy: "allergenDairy", eggs: "allergenEggs",
shellfish: "allergenShellfish", fish: "allergenFish", soy: "allergenSoy", gluten: "allergenGluten",
};
const TOTAL_STEPS = 3;
export function OnboardingWizard() {
const t = useTranslations("onboarding");
const tCommon = useTranslations("common");
const router = useRouter();
const [step, setStep] = useState(1);
const [dietaryTags, setDietaryTags] = useState<Partial<Record<DietaryTag, boolean>>>({});
const [allergens, setAllergens] = useState<Set<AllergenTag>>(new Set());
const [finishing, setFinishing] = useState(false);
function toggleDietaryTag(tag: DietaryTag, checked: boolean) {
setDietaryTags((prev) => ({ ...prev, [tag]: checked }));
}
function toggleAllergen(tag: AllergenTag, checked: boolean) {
setAllergens((prev) => {
const next = new Set(prev);
if (checked) next.add(tag);
else next.delete(tag);
return next;
});
}
async function finish(withData: boolean) {
setFinishing(true);
try {
await fetch("/api/v1/users/me/onboarding", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(withData ? { dietaryTags, allergens: Array.from(allergens) } : {}),
});
} catch {
// Onboarding completion isn't critical-path — if it fails, the user
// just sees the wizard once more next time. Not worth blocking or
// erroring loudly for a best-effort save.
} finally {
router.push("/recipes");
}
}
return (
<div className="min-h-[70vh] flex items-center justify-center">
<Card className="w-full max-w-lg">
<CardHeader>
<p className="text-xs text-muted-foreground">{t("stepOf", { step, total: TOTAL_STEPS })}</p>
<CardTitle className="flex items-center gap-2">
{step === 1 && <><Sparkles className="h-5 w-5 text-primary" />{t("welcomeTitle")}</>}
{step === 2 && t("dietaryTitle")}
{step === 3 && t("notificationsTitle")}
</CardTitle>
<CardDescription>
{step === 1 && t("welcomeBody")}
{step === 2 && t("dietaryBody")}
{step === 3 && t("notificationsBody")}
</CardDescription>
</CardHeader>
{step === 2 && (
<CardContent className="space-y-4">
<div className="space-y-2">
<p className="text-sm font-medium">{t("dietaryTagsLabel")}</p>
<div className="grid grid-cols-2 gap-2">
{DIETARY_TAGS.map((tag) => (
<div key={tag} className="flex items-center gap-2">
<Checkbox
id={`diet-${tag}`}
checked={!!dietaryTags[tag]}
onCheckedChange={(checked) => toggleDietaryTag(tag, checked === true)}
/>
<Label htmlFor={`diet-${tag}`} className="text-sm font-normal">{t(DIET_LABEL_KEYS[tag])}</Label>
</div>
))}
</div>
</div>
<div className="space-y-2">
<p className="text-sm font-medium">{t("allergensLabel")}</p>
<div className="grid grid-cols-2 gap-2">
{ALLERGEN_TAGS.map((tag) => (
<div key={tag} className="flex items-center gap-2">
<Checkbox
id={`allergen-${tag}`}
checked={allergens.has(tag)}
onCheckedChange={(checked) => toggleAllergen(tag, checked === true)}
/>
<Label htmlFor={`allergen-${tag}`} className="text-sm font-normal">{t(ALLERGEN_LABEL_KEYS[tag])}</Label>
</div>
))}
</div>
</div>
</CardContent>
)}
{step === 3 && (
<CardContent>
<PushSubscribeButton />
</CardContent>
)}
<CardFooter className="flex justify-between">
{step === 1 && (
<>
<Button variant="ghost" size="sm" disabled={finishing} onClick={() => { void finish(false); }}>
{tCommon("skip")}
</Button>
<Button size="sm" onClick={() => setStep(2)}>{tCommon("continue")}</Button>
</>
)}
{step === 2 && (
<>
<Button
variant="ghost"
size="sm"
onClick={() => {
setDietaryTags({});
setAllergens(new Set());
setStep(3);
}}
>
{tCommon("skip")}
</Button>
<Button size="sm" onClick={() => setStep(3)}>{tCommon("continue")}</Button>
</>
)}
{step === 3 && (
<Button size="sm" disabled={finishing} className="ml-auto" onClick={() => { void finish(true); }}>
{tCommon("finish")}
</Button>
)}
</CardFooter>
</Card>
</div>
);
}