feat: public marketing site (vitrine)

Adds a (marketing) route group -- Home, Features, About, Privacy,
Terms, Contact -- reusing the app's design system/i18n/deploy
pipeline rather than a separate site. Root page.tsx now branches on
session instead of always redirecting to /recipes: logged-in visitors
still land in the app unchanged, logged-out visitors see the vitrine
home page.

The actual integration point: proxy.ts's PUBLIC_PATHS previously sent
every anonymous "/" request straight to /login before page.tsx's
redirect ever ran. Added the new marketing routes to PUBLIC_PATHS,
plus a separate exact-match list for "/" itself -- it can't go in the
startsWith-matched array, since every path starts with "/" and that
would make the whole app public.

Also adds app/sitemap.ts and app/robots.ts (neither existed before),
disallowing the authenticated app and the unlisted /r/ and /s/ share
routes from crawling while allowing /u/ public profiles.

Pricing page deliberately not included -- waiting on Stripe Checkout
(STRIPE_PLAN.md) so its CTA goes somewhere real. Privacy/Terms are
structural drafts flagged inline as not lawyer-reviewed, per
STRIPE_PLAN.md's own tax-advice caveat -- same spirit here.

Verified with a full production build (pnpm build) -- compiles clean,
all 7 new routes render, since there's no DB in this sandbox to run
the dev server against for a real click-through.

