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:
@@ -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.
|
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
|
## 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).
|
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).
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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'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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { redirect } from "next/navigation";
|
|
||||||
|
|
||||||
export default function RootPage() {
|
|
||||||
redirect("/recipes");
|
|
||||||
}
|
|
||||||
@@ -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`,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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.47.0";
|
export const APP_VERSION = "0.48.0";
|
||||||
|
|
||||||
export type ChangelogEntry = {
|
export type ChangelogEntry = {
|
||||||
version: string;
|
version: string;
|
||||||
@@ -11,6 +11,14 @@ export type ChangelogEntry = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const CHANGELOG: 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",
|
version: "0.47.0",
|
||||||
date: "2026-07-18 00:30",
|
date: "2026-07-18 00:30",
|
||||||
|
|||||||
@@ -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": {
|
"nav": {
|
||||||
"menu": "Menu",
|
"menu": "Menu",
|
||||||
"recipes": "Recipes",
|
"recipes": "Recipes",
|
||||||
|
|||||||
@@ -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": {
|
"nav": {
|
||||||
"menu": "Menu",
|
"menu": "Menu",
|
||||||
"recipes": "Recettes",
|
"recipes": "Recettes",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@epicure/web",
|
"name": "@epicure/web",
|
||||||
"version": "0.47.0",
|
"version": "0.48.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|||||||
+5
-2
@@ -2,7 +2,10 @@ import { NextRequest, NextResponse } from "next/server";
|
|||||||
import { getSessionCookie } from "better-auth/cookies";
|
import { getSessionCookie } from "better-auth/cookies";
|
||||||
import { applyRateLimit } from "@/lib/rate-limit";
|
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"];
|
const ADMIN_PATHS = ["/admin"];
|
||||||
|
|
||||||
// A public-editable shopping list's own items endpoint — no session cookie to
|
// 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 —
|
// Only treat the items endpoint as public for requests with no session —
|
||||||
// an authenticated collaborator's own polling/edits should never be bucketed
|
// an authenticated collaborator's own polling/edits should never be bucketed
|
||||||
// into the anonymous-visitor IP rate limit below.
|
// 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 isAdmin = ADMIN_PATHS.some((p) => pathname.startsWith(p));
|
||||||
const isApi = pathname.startsWith("/api/v1");
|
const isApi = pathname.startsWith("/api/v1");
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "epicure",
|
"name": "epicure",
|
||||||
"version": "0.47.0",
|
"version": "0.48.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "pnpm --filter web dev",
|
"dev": "pnpm --filter web dev",
|
||||||
|
|||||||
Reference in New Issue
Block a user