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
+8
View File
@@ -2,6 +2,14 @@
All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together. All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together.
## 0.77.0 — 2026-07-24 13:00
### Added
- New post-signup onboarding wizard: a quick 3-step flow (welcome, dietary preferences & allergens, notification opt-in) shown once for new accounts, skippable at every step. Existing accounts are unaffected — only new signups see it.
### Fixed
- Allergen preferences (the `user_allergens` table) had a GDPR-export read path but no way to actually set them — the onboarding wizard's dietary/allergen step is the first UI that writes to it.
## 0.76.0 — 2026-07-24 12:15 ## 0.76.0 — 2026-07-24 12:15
### Added ### Added
+1
View File
@@ -71,6 +71,7 @@ Status legend: **Exists** (fully working) · **Partial** (works but with a real
| Auto-deduct pantry on cook | Exists (closed 2026-07-24) | Both UI callers that previously hardcoded `deductFromPantry: false` (`batch-cook-dishes.tsx`, `meal-planner.tsx`) now pass `true`. The new general "Mark cooked" feature (see below) additionally exposes it as a per-cook checkbox, default checked, rather than a silent always-on. | `apps/web/app/api/v1/recipes/[id]/cooked/route.ts`, `apps/web/components/recipe/batch-cook-dishes.tsx`, `apps/web/components/meal-plan/meal-planner.tsx` | | Auto-deduct pantry on cook | Exists (closed 2026-07-24) | Both UI callers that previously hardcoded `deductFromPantry: false` (`batch-cook-dishes.tsx`, `meal-planner.tsx`) now pass `true`. The new general "Mark cooked" feature (see below) additionally exposes it as a per-cook checkbox, default checked, rather than a silent always-on. | `apps/web/app/api/v1/recipes/[id]/cooked/route.ts`, `apps/web/components/recipe/batch-cook-dishes.tsx`, `apps/web/components/meal-plan/meal-planner.tsx` |
| Mark recipe as cooked (any recipe, not just batch-cook) | Exists (new, 2026-07-24) | A recipe can be logged as cooked any number of times — each log is a new `cookingHistory` row, no unique constraint, this was already true at the schema level, just not exposed for plain (non-batch) recipes before. New dialog on the recipe page: date (defaults today, can backdate), servings, and a deduct-from-pantry toggle (default on). Shows a "cooked N times · last DATE" indicator once logged. | `apps/web/components/recipe/{mark-cooked-dialog,mark-cooked-section}.tsx`, `apps/web/app/api/v1/recipes/[id]/cooked/route.ts` (`cookedAt` field, new) | | Mark recipe as cooked (any recipe, not just batch-cook) | Exists (new, 2026-07-24) | A recipe can be logged as cooked any number of times — each log is a new `cookingHistory` row, no unique constraint, this was already true at the schema level, just not exposed for plain (non-batch) recipes before. New dialog on the recipe page: date (defaults today, can backdate), servings, and a deduct-from-pantry toggle (default on). Shows a "cooked N times · last DATE" indicator once logged. | `apps/web/components/recipe/{mark-cooked-dialog,mark-cooked-section}.tsx`, `apps/web/app/api/v1/recipes/[id]/cooked/route.ts` (`cookedAt` field, new) |
| Billing/invoice details (full name, address, phone) | Exists (new, 2026-07-24) | New `userBillingDetails` table (1:1 with `users`), separate from the display `name` field. `fullName` is required by the form/API but nullable at the DB level (no backfill for existing users); address lines/city/postal code/country/phone are all optional. Lives under `Settings → Billing`, feeds future invoice generation — not wired into Stripe yet. | `packages/db/src/schema/billing.ts` (`userBillingDetails`), `apps/web/app/api/v1/users/me/billing-details/route.ts`, `apps/web/components/settings/billing-details-form.tsx` | | Billing/invoice details (full name, address, phone) | Exists (new, 2026-07-24) | New `userBillingDetails` table (1:1 with `users`), separate from the display `name` field. `fullName` is required by the form/API but nullable at the DB level (no backfill for existing users); address lines/city/postal code/country/phone are all optional. Lives under `Settings → Billing`, feeds future invoice generation — not wired into Stripe yet. | `packages/db/src/schema/billing.ts` (`userBillingDetails`), `apps/web/app/api/v1/users/me/billing-details/route.ts`, `apps/web/components/settings/billing-details-form.tsx` |
| Post-signup onboarding wizard | Exists (new, 2026-07-24) | 3-step wizard (welcome → dietary/allergen prefs → notification opt-in) shown once per account, right after the app shell layout loads for a user with no `onboardingCompletedAt`. Skippable at every step; existing accounts were backfilled as already-onboarded so the wizard only appears for new signups. Also closes a real pre-existing gap: `userAllergens` had a table and a GDPR-export read, but **no write path at all** — this ships the first one. Dietary tags are a new `users.dietaryTags` jsonb column (mirrors `recipes.dietaryTags`'s 7-tag shape); neither is yet consumed by AI generation or recipe matching. | `apps/web/app/onboarding/page.tsx`, `apps/web/components/onboarding/onboarding-wizard.tsx`, `apps/web/app/api/v1/users/me/onboarding/route.ts`, `apps/web/app/(app)/layout.tsx` (redirect gate) |
| Expiring-soon tracking | Exists | With "use it up" recipe suggestions. The leftover-expiry push/email cron only covers batch-cook dishes — plain pantry items only get an in-app badge, no reminder. | `apps/web/components/pantry/expiring-soon-suggestions.tsx`, `apps/web/app/api/internal/cron/leftover-reminders/route.ts` | | Expiring-soon tracking | Exists | With "use it up" recipe suggestions. The leftover-expiry push/email cron only covers batch-cook dishes — plain pantry items only get an in-app badge, no reminder. | `apps/web/components/pantry/expiring-soon-suggestions.tsx`, `apps/web/app/api/internal/cron/leftover-reminders/route.ts` |
| **Barcode scan (pantry)** | **Exists** | Real Open Food Facts API lookup by UPC/EAN | `apps/web/app/api/v1/pantry/scan/barcode` | | **Barcode scan (pantry)** | **Exists** | Real Open Food Facts API lookup by UPC/EAN | `apps/web/app/api/v1/pantry/scan/barcode` |
| Photo scan (pantry) | Exists | Vision-model based | `apps/web/app/api/v1/pantry/scan/photo` | | Photo scan (pantry) | Exists | Vision-model based | `apps/web/app/api/v1/pantry/scan/photo` |
+14 -1
View File
@@ -1,6 +1,19 @@
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { Nav } from "@/components/layout/nav"; 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 ( return (
<> <>
<Nav /> <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 });
}
+22
View File
@@ -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>
);
}
@@ -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>
);
}
+29
View File
@@ -0,0 +1,29 @@
"use client"
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"
import { cn } from "@/lib/utils"
import { CheckIcon } from "lucide-react"
function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
>
<CheckIcon
/>
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }
+11 -1
View File
@@ -1,5 +1,5 @@
// Mirrors CHANGELOG.md at the repo root — update both together. // Mirrors CHANGELOG.md at the repo root — update both together.
export const APP_VERSION = "0.76.0"; export const APP_VERSION = "0.77.0";
export type ChangelogEntry = { export type ChangelogEntry = {
version: string; version: string;
@@ -11,6 +11,16 @@ export type ChangelogEntry = {
}; };
export const CHANGELOG: ChangelogEntry[] = [ export const CHANGELOG: ChangelogEntry[] = [
{
version: "0.77.0",
date: "2026-07-24 13:00",
added: [
"New post-signup onboarding wizard: a quick 3-step flow (welcome, dietary preferences & allergens, notification opt-in) shown once for new accounts, skippable at every step. Existing accounts are unaffected — only new signups see it.",
],
fixed: [
"Allergen preferences (the user_allergens table) had a GDPR-export read path but no way to actually set them — the onboarding wizard's dietary/allergen step is the first UI that writes to it.",
],
},
{ {
version: "0.76.0", version: "0.76.0",
date: "2026-07-24 12:15", date: "2026-07-24 12:15",
+14
View File
@@ -497,6 +497,18 @@ export function generateOpenApiSpec(): object {
caloriesKcal: z.number().int().min(0).optional(), proteinG: z.number().int().min(0).optional(), caloriesKcal: z.number().int().min(0).optional(), proteinG: z.number().int().min(0).optional(),
carbsG: z.number().int().min(0).optional(), fatG: z.number().int().min(0).optional(), carbsG: z.number().int().min(0).optional(), fatG: z.number().int().min(0).optional(),
})); }));
const OnboardingStatusRef = registry.register("OnboardingStatus", z.object({
completed: z.boolean(),
dietaryTags: z.record(z.string(), z.boolean()),
allergens: z.array(z.string()),
}));
const CompleteOnboardingRef = registry.register("CompleteOnboarding", 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(["peanuts", "treeNuts", "dairy", "eggs", "shellfish", "fish", "soy", "gluten"])).optional(),
}));
const BillingDetailsRef = registry.register("BillingDetailsMe", z.object({ const BillingDetailsRef = registry.register("BillingDetailsMe", z.object({
id: z.string(), userId: z.string(), id: z.string(), userId: z.string(),
fullName: z.string().nullable(), addressLine1: z.string().nullable(), addressLine2: z.string().nullable(), fullName: z.string().nullable(), addressLine1: z.string().nullable(), addressLine2: z.string().nullable(),
@@ -558,6 +570,8 @@ export function generateOpenApiSpec(): object {
registry.registerPath({ method: "put", path: "/api/v1/users/me/nutrition-goals", summary: "Set your daily nutrition goals", security, request: { body: { content: { "application/json": { schema: UpdateNutritionGoalsRef } }, required: true } }, responses: { 200: { description: "Saved", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Invalid request", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "put", path: "/api/v1/users/me/nutrition-goals", summary: "Set your daily nutrition goals", security, request: { body: { content: { "application/json": { schema: UpdateNutritionGoalsRef } }, required: true } }, responses: { 200: { description: "Saved", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Invalid request", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/users/me/billing-details", summary: "Get your billing/invoice details", description: "Full name, address, and phone used on invoices — separate from your display name.", security, responses: { 200: { description: "Billing details or null", content: { "application/json": { schema: z.object({ data: BillingDetailsRef }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "get", path: "/api/v1/users/me/billing-details", summary: "Get your billing/invoice details", description: "Full name, address, and phone used on invoices — separate from your display name.", security, responses: { 200: { description: "Billing details or null", content: { "application/json": { schema: z.object({ data: BillingDetailsRef }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "put", path: "/api/v1/users/me/billing-details", summary: "Set your billing/invoice details", description: "fullName is required; addressLine1/2, city, postalCode, country, and phone are all optional.", security, request: { body: { content: { "application/json": { schema: UpdateBillingDetailsRef } }, required: true } }, responses: { 200: { description: "Saved", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Invalid request", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "put", path: "/api/v1/users/me/billing-details", summary: "Set your billing/invoice details", description: "fullName is required; addressLine1/2, city, postalCode, country, and phone are all optional.", security, request: { body: { content: { "application/json": { schema: UpdateBillingDetailsRef } }, required: true } }, responses: { 200: { description: "Saved", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Invalid request", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/users/me/onboarding", summary: "Get your post-signup onboarding status", security, responses: { 200: { description: "Status", content: { "application/json": { schema: z.object({ data: OnboardingStatusRef }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/users/me/onboarding", summary: "Complete (or skip) the post-signup onboarding wizard", description: "Marks onboarding done regardless of what's in the body — call with an empty body to skip entirely. dietaryTags/allergens are only written when provided.", security, request: { body: { content: { "application/json": { schema: CompleteOnboardingRef } } } }, responses: { 200: { description: "Completed", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Invalid request", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/users/search", summary: "Search users by name or username", description: "Excludes private accounts and any account you've blocked or that has blocked you. Requires at least 2 characters.", security, request: { query: z.object({ q: z.string().min(2).max(50) }) }, responses: { 200: { description: "Matches (up to 20)", content: { "application/json": { schema: z.object({ users: z.array(UserSearchResultRef) }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "get", path: "/api/v1/users/search", summary: "Search users by name or username", description: "Excludes private accounts and any account you've blocked or that has blocked you. Requires at least 2 characters.", security, request: { query: z.object({ q: z.string().min(2).max(50) }) }, responses: { 200: { description: "Matches (up to 20)", content: { "application/json": { schema: z.object({ users: z.array(UserSearchResultRef) }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/users/{username}", summary: "Get a public profile by username", security: [], request: { params: usernameParam }, responses: { 200: { description: "Profile", content: { "application/json": { schema: UserProfilePublicRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "get", path: "/api/v1/users/{username}", summary: "Get a public profile by username", security: [], request: { params: usernameParam }, responses: { 200: { description: "Profile", content: { "application/json": { schema: UserProfilePublicRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/users/{username}/follow", summary: "Follow a user", description: "Rate-limited: 30 req/min. Fails if either side has blocked the other.", security, request: { params: usernameParam }, responses: { 200: { description: "Following", content: { "application/json": { schema: z.object({ following: z.boolean() }) } } }, 400: { description: "Cannot follow yourself", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "post", path: "/api/v1/users/{username}/follow", summary: "Follow a user", description: "Rate-limited: 30 req/min. Fails if either side has blocked the other.", security, request: { params: usernameParam }, responses: { 200: { description: "Following", content: { "application/json": { schema: z.object({ following: z.boolean() }) } } }, 400: { description: "Cannot follow yourself", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
+32 -1
View File
@@ -766,7 +766,10 @@
"surpriseMe": "Surprise me", "surpriseMe": "Surprise me",
"generatingContent": "Generating recipe content…", "generatingContent": "Generating recipe content…",
"decrease": "Decrease", "decrease": "Decrease",
"increase": "Increase" "increase": "Increase",
"skip": "Skip",
"continue": "Continue",
"finish": "Finish"
}, },
"auth": { "auth": {
"signIn": "Sign in", "signIn": "Sign in",
@@ -1642,5 +1645,33 @@
"previous": "Previous", "previous": "Previous",
"next": "Next", "next": "Next",
"pageOf": "Page {page} of {total}" "pageOf": "Page {page} of {total}"
},
"onboarding": {
"stepOf": "Step {step} of {total}",
"welcomeTitle": "Welcome to Epicure",
"welcomeBody": "A couple of quick questions to tailor recipes and meal plans to you — takes under a minute, and you can skip any step.",
"dietaryTitle": "Dietary preferences & allergens",
"dietaryBody": "We'll use these to steer AI recipe generation and flag matching recipes — you can always change them later in Settings.",
"dietaryTagsLabel": "Diet",
"allergensLabel": "Allergens to avoid",
"dietVegan": "Vegan",
"dietVegetarian": "Vegetarian",
"dietGlutenFree": "Gluten-free",
"dietDairyFree": "Dairy-free",
"dietNutFree": "Nut-free",
"dietHalal": "Halal",
"dietKosher": "Kosher",
"allergenPeanuts": "Peanuts",
"allergenTreeNuts": "Tree nuts",
"allergenDairy": "Dairy",
"allergenEggs": "Eggs",
"allergenShellfish": "Shellfish",
"allergenFish": "Fish",
"allergenSoy": "Soy",
"allergenGluten": "Gluten",
"notificationsTitle": "Stay in the loop",
"notificationsBody": "Get notified about comments, follows, and shopping list reminders. You can fine-tune categories anytime in Settings → Notifications.",
"finishFailed": "Something went wrong — you can finish setup later from Settings",
"goToRecipes": "Go to recipes"
} }
} }
+32 -1
View File
@@ -766,7 +766,10 @@
"surpriseMe": "Surprends-moi", "surpriseMe": "Surprends-moi",
"generatingContent": "Génération du contenu de la recette…", "generatingContent": "Génération du contenu de la recette…",
"decrease": "Diminuer", "decrease": "Diminuer",
"increase": "Augmenter" "increase": "Augmenter",
"skip": "Passer",
"continue": "Continuer",
"finish": "Terminer"
}, },
"auth": { "auth": {
"signIn": "Se connecter", "signIn": "Se connecter",
@@ -1633,5 +1636,33 @@
"previous": "Précédent", "previous": "Précédent",
"next": "Suivant", "next": "Suivant",
"pageOf": "Page {page} sur {total}" "pageOf": "Page {page} sur {total}"
},
"onboarding": {
"stepOf": "Étape {step} sur {total}",
"welcomeTitle": "Bienvenue sur Epicure",
"welcomeBody": "Quelques questions rapides pour adapter les recettes et les plans de repas à vos besoins — moins d'une minute, et chaque étape peut être passée.",
"dietaryTitle": "Préférences alimentaires & allergènes",
"dietaryBody": "Nous les utiliserons pour orienter la génération de recettes par l'IA et repérer les recettes correspondantes — vous pourrez toujours les modifier plus tard dans les Réglages.",
"dietaryTagsLabel": "Régime",
"allergensLabel": "Allergènes à éviter",
"dietVegan": "Végan",
"dietVegetarian": "Végétarien",
"dietGlutenFree": "Sans gluten",
"dietDairyFree": "Sans lactose",
"dietNutFree": "Sans fruits à coque",
"dietHalal": "Halal",
"dietKosher": "Kasher",
"allergenPeanuts": "Arachides",
"allergenTreeNuts": "Fruits à coque",
"allergenDairy": "Produits laitiers",
"allergenEggs": "Œufs",
"allergenShellfish": "Crustacés",
"allergenFish": "Poisson",
"allergenSoy": "Soja",
"allergenGluten": "Gluten",
"notificationsTitle": "Restez informé",
"notificationsBody": "Recevez des notifications pour les commentaires, les abonnements et les rappels de liste de courses. Vous pourrez affiner les catégories à tout moment dans Réglages → Notifications.",
"finishFailed": "Une erreur est survenue — vous pourrez terminer la configuration plus tard dans les Réglages",
"goToRecipes": "Aller aux recettes"
} }
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@epicure/web", "name": "@epicure/web",
"version": "0.76.0", "version": "0.77.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "epicure", "name": "epicure",
"version": "0.76.0", "version": "0.77.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "pnpm --filter web dev", "dev": "pnpm --filter web dev",
@@ -0,0 +1,5 @@
ALTER TABLE "users" ADD COLUMN "onboarding_completed_at" timestamp;--> statement-breakpoint
ALTER TABLE "users" ADD COLUMN "dietary_tags" jsonb DEFAULT '{}'::jsonb;--> statement-breakpoint
-- Backfill existing accounts as already onboarded — the wizard is only for
-- users who sign up after this migration, not everyone who predates it.
UPDATE "users" SET "onboarding_completed_at" = "created_at" WHERE "onboarding_completed_at" IS NULL;
File diff suppressed because it is too large Load Diff
@@ -449,6 +449,13 @@
"when": 1784885406472, "when": 1784885406472,
"tag": "0063_sharp_stone_men", "tag": "0063_sharp_stone_men",
"breakpoints": true "breakpoints": true
},
{
"idx": 64,
"version": "7",
"when": 1784885885146,
"tag": "0064_thick_drax",
"breakpoints": true
} }
] ]
} }
+19
View File
@@ -4,11 +4,25 @@ import {
timestamp, timestamp,
boolean, boolean,
integer, integer,
jsonb,
pgEnum, pgEnum,
primaryKey, primaryKey,
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
import { relations } from "drizzle-orm"; import { relations } from "drizzle-orm";
// Mirrors recipes.ts's DietaryTags shape — duplicated rather than imported to
// avoid a users.ts <-> recipes.ts circular import (recipes.ts already
// references users.ts for the authorId FK).
export type UserDietaryTags = {
vegan?: boolean;
vegetarian?: boolean;
glutenFree?: boolean;
dairyFree?: boolean;
nutFree?: boolean;
halal?: boolean;
kosher?: boolean;
};
export const userRoleEnum = pgEnum("user_role", ["user", "moderator", "admin"]); export const userRoleEnum = pgEnum("user_role", ["user", "moderator", "admin"]);
export const tierEnum = pgEnum("tier", ["free", "pro", "family"]); export const tierEnum = pgEnum("tier", ["free", "pro", "family"]);
export const unitPrefEnum = pgEnum("unit_pref", ["metric", "imperial"]); export const unitPrefEnum = pgEnum("unit_pref", ["metric", "imperial"]);
@@ -55,6 +69,11 @@ export const users = pgTable("users", {
unitPref: unitPrefEnum("unit_pref").notNull().default("metric"), unitPref: unitPrefEnum("unit_pref").notNull().default("metric"),
locale: text("locale").notNull().default("en"), locale: text("locale").notNull().default("en"),
twoFactorEnabled: boolean("two_factor_enabled").notNull().default(false), twoFactorEnabled: boolean("two_factor_enabled").notNull().default(false),
// Null = hasn't been through (or finished/skipped) the post-signup
// onboarding wizard yet. Set once, either on completion or on skip — both
// count as "done," so the wizard never shows twice.
onboardingCompletedAt: timestamp("onboarding_completed_at"),
dietaryTags: jsonb("dietary_tags").$type<UserDietaryTags>().default({}),
createdAt: timestamp("created_at").notNull().defaultNow(), createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow(),
}); });