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:
@@ -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.
|
||||
|
||||
## 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
|
||||
|
||||
### Added
|
||||
|
||||
@@ -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` |
|
||||
| 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` |
|
||||
| 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` |
|
||||
| **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` |
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -1,5 +1,5 @@
|
||||
// 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 = {
|
||||
version: string;
|
||||
@@ -11,6 +11,16 @@ export type 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",
|
||||
date: "2026-07-24 12:15",
|
||||
|
||||
@@ -497,6 +497,18 @@ export function generateOpenApiSpec(): object {
|
||||
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(),
|
||||
}));
|
||||
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({
|
||||
id: z.string(), userId: z.string(),
|
||||
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: "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: "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/{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 } } } } });
|
||||
|
||||
@@ -766,7 +766,10 @@
|
||||
"surpriseMe": "Surprise me",
|
||||
"generatingContent": "Generating recipe content…",
|
||||
"decrease": "Decrease",
|
||||
"increase": "Increase"
|
||||
"increase": "Increase",
|
||||
"skip": "Skip",
|
||||
"continue": "Continue",
|
||||
"finish": "Finish"
|
||||
},
|
||||
"auth": {
|
||||
"signIn": "Sign in",
|
||||
@@ -1642,5 +1645,33 @@
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -766,7 +766,10 @@
|
||||
"surpriseMe": "Surprends-moi",
|
||||
"generatingContent": "Génération du contenu de la recette…",
|
||||
"decrease": "Diminuer",
|
||||
"increase": "Augmenter"
|
||||
"increase": "Augmenter",
|
||||
"skip": "Passer",
|
||||
"continue": "Continuer",
|
||||
"finish": "Terminer"
|
||||
},
|
||||
"auth": {
|
||||
"signIn": "Se connecter",
|
||||
@@ -1633,5 +1636,33 @@
|
||||
"previous": "Précédent",
|
||||
"next": "Suivant",
|
||||
"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,6 +1,6 @@
|
||||
{
|
||||
"name": "@epicure/web",
|
||||
"version": "0.76.0",
|
||||
"version": "0.77.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "epicure",
|
||||
"version": "0.76.0",
|
||||
"version": "0.77.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"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,
|
||||
"tag": "0063_sharp_stone_men",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 64,
|
||||
"version": "7",
|
||||
"when": 1784885885146,
|
||||
"tag": "0064_thick_drax",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -4,11 +4,25 @@ import {
|
||||
timestamp,
|
||||
boolean,
|
||||
integer,
|
||||
jsonb,
|
||||
pgEnum,
|
||||
primaryKey,
|
||||
} from "drizzle-orm/pg-core";
|
||||
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 tierEnum = pgEnum("tier", ["free", "pro", "family"]);
|
||||
export const unitPrefEnum = pgEnum("unit_pref", ["metric", "imperial"]);
|
||||
@@ -55,6 +69,11 @@ export const users = pgTable("users", {
|
||||
unitPref: unitPrefEnum("unit_pref").notNull().default("metric"),
|
||||
locale: text("locale").notNull().default("en"),
|
||||
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(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user