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,75 @@
import { NextRequest, NextResponse } from "next/server";
import { db, users, userAllergens, eq } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
import { z } from "zod";
const ALLERGEN_TAGS = ["peanuts", "treeNuts", "dairy", "eggs", "shellfish", "fish", "soy", "gluten"] as const;
const CompleteSchema = z.object({
dietaryTags: z
.object({
vegan: z.boolean().optional(),
vegetarian: z.boolean().optional(),
glutenFree: z.boolean().optional(),
dairyFree: z.boolean().optional(),
nutFree: z.boolean().optional(),
halal: z.boolean().optional(),
kosher: z.boolean().optional(),
})
.optional(),
allergens: z.array(z.enum(ALLERGEN_TAGS)).optional(),
});
export async function GET() {
const { session, response } = await requireSession();
if (response) return response;
const [dbUser, allergenRows] = await Promise.all([
db.query.users.findFirst({
where: eq(users.id, session!.user.id),
columns: { onboardingCompletedAt: true, dietaryTags: true },
}),
db.select().from(userAllergens).where(eq(userAllergens.userId, session!.user.id)),
]);
return NextResponse.json({
data: {
completed: !!dbUser?.onboardingCompletedAt,
dietaryTags: dbUser?.dietaryTags ?? {},
allergens: allergenRows.map((r) => r.allergenTag),
},
});
}
// Marks onboarding done regardless of what (if anything) was filled in — a
// user can skip every step and this still fires so the wizard never shows
// again. dietaryTags/allergens are optional and only written if provided.
export async function POST(req: NextRequest) {
const { session, response } = await requireSession();
if (response) return response;
const parsed = CompleteSchema.safeParse(await req.json().catch(() => ({})));
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
}
const { dietaryTags, allergens } = parsed.data;
const userId = session!.user.id;
await db
.update(users)
.set({
onboardingCompletedAt: new Date(),
...(dietaryTags ? { dietaryTags } : {}),
})
.where(eq(users.id, userId));
if (allergens) {
await db.delete(userAllergens).where(eq(userAllergens.userId, userId));
if (allergens.length > 0) {
await db.insert(userAllergens).values(allergens.map((allergenTag) => ({ userId, allergenTag })));
}
}
return NextResponse.json({ ok: true });
}