v0.48.0
This commit is contained in:
Arnaud
2026-07-18 09:33:47 +02:00
parent 2a256a8943
commit e71c978e52
19 changed files with 533 additions and 10 deletions
+7
View File
@@ -2,6 +2,13 @@
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.48.0 — 2026-07-18 09:45
### Added
- Public marketing pages — Home, Features, About, Privacy, Terms, Contact — visible to logged-out visitors at the root domain, plus a sitemap and robots.txt. Logged-in users hitting "/" still land straight in the app, unchanged.
Note: Privacy and Terms are structural drafts, not lawyer-reviewed (see `VITRINE_PLAN.md` §4). Pricing page intentionally not included yet (waiting on Stripe Checkout per `STRIPE_PLAN.md`).
## 0.47.0 — 2026-07-18 00:30
Renamed the "Team" billing tier to "Family" (free/pro/family) — same limits, same admin editing, just the name. Existing Team users keep their tier/limits unaffected; the DB enum value itself was renamed in place (no data migration needed).
+23
View File
@@ -0,0 +1,23 @@
import type { Metadata } from "next";
import { getMessages } from "@/lib/i18n/server";
export const metadata: Metadata = {
title: "About — Epicure",
description: "The story behind Epicure.",
};
export default function AboutPage() {
const m = getMessages(undefined);
const t = m.marketing.about;
return (
<div className="container mx-auto px-4 py-16 max-w-2xl space-y-6">
<h1 className="text-3xl font-bold tracking-tight">{t.title}</h1>
<div className="space-y-4 text-muted-foreground leading-relaxed">
{t.paragraphs.map((p, i) => (
<p key={i}>{p}</p>
))}
</div>
</div>
);
}
+29
View File
@@ -0,0 +1,29 @@
import type { Metadata } from "next";
import { Mail } from "lucide-react";
import { getMessages } from "@/lib/i18n/server";
export const metadata: Metadata = {
title: "Contact — Epicure",
description: "Get in touch with the Epicure team.",
};
const SUPPORT_EMAIL = "support@epicure.app";
export default function ContactPage() {
const m = getMessages(undefined);
const t = m.marketing.contact;
return (
<div className="container mx-auto px-4 py-16 max-w-lg text-center space-y-6">
<h1 className="text-3xl font-bold tracking-tight">{t.title}</h1>
<p className="text-muted-foreground">{t.subtitle}</p>
<a
href={`mailto:${SUPPORT_EMAIL}`}
className="inline-flex items-center gap-2 rounded-lg border px-4 py-2.5 text-sm font-medium hover:bg-accent transition-colors"
>
<Mail className="h-4 w-4" />
{SUPPORT_EMAIL}
</a>
</div>
);
}
@@ -0,0 +1,54 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Sparkles, Calendar, Package, MessageCircle, Users, ChefHat, Camera, ListChecks } from "lucide-react";
import { getMessages } from "@/lib/i18n/server";
import { buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export const metadata: Metadata = {
title: "Features — Epicure",
description: "AI recipe generation, meal planning, pantry tracking, and a cooking assistant that can act on your behalf.",
};
const FEATURES = [
{ icon: Sparkles, key: "ai" },
{ icon: Camera, key: "photoImport" },
{ icon: MessageCircle, key: "assistant" },
{ icon: Calendar, key: "mealPlan" },
{ icon: Package, key: "pantry" },
{ icon: ListChecks, key: "shoppingList" },
{ icon: Users, key: "social" },
{ icon: ChefHat, key: "batchCook" },
] as const;
export default function FeaturesPage() {
const m = getMessages(undefined);
const t = m.marketing.features;
return (
<div className="container mx-auto px-4 py-16 space-y-12">
<div className="text-center space-y-4 max-w-2xl mx-auto">
<h1 className="text-3xl sm:text-4xl font-bold tracking-tight">{t.title}</h1>
<p className="text-lg text-muted-foreground">{t.subtitle}</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-4xl mx-auto">
{FEATURES.map(({ icon: Icon, key }) => (
<div key={key} className="rounded-xl border bg-card p-6 space-y-3">
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center">
<Icon className="h-5 w-5 text-primary" />
</div>
<h3 className="font-semibold text-lg">{t.items[key].title}</h3>
<p className="text-sm text-muted-foreground leading-relaxed">{t.items[key].description}</p>
</div>
))}
</div>
<div className="text-center pt-4">
<Link href="/signup" className={cn(buttonVariants({ size: "lg" }))}>
{t.cta}
</Link>
</div>
</div>
);
}
+17
View File
@@ -0,0 +1,17 @@
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { MarketingNav } from "@/components/marketing/marketing-nav";
import { MarketingFooter } from "@/components/marketing/marketing-footer";
export default async function MarketingLayout({ children }: { children: React.ReactNode }) {
const session = await auth.api.getSession({ headers: await headers() }).catch(() => null);
const locale = (session?.user as { locale?: string } | undefined)?.locale;
return (
<div className="flex min-h-screen flex-col">
<MarketingNav />
<main className="flex-1">{children}</main>
<MarketingFooter locale={locale} />
</div>
);
}
+68
View File
@@ -0,0 +1,68 @@
import { redirect } from "next/navigation";
import { headers } from "next/headers";
import Link from "next/link";
import { auth } from "@/lib/auth/server";
import { getMessages } from "@/lib/i18n/server";
import { buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { Sparkles, Calendar, Package, MessageCircle, Users, ChefHat } from "lucide-react";
const HIGHLIGHTS = [
{ icon: Sparkles, key: "ai" },
{ icon: MessageCircle, key: "assistant" },
{ icon: Calendar, key: "mealPlan" },
{ icon: Package, key: "pantry" },
{ icon: Users, key: "social" },
{ icon: ChefHat, key: "batchCook" },
] as const;
export default async function MarketingHomePage() {
const session = await auth.api.getSession({ headers: await headers() });
if (session) redirect("/recipes");
const m = getMessages(undefined);
const t = m.marketing.home;
return (
<div>
<section className="container mx-auto px-4 py-20 sm:py-28 text-center space-y-6">
<h1 className="text-4xl sm:text-5xl font-bold tracking-tight max-w-2xl mx-auto">
{t.heroTitle}
</h1>
<p className="text-lg text-muted-foreground max-w-xl mx-auto">
{t.heroSubtitle}
</p>
<div className="flex items-center justify-center gap-3 pt-2">
<Link href="/signup" className={cn(buttonVariants({ size: "lg" }))}>
{t.ctaPrimary}
</Link>
<Link href="/features" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
{t.ctaSecondary}
</Link>
</div>
</section>
<section className="container mx-auto px-4 py-16 border-t">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{HIGHLIGHTS.map(({ icon: Icon, key }) => (
<div key={key} className="rounded-xl border bg-card p-6 space-y-3">
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center">
<Icon className="h-5 w-5 text-primary" />
</div>
<h3 className="font-semibold">{t.highlights[key].title}</h3>
<p className="text-sm text-muted-foreground leading-relaxed">{t.highlights[key].description}</p>
</div>
))}
</div>
</section>
<section className="container mx-auto px-4 py-16 border-t text-center space-y-4">
<h2 className="text-2xl font-semibold">{t.bottomCtaTitle}</h2>
<p className="text-muted-foreground max-w-lg mx-auto">{t.bottomCtaSubtitle}</p>
<Link href="/signup" className={cn(buttonVariants({ size: "lg" }))}>
{t.ctaPrimary}
</Link>
</section>
</div>
);
}
+47
View File
@@ -0,0 +1,47 @@
import type { Metadata } from "next";
import { AlertTriangle } from "lucide-react";
export const metadata: Metadata = {
title: "Privacy Policy — Epicure",
description: "How Epicure collects, uses, and protects your data.",
};
export default function PrivacyPage() {
return (
<div className="container mx-auto px-4 py-16 max-w-2xl space-y-6">
<div className="rounded-lg border border-yellow-500/40 bg-yellow-500/10 p-4 flex gap-3 text-sm">
<AlertTriangle className="h-4 w-4 text-yellow-600 shrink-0 mt-0.5" />
<p>
<strong>Draft not yet reviewed by counsel.</strong> This page is a
placeholder structure, not a finished, legally-binding policy. Replace
before accepting real signups/payments (see <code>STRIPE_PLAN.md</code> §10).
</p>
</div>
<h1 className="text-3xl font-bold tracking-tight">Privacy Policy</h1>
<div className="space-y-6 text-sm text-muted-foreground leading-relaxed">
<section className="space-y-2">
<h2 className="font-semibold text-foreground">Data we collect</h2>
<p>Account information (email, name), recipes and content you create, usage data (AI calls, storage), and payment information (processed by Stripe we never store card details ourselves).</p>
</section>
<section className="space-y-2">
<h2 className="font-semibold text-foreground">How we use it</h2>
<p>To provide the service (store your recipes, run AI features you request, process subscriptions), and nothing beyond that no data is sold to third parties.</p>
</section>
<section className="space-y-2">
<h2 className="font-semibold text-foreground">Third parties</h2>
<p>AI providers (OpenAI/Anthropic/OpenRouter/Ollama, depending on your configuration), Stripe for payments, and your own configured integrations (webhooks, API keys) that you explicitly set up.</p>
</section>
<section className="space-y-2">
<h2 className="font-semibold text-foreground">Your rights</h2>
<p>Access, export, correct, or delete your data at any time from Settings, or by contacting us (see Contact page). EU/EEA users have rights under GDPR.</p>
</section>
<section className="space-y-2">
<h2 className="font-semibold text-foreground">Contact</h2>
<p>Questions about this policy see the Contact page.</p>
</section>
</div>
</div>
);
}
+47
View File
@@ -0,0 +1,47 @@
import type { Metadata } from "next";
import { AlertTriangle } from "lucide-react";
export const metadata: Metadata = {
title: "Terms of Service — Epicure",
description: "The terms governing use of Epicure.",
};
export default function TermsPage() {
return (
<div className="container mx-auto px-4 py-16 max-w-2xl space-y-6">
<div className="rounded-lg border border-yellow-500/40 bg-yellow-500/10 p-4 flex gap-3 text-sm">
<AlertTriangle className="h-4 w-4 text-yellow-600 shrink-0 mt-0.5" />
<p>
<strong>Draft not yet reviewed by counsel.</strong> This page is a
placeholder structure, not finished, legally-binding terms. Replace
before accepting real signups/payments (see <code>STRIPE_PLAN.md</code> §10).
</p>
</div>
<h1 className="text-3xl font-bold tracking-tight">Terms of Service</h1>
<div className="space-y-6 text-sm text-muted-foreground leading-relaxed">
<section className="space-y-2">
<h2 className="font-semibold text-foreground">Using Epicure</h2>
<p>You&apos;re responsible for the content you create and share, and for keeping your account credentials secure.</p>
</section>
<section className="space-y-2">
<h2 className="font-semibold text-foreground">Subscriptions</h2>
<p>Paid tiers (Pro, Family) renew automatically until cancelled. Cancel anytime from Settings access continues until the end of the paid period.</p>
</section>
<section className="space-y-2">
<h2 className="font-semibold text-foreground">Content ownership</h2>
<p>You own what you create. We only use it to provide the service to you (and, if you set a recipe public/unlisted/followers-visible, to whoever you&apos;ve chosen to share it with).</p>
</section>
<section className="space-y-2">
<h2 className="font-semibold text-foreground">AI-generated content</h2>
<p>AI-generated recipes are provided as-is always use your own judgment, especially around allergens and food safety.</p>
</section>
<section className="space-y-2">
<h2 className="font-semibold text-foreground">Changes</h2>
<p>We may update these terms; meaningful changes will be communicated in-app.</p>
</section>
</div>
</div>
);
}
-5
View File
@@ -1,5 +0,0 @@
import { redirect } from "next/navigation";
export default function RootPage() {
redirect("/recipes");
}
+17
View File
@@ -0,0 +1,17 @@
import type { MetadataRoute } from "next";
const BASE_URL = process.env["BETTER_AUTH_URL"] ?? "http://localhost:3000";
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: "*",
allow: ["/", "/features", "/pricing", "/about", "/privacy", "/terms", "/contact"],
// /r/ and /s/ are unlisted share links (recipe/shopping-list) — "unlisted"
// means reachable by direct link only, not meant to be crawled/indexed.
// /u/ (public profiles) is deliberately left crawlable.
disallow: ["/api/", "/admin/", "/recipes", "/explore", "/meal-plan", "/pantry", "/shopping-lists", "/settings", "/collections", "/nutrition", "/r/", "/s/"],
},
sitemap: `${BASE_URL}/sitemap.xml`,
};
}
+13
View File
@@ -0,0 +1,13 @@
import type { MetadataRoute } from "next";
const BASE_URL = process.env["BETTER_AUTH_URL"] ?? "http://localhost:3000";
export default function sitemap(): MetadataRoute.Sitemap {
const routes = ["", "/features", "/pricing", "/about", "/privacy", "/terms", "/contact"];
return routes.map((route) => ({
url: `${BASE_URL}${route}`,
lastModified: new Date(),
changeFrequency: route === "" ? "weekly" : "monthly",
priority: route === "" ? 1 : 0.5,
}));
}
@@ -0,0 +1,28 @@
import Link from "next/link";
import { getMessages, formatMessage } from "@/lib/i18n/server";
const LINKS = [
{ href: "/about", key: "about" },
{ href: "/privacy", key: "privacy" },
{ href: "/terms", key: "terms" },
{ href: "/contact", key: "contact" },
] as const;
export function MarketingFooter({ locale }: { locale?: string }) {
const m = getMessages(locale);
return (
<footer className="border-t mt-auto">
<div className="container mx-auto px-4 py-8 flex flex-col sm:flex-row items-center justify-between gap-4 text-sm text-muted-foreground">
<p>{formatMessage(m.marketing.footer.copyright, { year: 2026 })}</p>
<nav className="flex items-center gap-4">
{LINKS.map(({ href, key }) => (
<Link key={href} href={href} className="hover:text-foreground transition-colors">
{m.marketing.footer[key]}
</Link>
))}
</nav>
</div>
</footer>
);
}
@@ -0,0 +1,51 @@
import Link from "next/link";
import { headers } from "next/headers";
import { ChefHat } from "lucide-react";
import { auth } from "@/lib/auth/server";
import { buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { getMessages } from "@/lib/i18n/server";
const LINKS = [
{ href: "/features", key: "features" },
{ href: "/about", key: "about" },
] as const;
export async function MarketingNav() {
const session = await auth.api.getSession({ headers: await headers() }).catch(() => null);
const m = getMessages((session?.user as { locale?: string } | undefined)?.locale);
return (
<header className="sticky top-0 z-50 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container mx-auto flex h-14 items-center gap-6 px-4">
<Link href="/" className="flex items-center gap-2 font-semibold">
<ChefHat className="h-5 w-5" />
<span>Epicure</span>
</Link>
<nav className="hidden md:flex items-center gap-1 text-sm text-muted-foreground">
{LINKS.map(({ href, key }) => (
<Link key={href} href={href} className="px-3 py-2 rounded-md hover:text-foreground hover:bg-accent transition-colors">
{m.marketing.nav[key]}
</Link>
))}
</nav>
<div className="ml-auto flex items-center gap-2">
{session ? (
<Link href="/recipes" className={cn(buttonVariants({ size: "sm" }))}>
{m.marketing.nav.openApp}
</Link>
) : (
<>
<Link href="/login" className={cn(buttonVariants({ variant: "ghost", size: "sm" }))}>
{m.marketing.nav.login}
</Link>
<Link href="/signup" className={cn(buttonVariants({ size: "sm" }))}>
{m.marketing.nav.signup}
</Link>
</>
)}
</div>
</div>
</header>
);
}
+9 -1
View File
@@ -1,5 +1,5 @@
// Mirrors CHANGELOG.md at the repo root — update both together.
export const APP_VERSION = "0.47.0";
export const APP_VERSION = "0.48.0";
export type ChangelogEntry = {
version: string;
@@ -11,6 +11,14 @@ export type ChangelogEntry = {
};
export const CHANGELOG: ChangelogEntry[] = [
{
version: "0.48.0",
date: "2026-07-18 09:45",
added: [
"Public marketing pages — Home, Features, About, Privacy, Terms, Contact — visible to logged-out visitors at the root domain, plus a sitemap and robots.txt. Logged-in users hitting \"/\" still land straight in the app, unchanged.",
],
notes: "Privacy and Terms are structural drafts, not lawyer-reviewed — see VITRINE_PLAN.md §4. Pricing page intentionally not included yet (waiting on Stripe Checkout per STRIPE_PLAN.md).",
},
{
version: "0.47.0",
date: "2026-07-18 00:30",
+58
View File
@@ -1,4 +1,62 @@
{
"marketing": {
"nav": {
"features": "Features",
"about": "About",
"openApp": "Open app",
"login": "Log in",
"signup": "Sign up"
},
"footer": {
"about": "About",
"privacy": "Privacy",
"terms": "Terms",
"contact": "Contact",
"copyright": "© {year} Epicure"
},
"home": {
"heroTitle": "Your AI-powered recipe book",
"heroSubtitle": "Generate, organize, and share recipes — plan meals, track your pantry, and cook with an assistant that can act on your behalf.",
"ctaPrimary": "Get started free",
"ctaSecondary": "See features",
"bottomCtaTitle": "Start cooking smarter",
"bottomCtaSubtitle": "Free to start. No credit card required.",
"highlights": {
"ai": { "title": "AI recipe generation", "description": "Generate a full recipe from an idea, a photo, or a URL — vision and text models work together to write the details." },
"assistant": { "title": "Cooking assistant", "description": "Ask cooking questions anytime — it can even draft a recipe or shopping list right in the conversation, for you to review and confirm." },
"mealPlan": { "title": "Meal planning", "description": "Plan your week, generate a plan with AI, and turn it straight into a shopping list." },
"pantry": { "title": "Pantry tracking", "description": "Know what you have on hand — scan it in, and let recipes account for it automatically." },
"social": { "title": "Share & discover", "description": "Follow other cooks, rate and comment on recipes, and control exactly who can see yours." },
"batchCook": { "title": "Batch cooking", "description": "Cook once, eat all week — dedicated batch-cook planning with fridge/freezer guidance." }
}
},
"features": {
"title": "Everything you need to cook smarter",
"subtitle": "From a single idea to a finished, organized recipe collection.",
"cta": "Try it free",
"items": {
"ai": { "title": "AI recipe generation", "description": "Generate a recipe from a short idea, a URL, or a description — dish or drink, automatically detected." },
"photoImport": { "title": "Import from a photo", "description": "A vision model recognizes what's in the photo, then a text model writes the full recipe from that description." },
"assistant": { "title": "Cooking assistant with tool-calling", "description": "A conversational assistant for cooking questions that can also draft a recipe or shopping-list additions for you to confirm — nothing saves without your say." },
"mealPlan": { "title": "AI meal planning", "description": "Generate a week of meals around your preferences, dietary needs, and what's already in your pantry." },
"pantry": { "title": "Pantry tracking", "description": "Track what you have — scan barcodes or photos — and let it inform meal planning and shopping lists." },
"shoppingList": { "title": "Shopping lists", "description": "Multiple lists, shared with others, auto-populated from recipes or meal plans." },
"social": { "title": "Follows, ratings & comments", "description": "A social layer for recipes — follow other cooks, rate and review, with granular visibility controls including followers-only." },
"batchCook": { "title": "Batch cooking", "description": "Plan cook-once-eat-all-week sessions with per-dish fridge/freezer guidance." }
}
},
"about": {
"title": "About Epicure",
"paragraphs": [
"Epicure started as a simple idea: a recipe book that actually keeps up with how you cook — planning, pantry, and AI all in one place instead of five different apps.",
"It's an independent project, built and maintained by one person, growing based on what's actually useful to the people using it."
]
},
"contact": {
"title": "Get in touch",
"subtitle": "Questions, feedback, or something broken — email us and we'll get back to you."
}
},
"nav": {
"menu": "Menu",
"recipes": "Recipes",
+58
View File
@@ -1,4 +1,62 @@
{
"marketing": {
"nav": {
"features": "Fonctionnalités",
"about": "À propos",
"openApp": "Ouvrir l'application",
"login": "Connexion",
"signup": "Inscription"
},
"footer": {
"about": "À propos",
"privacy": "Confidentialité",
"terms": "Conditions",
"contact": "Contact",
"copyright": "© {year} Epicure"
},
"home": {
"heroTitle": "Votre carnet de recettes propulsé par l'IA",
"heroSubtitle": "Générez, organisez et partagez vos recettes — planifiez vos repas, suivez votre garde-manger, et cuisinez avec un assistant capable d'agir pour vous.",
"ctaPrimary": "Commencer gratuitement",
"ctaSecondary": "Voir les fonctionnalités",
"bottomCtaTitle": "Cuisinez plus intelligemment",
"bottomCtaSubtitle": "Gratuit pour commencer. Aucune carte bancaire requise.",
"highlights": {
"ai": { "title": "Génération de recettes par IA", "description": "Générez une recette complète à partir d'une idée, d'une photo ou d'une URL — modèles de vision et de texte travaillent ensemble pour rédiger les détails." },
"assistant": { "title": "Assistant culinaire", "description": "Posez vos questions de cuisine à tout moment — il peut même rédiger une recette ou une liste de courses directement dans la conversation, pour que vous la validiez." },
"mealPlan": { "title": "Planification de repas", "description": "Planifiez votre semaine, générez un plan avec l'IA, et transformez-le directement en liste de courses." },
"pantry": { "title": "Suivi du garde-manger", "description": "Sachez ce que vous avez sous la main — scannez-le, et laissez les recettes en tenir compte automatiquement." },
"social": { "title": "Partager & découvrir", "description": "Suivez d'autres cuisiniers, notez et commentez les recettes, et contrôlez précisément qui peut voir les vôtres." },
"batchCook": { "title": "Cuisine en lots", "description": "Cuisinez une fois, mangez toute la semaine — planification dédiée avec conseils de conservation au frigo/congélateur." }
}
},
"features": {
"title": "Tout ce qu'il faut pour cuisiner plus intelligemment",
"subtitle": "D'une simple idée à une collection de recettes organisée.",
"cta": "Essayer gratuitement",
"items": {
"ai": { "title": "Génération de recettes par IA", "description": "Générez une recette à partir d'une courte idée, d'une URL ou d'une description — plat ou boisson, détecté automatiquement." },
"photoImport": { "title": "Importer depuis une photo", "description": "Un modèle de vision identifie ce qui est sur la photo, puis un modèle de texte rédige la recette complète à partir de cette description." },
"assistant": { "title": "Assistant culinaire avec actions", "description": "Un assistant conversationnel pour vos questions de cuisine, capable aussi de rédiger une recette ou d'ajouter des articles à une liste de courses pour que vous les validiez — rien n'est enregistré sans votre accord." },
"mealPlan": { "title": "Planification de repas par IA", "description": "Générez une semaine de repas selon vos préférences, besoins alimentaires, et ce qui est déjà dans votre garde-manger." },
"pantry": { "title": "Suivi du garde-manger", "description": "Suivez ce que vous avez — scannez codes-barres ou photos — pour informer la planification et les listes de courses." },
"shoppingList": { "title": "Listes de courses", "description": "Plusieurs listes, partageables, remplies automatiquement à partir des recettes ou des plans de repas." },
"social": { "title": "Abonnements, notes & commentaires", "description": "Une couche sociale pour les recettes — suivez d'autres cuisiniers, notez et commentez, avec un contrôle de visibilité précis, y compris réservé aux abonnés." },
"batchCook": { "title": "Cuisine en lots", "description": "Planifiez des sessions de cuisine en lots avec conseils de conservation par plat." }
}
},
"about": {
"title": "À propos d'Epicure",
"paragraphs": [
"Epicure est né d'une idée simple : un carnet de recettes qui suit vraiment votre façon de cuisiner — planification, garde-manger et IA réunis en un seul endroit plutôt qu'éparpillés dans cinq applications différentes.",
"C'est un projet indépendant, conçu et maintenu par une seule personne, qui évolue en fonction de ce qui est réellement utile à ses utilisateurs."
]
},
"contact": {
"title": "Nous contacter",
"subtitle": "Questions, retours, ou un problème rencontré — écrivez-nous, nous vous répondrons."
}
},
"nav": {
"menu": "Menu",
"recipes": "Recettes",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@epicure/web",
"version": "0.47.0",
"version": "0.48.0",
"private": true,
"scripts": {
"dev": "next dev",
+5 -2
View File
@@ -2,7 +2,10 @@ import { NextRequest, NextResponse } from "next/server";
import { getSessionCookie } from "better-auth/cookies";
import { applyRateLimit } from "@/lib/rate-limit";
const PUBLIC_PATHS = ["/login", "/signup", "/verify-email", "/verify-2fa", "/forgot-password", "/reset-password", "/api/auth", "/r/", "/u/", "/s/", "/docs", "/api/v1/openapi.json", "/api/webhooks", "/api/v1/invites/", "/api/internal/"];
const PUBLIC_PATHS = ["/login", "/signup", "/verify-email", "/verify-2fa", "/forgot-password", "/reset-password", "/api/auth", "/r/", "/u/", "/s/", "/docs", "/api/v1/openapi.json", "/api/webhooks", "/api/v1/invites/", "/api/internal/", "/features", "/pricing", "/about", "/privacy", "/terms", "/contact"];
// Exact-match only — "/" can't go in PUBLIC_PATHS's startsWith list, since
// every path starts with "/" and that would make the whole app public.
const PUBLIC_EXACT_PATHS = ["/"];
const ADMIN_PATHS = ["/admin"];
// A public-editable shopping list's own items endpoint — no session cookie to
@@ -44,7 +47,7 @@ export async function proxy(request: NextRequest) {
// Only treat the items endpoint as public for requests with no session —
// an authenticated collaborator's own polling/edits should never be bucketed
// into the anonymous-visitor IP rate limit below.
const isPublic = PUBLIC_PATHS.some((p) => pathname.startsWith(p)) || (isShoppingListItems && !sessionCookie);
const isPublic = PUBLIC_PATHS.some((p) => pathname.startsWith(p)) || PUBLIC_EXACT_PATHS.includes(pathname) || (isShoppingListItems && !sessionCookie);
const isAdmin = ADMIN_PATHS.some((p) => pathname.startsWith(p));
const isApi = pathname.startsWith("/api/v1");
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "epicure",
"version": "0.47.0",
"version": "0.48.0",
"private": true,
"scripts": {
"dev": "pnpm --filter web dev",