From df250c40e8be304eb6015f59882362b02bc483b0 Mon Sep 17 00:00:00 2001 From: AyrisAI Date: Sat, 29 Aug 2026 01:55:26 +0300 Subject: [PATCH] feat(web): public menu language pill selector; fix missing cover_url column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Public menu page now shows a TR|EN|... pill selector (styled after the Menulio design reference) that switches category/item text to the AI translations added via the mobile app, falling back to Turkish for anything not yet translated. Also adds a migration for restaurants.cover_url, which was referenced by existing code but was missing from the live database — the public menu page was silently forcing cover photos to null on every restaurant. Co-Authored-By: Claude Sonnet 5 --- apps/web/src/app/menu/[slug]/page.tsx | 17 ++- apps/web/src/components/PublicMenuClient.tsx | 101 +++++++++++++++--- .../20260829010000_restaurant_cover_url.sql | 2 + 3 files changed, 102 insertions(+), 18 deletions(-) create mode 100644 supabase/migrations/20260829010000_restaurant_cover_url.sql diff --git a/apps/web/src/app/menu/[slug]/page.tsx b/apps/web/src/app/menu/[slug]/page.tsx index 3f5024a..2addace 100644 --- a/apps/web/src/app/menu/[slug]/page.tsx +++ b/apps/web/src/app/menu/[slug]/page.tsx @@ -17,6 +17,7 @@ interface MenuItemRow { price: number; image_url: string | null; is_active: boolean; + translations: Record | null; } interface MenuCategoryRow { @@ -25,6 +26,7 @@ interface MenuCategoryRow { description: string | null; is_active: boolean; menu_items: MenuItemRow[]; + translations: Record | null; } export async function generateMetadata({ params }: PageProps): Promise { @@ -71,14 +73,14 @@ export async function generateMetadata({ params }: PageProps): Promise async function fetchRestaurantBySlug(slug: string) { let { data: bySlug, error } = await supabase .from("restaurants") - .select("id, name, slug, logo_url, cover_url, phone, address") + .select("id, name, slug, logo_url, cover_url, phone, address, enabled_languages") .eq("slug", slug) .maybeSingle(); if (error && error.code === "42703") { const { data: fallback } = await supabase .from("restaurants") - .select("id, name, slug, logo_url, phone, address") + .select("id, name, slug, logo_url, phone, address, enabled_languages") .eq("slug", slug) .maybeSingle(); if (fallback) { @@ -90,14 +92,14 @@ async function fetchRestaurantBySlug(slug: string) { let { data: domainRow, error: domErr } = await supabase .from("domains") - .select("restaurant_id, restaurants(id, name, slug, logo_url, cover_url, phone, address)") + .select("restaurant_id, restaurants(id, name, slug, logo_url, cover_url, phone, address, enabled_languages)") .eq("hostname", slug) .maybeSingle(); if (domErr && domErr.code === "42703") { const { data: fallbackDom } = await supabase .from("domains") - .select("restaurant_id, restaurants(id, name, slug, logo_url, phone, address)") + .select("restaurant_id, restaurants(id, name, slug, logo_url, phone, address, enabled_languages)") .eq("hostname", slug) .maybeSingle(); if (fallbackDom?.restaurants) { @@ -150,7 +152,9 @@ export default async function PublicMenuPage({ params, searchParams }: PageProps if (menu) { const { data: rawCategories } = await supabase .from("menu_categories") - .select("id, name, description, is_active, menu_items(id, name, description, price, image_url, is_active)") + .select( + "id, name, description, is_active, translations, menu_items(id, name, description, price, image_url, is_active, translations)", + ) .eq("menu_id", menu.id) .eq("is_active", true) .order("sort_order", { ascending: true }) @@ -161,6 +165,7 @@ export default async function PublicMenuPage({ params, searchParams }: PageProps name: cat.name, description: cat.description, is_active: cat.is_active, + translations: cat.translations ?? {}, menu_items: (cat.menu_items ?? []) .filter((item) => item.is_active) .map((item): MenuItem => ({ @@ -170,6 +175,7 @@ export default async function PublicMenuPage({ params, searchParams }: PageProps price: item.price, image_url: item.image_url, is_active: item.is_active, + translations: item.translations ?? {}, })), })); } else if (slug === "demo" || slug === "gusto-brasserie") { @@ -202,6 +208,7 @@ export default async function PublicMenuPage({ params, searchParams }: PageProps categories={categories} initialThemeKey={themeKey} customThemeConfig={customConfig} + enabledLanguages={restaurant.enabled_languages ?? ["tr"]} /> ); } diff --git a/apps/web/src/components/PublicMenuClient.tsx b/apps/web/src/components/PublicMenuClient.tsx index 7c10b10..e40650a 100644 --- a/apps/web/src/components/PublicMenuClient.tsx +++ b/apps/web/src/components/PublicMenuClient.tsx @@ -12,6 +12,7 @@ export interface MenuItem { price: number; image_url: string | null; is_active: boolean; + translations?: Record | null; } export interface MenuCategory { @@ -20,8 +21,18 @@ export interface MenuCategory { description: string | null; is_active: boolean; menu_items: MenuItem[]; + translations?: Record | null; } +const LANGUAGE_LABELS: Record = { + tr: "TR", + en: "EN", + de: "DE", + ar: "AR", + ru: "RU", + fr: "FR", +}; + export interface RestaurantData { id: string; name: string; @@ -38,6 +49,7 @@ interface PublicMenuClientProps { initialThemeKey?: string; customThemeConfig?: Partial; allowThemeSwitching?: boolean; + enabledLanguages?: string[]; } export function PublicMenuClient({ @@ -46,8 +58,10 @@ export function PublicMenuClient({ initialThemeKey = "elegant", customThemeConfig, allowThemeSwitching = false, + enabledLanguages = ["tr"], }: PublicMenuClientProps) { const [selectedThemeKey, setSelectedThemeKey] = useState(initialThemeKey); + const [selectedLanguage, setSelectedLanguage] = useState("tr"); const [searchQuery, setSearchQuery] = useState(""); const [activeCategoryId, setActiveCategoryId] = useState(categories[0]?.id ?? ""); const [selectedItem, setSelectedItem] = useState(null); @@ -95,12 +109,27 @@ export function PublicMenuClient({ return () => window.removeEventListener("scroll", handleScroll); }, [categories]); + // Apply AI-translated text for the selected language, falling back to the + // original Turkish content when a translation isn't available yet. + const localizedCategories = useMemo(() => { + if (selectedLanguage === "tr") return categories; + return categories.map((cat) => ({ + ...cat, + name: cat.translations?.[selectedLanguage]?.name || cat.name, + menu_items: cat.menu_items.map((item) => ({ + ...item, + name: item.translations?.[selectedLanguage]?.name || item.name, + description: item.translations?.[selectedLanguage]?.description ?? item.description, + })), + })); + }, [categories, selectedLanguage]); + // Filter categories and items based on search const filteredCategories = useMemo(() => { const q = searchQuery.trim().toLowerCase(); - if (!q) return categories; + if (!q) return localizedCategories; - return categories + return localizedCategories .map((cat) => ({ ...cat, menu_items: cat.menu_items.filter( @@ -110,7 +139,7 @@ export function PublicMenuClient({ ), })) .filter((cat) => cat.menu_items.length > 0); - }, [categories, searchQuery]); + }, [localizedCategories, searchQuery]); const totalItemsCount = useMemo(() => { return categories.reduce((acc, cat) => acc + cat.menu_items.filter((i) => i.is_active).length, 0); @@ -213,12 +242,33 @@ export function PublicMenuClient({ )}
+ {/* Language Selector Pills */} + {enabledLanguages.length > 1 && ( +
+ {enabledLanguages.map((lang) => { + const isActive = selectedLanguage === lang; + return ( + + ); + })} +
+ )} + {/* Restaurant Info Trigger */} {(restaurant.phone || restaurant.address) && (