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:
@@ -1,6 +1,19 @@
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Nav } from "@/components/layout/nav";
|
||||
import { auth } from "@/lib/auth/server";
|
||||
import { db, users, eq } from "@epicure/db";
|
||||
|
||||
export default async function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (session) {
|
||||
const dbUser = await db.query.users.findFirst({
|
||||
where: eq(users.id, session.user.id),
|
||||
columns: { onboardingCompletedAt: true },
|
||||
});
|
||||
if (!dbUser?.onboardingCompletedAt) redirect("/onboarding");
|
||||
}
|
||||
|
||||
export default function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<Nav />
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { headers } from "next/headers";
|
||||
import { auth } from "@/lib/auth/server";
|
||||
import { db, users, eq } from "@epicure/db";
|
||||
import { OnboardingWizard } from "@/components/onboarding/onboarding-wizard";
|
||||
|
||||
export default async function OnboardingPage() {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session) redirect("/login");
|
||||
|
||||
const dbUser = await db.query.users.findFirst({
|
||||
where: eq(users.id, session.user.id),
|
||||
columns: { onboardingCompletedAt: true },
|
||||
});
|
||||
if (dbUser?.onboardingCompletedAt) redirect("/recipes");
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<OnboardingWizard />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user