From 8dccd8898b0db53e614169b66bd4eefe9e5885fa Mon Sep 17 00:00:00 2001 From: Arnaud Date: Sat, 18 Jul 2026 10:55:06 +0200 Subject: [PATCH] feat: vitrine language/theme switching, missing features, disabled-signups handling Language switcher (flag toggle) and dark-mode toggle in the marketing header, both usable by logged-out visitors -- the marketing pages previously hardcoded English (getMessages(undefined)) regardless of any preference, and had no theme control since the authenticated Nav's toggle isn't rendered there. New cookie-based locale resolution (lib/marketing-locale.ts) separate from the authenticated app's useLocale/setLocale, which persists via an authenticated PATCH that would just 401 and revert for an anonymous visitor. Root layout now falls back to that cookie for when there's no session. Features/Home now cover cook mode, recipe variations, personalized recommendations, and ingredient substitution -- all real, shipped features that were missing from the page copy. Signup CTAs check isSignupsDisabled() and swap to "Signups closed" instead of "Get started free" -- still linking to /signup, since that page already handles the invite-token exception correctly. Fixed along the way: importing the cookie-name constant from marketing-locale.ts in the client-side LanguageSwitcher pulled that file's server-only imports (lib/auth/server -> the DB client -> `postgres`) into the client bundle, breaking the build on Node built-ins. Split the constant into its own client-safe file (marketing-locale-cookie.ts). Caught by `pnpm build`, not typecheck. Verified with typecheck, lint, and a full production build (clean compile) -- no DB in this sandbox to click through a real dev server. v0.48.1 --- CHANGELOG.md | 5 ++ apps/web/app/(marketing)/about/page.tsx | 5 +- apps/web/app/(marketing)/contact/page.tsx | 5 +- apps/web/app/(marketing)/features/page.tsx | 17 ++++-- apps/web/app/(marketing)/layout.tsx | 6 +- apps/web/app/(marketing)/page.tsx | 14 +++-- apps/web/app/layout.tsx | 6 +- .../marketing/language-switcher.tsx | 55 +++++++++++++++++++ .../components/marketing/marketing-nav.tsx | 16 +++++- .../web/components/marketing/theme-toggle.tsx | 23 ++++++++ apps/web/lib/changelog.ts | 9 ++- apps/web/lib/marketing-locale-cookie.ts | 6 ++ apps/web/lib/marketing-locale.ts | 28 ++++++++++ apps/web/messages/en.json | 9 ++- apps/web/messages/fr.json | 9 ++- apps/web/package.json | 2 +- package.json | 2 +- 17 files changed, 191 insertions(+), 26 deletions(-) create mode 100644 apps/web/components/marketing/language-switcher.tsx create mode 100644 apps/web/components/marketing/theme-toggle.tsx create mode 100644 apps/web/lib/marketing-locale-cookie.ts create mode 100644 apps/web/lib/marketing-locale.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 221fd25..d2d8a65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ 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.1 — 2026-07-18 11:00 + +### Added +- Marketing site: language switcher (flag toggle, EN/FR) and a dark-mode toggle in the header — both work for logged-out visitors, no account needed. Features/Home now also cover cook mode, recipe variations, personalized recommendations, and ingredient substitution. Signup CTAs now say "Signups closed" instead of "Get started free" when signups are disabled. + ## 0.48.0 — 2026-07-18 09:45 ### Added diff --git a/apps/web/app/(marketing)/about/page.tsx b/apps/web/app/(marketing)/about/page.tsx index 44c3c48..befe36a 100644 --- a/apps/web/app/(marketing)/about/page.tsx +++ b/apps/web/app/(marketing)/about/page.tsx @@ -1,13 +1,14 @@ import type { Metadata } from "next"; import { getMessages } from "@/lib/i18n/server"; +import { getEffectiveMarketingLocale } from "@/lib/marketing-locale"; export const metadata: Metadata = { title: "About — Epicure", description: "The story behind Epicure.", }; -export default function AboutPage() { - const m = getMessages(undefined); +export default async function AboutPage() { + const m = getMessages(await getEffectiveMarketingLocale()); const t = m.marketing.about; return ( diff --git a/apps/web/app/(marketing)/contact/page.tsx b/apps/web/app/(marketing)/contact/page.tsx index 54d6a81..fcc78ec 100644 --- a/apps/web/app/(marketing)/contact/page.tsx +++ b/apps/web/app/(marketing)/contact/page.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import { Mail } from "lucide-react"; import { getMessages } from "@/lib/i18n/server"; +import { getEffectiveMarketingLocale } from "@/lib/marketing-locale"; export const metadata: Metadata = { title: "Contact — Epicure", @@ -9,8 +10,8 @@ export const metadata: Metadata = { const SUPPORT_EMAIL = "support@epicure.app"; -export default function ContactPage() { - const m = getMessages(undefined); +export default async function ContactPage() { + const m = getMessages(await getEffectiveMarketingLocale()); const t = m.marketing.contact; return ( diff --git a/apps/web/app/(marketing)/features/page.tsx b/apps/web/app/(marketing)/features/page.tsx index 0eda37d..b90427c 100644 --- a/apps/web/app/(marketing)/features/page.tsx +++ b/apps/web/app/(marketing)/features/page.tsx @@ -1,19 +1,25 @@ import type { Metadata } from "next"; import Link from "next/link"; -import { Sparkles, Calendar, Package, MessageCircle, Users, ChefHat, Camera, ListChecks } from "lucide-react"; +import { Sparkles, Calendar, Package, MessageCircle, Users, ChefHat, Camera, ListChecks, Utensils, Wand2, Sparkle, Replace } from "lucide-react"; import { getMessages } from "@/lib/i18n/server"; +import { getEffectiveMarketingLocale } from "@/lib/marketing-locale"; +import { isSignupsDisabled } from "@/lib/site-settings"; 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.", + description: "AI recipe generation, meal planning, pantry tracking, cook mode, 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: Utensils, key: "cookMode" }, + { icon: Wand2, key: "variations" }, + { icon: Sparkle, key: "recommendations" }, + { icon: Replace, key: "substitution" }, { icon: Calendar, key: "mealPlan" }, { icon: Package, key: "pantry" }, { icon: ListChecks, key: "shoppingList" }, @@ -21,8 +27,9 @@ const FEATURES = [ { icon: ChefHat, key: "batchCook" }, ] as const; -export default function FeaturesPage() { - const m = getMessages(undefined); +export default async function FeaturesPage() { + const [locale, signupsDisabled] = await Promise.all([getEffectiveMarketingLocale(), isSignupsDisabled()]); + const m = getMessages(locale); const t = m.marketing.features; return ( @@ -46,7 +53,7 @@ export default function FeaturesPage() {
- {t.cta} + {signupsDisabled ? m.marketing.nav.signupClosed : t.cta}
diff --git a/apps/web/app/(marketing)/layout.tsx b/apps/web/app/(marketing)/layout.tsx index c8e4e66..a724cc2 100644 --- a/apps/web/app/(marketing)/layout.tsx +++ b/apps/web/app/(marketing)/layout.tsx @@ -1,11 +1,9 @@ -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"; +import { getEffectiveMarketingLocale } from "@/lib/marketing-locale"; 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; + const locale = await getEffectiveMarketingLocale(); return (
diff --git a/apps/web/app/(marketing)/page.tsx b/apps/web/app/(marketing)/page.tsx index 5405470..600ae62 100644 --- a/apps/web/app/(marketing)/page.tsx +++ b/apps/web/app/(marketing)/page.tsx @@ -3,15 +3,19 @@ import { headers } from "next/headers"; import Link from "next/link"; import { auth } from "@/lib/auth/server"; import { getMessages } from "@/lib/i18n/server"; +import { getEffectiveMarketingLocale } from "@/lib/marketing-locale"; +import { isSignupsDisabled } from "@/lib/site-settings"; import { buttonVariants } from "@/components/ui/button"; import { cn } from "@/lib/utils"; -import { Sparkles, Calendar, Package, MessageCircle, Users, ChefHat } from "lucide-react"; +import { Sparkles, Calendar, Package, MessageCircle, Users, ChefHat, Utensils, Wand2 } from "lucide-react"; const HIGHLIGHTS = [ { icon: Sparkles, key: "ai" }, { icon: MessageCircle, key: "assistant" }, { icon: Calendar, key: "mealPlan" }, { icon: Package, key: "pantry" }, + { icon: Utensils, key: "cookMode" }, + { icon: Wand2, key: "variations" }, { icon: Users, key: "social" }, { icon: ChefHat, key: "batchCook" }, ] as const; @@ -20,8 +24,10 @@ export default async function MarketingHomePage() { const session = await auth.api.getSession({ headers: await headers() }); if (session) redirect("/recipes"); - const m = getMessages(undefined); + const [locale, signupsDisabled] = await Promise.all([getEffectiveMarketingLocale(), isSignupsDisabled()]); + const m = getMessages(locale); const t = m.marketing.home; + const ctaLabel = signupsDisabled ? m.marketing.nav.signupClosed : t.ctaPrimary; return (
@@ -34,7 +40,7 @@ export default async function MarketingHomePage() {

- {t.ctaPrimary} + {ctaLabel} {t.ctaSecondary} @@ -60,7 +66,7 @@ export default async function MarketingHomePage() {

{t.bottomCtaTitle}

{t.bottomCtaSubtitle}

- {t.ctaPrimary} + {ctaLabel}
diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 20d745c..f8147ab 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -5,6 +5,7 @@ import { Providers } from "@/components/providers"; import { auth } from "@/lib/auth/server"; import type { Locale } from "@/lib/i18n/provider"; import { SwRegister } from "@/components/pwa/sw-register"; +import { getMarketingLocale } from "@/lib/marketing-locale"; import "./globals.css"; const lora = Lora({ @@ -32,7 +33,10 @@ export default async function RootLayout({ children, }: Readonly<{ children: React.ReactNode }>) { const session = await auth.api.getSession({ headers: await headers() }).catch(() => null); - const initialLocale = ((session?.user as { locale?: string })?.locale ?? "en") as Locale; + // Signed-in users: their saved preference. Anonymous visitors (marketing + // pages): the marketing-locale cookie instead, since there's no account to + // read a preference from. + const initialLocale = ((session?.user as { locale?: string })?.locale ?? await getMarketingLocale()) as Locale; return ( + {OPTIONS.map(({ code, flag, label }) => ( + + ))} +
+ ); +} diff --git a/apps/web/components/marketing/marketing-nav.tsx b/apps/web/components/marketing/marketing-nav.tsx index 13ed9c6..f84d554 100644 --- a/apps/web/components/marketing/marketing-nav.tsx +++ b/apps/web/components/marketing/marketing-nav.tsx @@ -5,6 +5,10 @@ import { auth } from "@/lib/auth/server"; import { buttonVariants } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import { getMessages } from "@/lib/i18n/server"; +import { getEffectiveMarketingLocale } from "@/lib/marketing-locale"; +import { isSignupsDisabled } from "@/lib/site-settings"; +import { LanguageSwitcher } from "./language-switcher"; +import { ThemeToggle } from "./theme-toggle"; const LINKS = [ { href: "/features", key: "features" }, @@ -12,8 +16,12 @@ const LINKS = [ ] 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); + const [session, locale, signupsDisabled] = await Promise.all([ + auth.api.getSession({ headers: await headers() }).catch(() => null), + getEffectiveMarketingLocale(), + isSignupsDisabled(), + ]); + const m = getMessages(locale); return (
@@ -30,6 +38,8 @@ export async function MarketingNav() { ))}
+ + {session ? ( {m.marketing.nav.openApp} @@ -40,7 +50,7 @@ export async function MarketingNav() { {m.marketing.nav.login} - {m.marketing.nav.signup} + {signupsDisabled ? m.marketing.nav.signupClosed : m.marketing.nav.signup} )} diff --git a/apps/web/components/marketing/theme-toggle.tsx b/apps/web/components/marketing/theme-toggle.tsx new file mode 100644 index 0000000..b97de73 --- /dev/null +++ b/apps/web/components/marketing/theme-toggle.tsx @@ -0,0 +1,23 @@ +"use client"; + +import { useTheme } from "next-themes"; +import { Sun, Moon } from "lucide-react"; + +/** Simple light/dark toggle for the marketing header — the authenticated + * app's Nav has a fuller light/dark/system control buried in a dropdown; + * this is a one-click version for a page a visitor hasn't logged into yet. */ +export function ThemeToggle() { + const { resolvedTheme, setTheme } = useTheme(); + const isDark = resolvedTheme === "dark"; + + return ( + + ); +} diff --git a/apps/web/lib/changelog.ts b/apps/web/lib/changelog.ts index 44ee65d..16bacc4 100644 --- a/apps/web/lib/changelog.ts +++ b/apps/web/lib/changelog.ts @@ -1,5 +1,5 @@ // Mirrors CHANGELOG.md at the repo root — update both together. -export const APP_VERSION = "0.48.0"; +export const APP_VERSION = "0.48.1"; export type ChangelogEntry = { version: string; @@ -11,6 +11,13 @@ export type ChangelogEntry = { }; export const CHANGELOG: ChangelogEntry[] = [ + { + version: "0.48.1", + date: "2026-07-18 11:00", + added: [ + "Marketing site: language switcher (flag toggle, EN/FR) and a dark-mode toggle in the header — both work for logged-out visitors, no account needed. Features/Home now also cover cook mode, recipe variations, personalized recommendations, and ingredient substitution. Signup CTAs now say \"Signups closed\" instead of \"Get started free\" when signups are disabled.", + ], + }, { version: "0.48.0", date: "2026-07-18 09:45", diff --git a/apps/web/lib/marketing-locale-cookie.ts b/apps/web/lib/marketing-locale-cookie.ts new file mode 100644 index 0000000..644c737 --- /dev/null +++ b/apps/web/lib/marketing-locale-cookie.ts @@ -0,0 +1,6 @@ +// Just the cookie name — split from marketing-locale.ts so client +// components can import it without pulling in that file's server-only +// imports (next/headers, lib/auth/server → the DB client → `postgres`, +// which Next.js then tries to bundle for the browser and fails on Node +// built-ins like `tls`). +export const MARKETING_LOCALE_COOKIE = "marketing_locale"; diff --git a/apps/web/lib/marketing-locale.ts b/apps/web/lib/marketing-locale.ts new file mode 100644 index 0000000..cda4ab7 --- /dev/null +++ b/apps/web/lib/marketing-locale.ts @@ -0,0 +1,28 @@ +import { headers, cookies } from "next/headers"; +import { auth } from "@/lib/auth/server"; +import type { Locale } from "@/lib/i18n/provider"; +import { MARKETING_LOCALE_COOKIE } from "@/lib/marketing-locale-cookie"; + +export { MARKETING_LOCALE_COOKIE }; + +/** + * Locale for the public marketing pages — cookie-based, not tied to a user + * account (visitors there have no session). Separate from the authenticated + * app's locale system (`lib/i18n/provider.tsx`'s `useLocale`/`setLocale`), + * which persists to `users.locale` via an authenticated PATCH that would + * just 401 for an anonymous visitor and revert the UI. + */ +export async function getMarketingLocale(): Promise { + const store = await cookies(); + const value = store.get(MARKETING_LOCALE_COOKIE)?.value; + return value === "fr" ? "fr" : "en"; +} + +/** A logged-in visitor's own saved locale wins (matches what they see + * everywhere else in the app); otherwise the marketing cookie. */ +export async function getEffectiveMarketingLocale(): Promise { + const session = await auth.api.getSession({ headers: await headers() }).catch(() => null); + const sessionLocale = (session?.user as { locale?: string } | undefined)?.locale; + if (sessionLocale === "en" || sessionLocale === "fr") return sessionLocale; + return getMarketingLocale(); +} diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 153ce9f..fae612e 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -5,7 +5,8 @@ "about": "About", "openApp": "Open app", "login": "Log in", - "signup": "Sign up" + "signup": "Sign up", + "signupClosed": "Signups closed" }, "footer": { "about": "About", @@ -26,6 +27,8 @@ "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." }, + "cookMode": { "title": "Hands-free cook mode", "description": "A distraction-free, step-by-step view for actually cooking — large text, timers, no scrolling with messy hands." }, + "variations": { "title": "Recipe variations", "description": "Ask for a dietary twist, an ingredient swap, or a whole new spin on a recipe — AI proposes a variation, you decide whether to keep it." }, "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." } } @@ -39,6 +42,10 @@ "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." }, + "cookMode": { "title": "Hands-free cook mode", "description": "A distraction-free, step-by-step cooking view — large text, built-in timers, made for a kitchen counter, not a desk." }, + "variations": { "title": "Recipe variations", "description": "Adapt a recipe for a diet, an excluded ingredient, or a creative twist — AI proposes changes, you review and confirm before anything is saved." }, + "recommendations": { "title": "Personalized recommendations", "description": "A For You feed ranked from what you've favorited and rated, plus meal/drink pairing suggestions for any recipe." }, + "substitution": { "title": "Ingredient substitution", "description": "Out of something, or avoiding it? Get AI-suggested swaps for any ingredient, right from the recipe." }, "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." }, diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json index caccaf4..a58f40a 100644 --- a/apps/web/messages/fr.json +++ b/apps/web/messages/fr.json @@ -5,7 +5,8 @@ "about": "À propos", "openApp": "Ouvrir l'application", "login": "Connexion", - "signup": "Inscription" + "signup": "Inscription", + "signupClosed": "Inscriptions fermées" }, "footer": { "about": "À propos", @@ -26,6 +27,8 @@ "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." }, + "cookMode": { "title": "Mode cuisine mains libres", "description": "Une vue étape par étape sans distraction pour cuisiner — grand texte, minuteurs intégrés, pas besoin de scroller les mains sales." }, + "variations": { "title": "Variations de recette", "description": "Demandez une variante diététique, un remplacement d'ingrédient, ou une toute nouvelle interprétation — l'IA propose une variation, vous décidez de la garder." }, "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." } } @@ -39,6 +42,10 @@ "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." }, + "cookMode": { "title": "Mode cuisine mains libres", "description": "Une vue de cuisine étape par étape sans distraction — grand texte, minuteurs intégrés, pensée pour le plan de travail, pas un bureau." }, + "variations": { "title": "Variations de recette", "description": "Adaptez une recette pour un régime, un ingrédient exclu, ou une touche créative — l'IA propose des changements, vous validez avant tout enregistrement." }, + "recommendations": { "title": "Recommandations personnalisées", "description": "Un fil « Pour vous » classé selon vos favoris et vos notes, plus des suggestions d'accords repas/boisson pour chaque recette." }, + "substitution": { "title": "Substitution d'ingrédients", "description": "Il vous manque un ingrédient, ou vous l'évitez ? Obtenez des remplacements suggérés par l'IA, directement depuis la recette." }, "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." }, diff --git a/apps/web/package.json b/apps/web/package.json index 4e11ffb..8589260 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@epicure/web", - "version": "0.48.0", + "version": "0.48.1", "private": true, "scripts": { "dev": "next dev", diff --git a/package.json b/package.json index a7379a1..c83edbc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "epicure", - "version": "0.48.0", + "version": "0.48.1", "private": true, "scripts": { "dev": "pnpm --filter web dev",