4aa47ca61d
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>
162 lines
6.1 KiB
TypeScript
162 lines
6.1 KiB
TypeScript
"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>
|
|
);
|
|
}
|