feat(web): public menu language pill selector; fix missing cover_url column

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 <noreply@anthropic.com>
This commit is contained in:
AyrisAI
2026-08-29 01:55:26 +03:00
co-authored by Claude Sonnet 5
parent 6ec4631e82
commit df250c40e8
3 changed files with 102 additions and 18 deletions
+12 -5
View File
@@ -17,6 +17,7 @@ interface MenuItemRow {
price: number;
image_url: string | null;
is_active: boolean;
translations: Record<string, { name: string; description: string | null }> | null;
}
interface MenuCategoryRow {
@@ -25,6 +26,7 @@ interface MenuCategoryRow {
description: string | null;
is_active: boolean;
menu_items: MenuItemRow[];
translations: Record<string, { name: string }> | null;
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
@@ -71,14 +73,14 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
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"]}
/>
);
}
+88 -13
View File
@@ -12,6 +12,7 @@ export interface MenuItem {
price: number;
image_url: string | null;
is_active: boolean;
translations?: Record<string, { name: string; description: string | null }> | null;
}
export interface MenuCategory {
@@ -20,8 +21,18 @@ export interface MenuCategory {
description: string | null;
is_active: boolean;
menu_items: MenuItem[];
translations?: Record<string, { name: string }> | null;
}
const LANGUAGE_LABELS: Record<string, string> = {
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<ThemeConfig>;
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<string>(initialThemeKey);
const [selectedLanguage, setSelectedLanguage] = useState<string>("tr");
const [searchQuery, setSearchQuery] = useState("");
const [activeCategoryId, setActiveCategoryId] = useState<string>(categories[0]?.id ?? "");
const [selectedItem, setSelectedItem] = useState<MenuItem | null>(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({
)}
<div className="flex items-center gap-1.5 sm:gap-2">
{/* Language Selector Pills */}
{enabledLanguages.length > 1 && (
<div className="flex items-center gap-1 px-1 py-1 rounded-full bg-black/30 backdrop-blur-md border border-white/15">
{enabledLanguages.map((lang) => {
const isActive = selectedLanguage === lang;
return (
<button
key={lang}
type="button"
onClick={() => setSelectedLanguage(lang)}
className={`px-2 sm:px-2.5 py-1 rounded-full text-[10px] sm:text-[11px] font-bold tracking-wide transition-all ${
isActive ? "bg-white text-stone-900 shadow-sm" : "text-white/80 hover:text-white"
}`}
>
{LANGUAGE_LABELS[lang] ?? lang.toUpperCase()}
</button>
);
})}
</div>
)}
{/* Restaurant Info Trigger */}
{(restaurant.phone || restaurant.address) && (
<button
type="button"
onClick={() => setShowInfoModal(true)}
className="w-8 h-8 sm:w-9 sm:h-9 rounded-full bg-white/10 hover:bg-white/20 active:scale-95 transition-all flex items-center justify-center border border-white/15 backdrop-blur-md"
className="w-8 h-8 sm:w-9 sm:h-9 rounded-full bg-white/10 hover:bg-white/20 active:scale-95 transition-all flex items-center justify-center border border-white/15 backdrop-blur-md cursor-pointer focus-visible:ring-2 focus-visible:ring-amber-500 focus-visible:outline-none"
aria-label="Restoran Bilgisi"
>
<svg className="w-3.5 h-3.5 sm:w-4 sm:h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -231,7 +281,7 @@ export function PublicMenuClient({
<button
type="button"
onClick={handleShare}
className="w-8 h-8 sm:w-9 sm:h-9 rounded-full bg-white/10 hover:bg-white/20 active:scale-95 transition-all flex items-center justify-center border border-white/15 backdrop-blur-md"
className="w-8 h-8 sm:w-9 sm:h-9 rounded-full bg-white/10 hover:bg-white/20 active:scale-95 transition-all flex items-center justify-center border border-white/15 backdrop-blur-md cursor-pointer focus-visible:ring-2 focus-visible:ring-amber-500 focus-visible:outline-none"
aria-label="Menüyü Paylaş"
>
{copiedLink ? (
@@ -330,8 +380,9 @@ export function PublicMenuClient({
type="text"
placeholder="Yemek, içecek veya tatlı ara..."
value={searchQuery}
aria-label="Menüde arama yap"
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-11 pr-10 py-3 rounded-2xl text-stone-900 bg-white/95 placeholder-stone-400 text-sm focus:outline-none focus:ring-2 transition-all shadow-lg"
className="w-full pl-11 pr-10 py-3 rounded-2xl text-stone-900 bg-white/95 placeholder-stone-400 text-sm focus:outline-none focus:ring-2 focus-visible:ring-amber-500 transition-all shadow-lg cursor-pointer"
style={{
outlineColor: theme.config.primaryColor,
}}
@@ -410,7 +461,7 @@ export function PublicMenuClient({
}}
>
<div className="flex items-center gap-2 overflow-x-auto no-scrollbar scroll-smooth py-0.5">
{categories.map((cat) => {
{localizedCategories.map((cat) => {
const isActive = activeCategoryId === cat.id;
return (
<button
@@ -608,6 +659,9 @@ export function PublicMenuClient({
<img
src={item.image_url}
alt={item.name}
onError={(e) => {
e.currentTarget.src = "https://images.unsplash.com/photo-1546069901-ba9599a7e63c?w=800&auto=format&fit=crop&q=80";
}}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
loading="lazy"
/>
@@ -687,6 +741,9 @@ export function PublicMenuClient({
<img
src={item.image_url}
alt={item.name}
onError={(e) => {
e.currentTarget.src = "https://images.unsplash.com/photo-1546069901-ba9599a7e63c?w=800&auto=format&fit=crop&q=80";
}}
className="w-full h-full object-cover rounded-2xl shadow-md group-hover:scale-105 transition-transform duration-300"
loading="lazy"
/>
@@ -715,6 +772,9 @@ export function PublicMenuClient({
<img
src={item.image_url}
alt={item.name}
onError={(e) => {
e.currentTarget.src = "https://images.unsplash.com/photo-1546069901-ba9599a7e63c?w=800&auto=format&fit=crop&q=80";
}}
className="w-full h-full object-cover grayscale-[0.2] group-hover:grayscale-0 group-hover:scale-105 transition-all duration-500"
loading="lazy"
/>
@@ -794,6 +854,9 @@ export function PublicMenuClient({
<img
src={item.image_url}
alt={item.name}
onError={(e) => {
e.currentTarget.src = "https://images.unsplash.com/photo-1546069901-ba9599a7e63c?w=800&auto=format&fit=crop&q=80";
}}
className="w-full h-full object-cover grayscale contrast-125 group-hover:scale-105 group-hover:grayscale-0 transition-all duration-300"
loading="lazy"
/>
@@ -820,6 +883,9 @@ export function PublicMenuClient({
<img
src={item.image_url}
alt={item.name}
onError={(e) => {
e.currentTarget.src = "https://images.unsplash.com/photo-1546069901-ba9599a7e63c?w=800&auto=format&fit=crop&q=80";
}}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
loading="lazy"
/>
@@ -909,6 +975,9 @@ export function PublicMenuClient({
<img
src={item.image_url}
alt={item.name}
onError={(e) => {
e.currentTarget.src = "https://images.unsplash.com/photo-1546069901-ba9599a7e63c?w=800&auto=format&fit=crop&q=80";
}}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
loading="lazy"
/>
@@ -926,23 +995,26 @@ export function PublicMenuClient({
{/* Footer */}
<footer
className="px-6 py-10 text-center border-t mt-auto text-xs space-y-4"
className="px-6 py-8 text-center border-t mt-auto text-xs space-y-3"
style={{
borderColor: theme.cardBorder,
color: theme.textSecondary,
}}
>
<div className="flex items-center justify-center gap-2 font-semibold">
<span>Powered by</span>
<span className="text-stone-900 dark:text-white font-black tracking-wider uppercase">
<div className="flex items-center justify-center gap-2 font-bold tracking-wide">
<span className="opacity-80">Powered by</span>
<span
className="font-black tracking-widest uppercase px-2 py-0.5 rounded text-[11px]"
style={{ backgroundColor: `${theme.config.primaryColor}20`, color: theme.config.primaryColor }}
>
MENULIO
</span>
</div>
<p className="text-[11px] opacity-70">
<p className="text-[11px] opacity-75 max-w-sm mx-auto">
© {new Date().getFullYear()} {restaurant.name}. Fiyatlara tüm vergiler dahildir.
</p>
<AyrisFooterBadge className="mt-4 pt-4 border-t border-black/10 dark:border-white/10" />
<AyrisFooterBadge className="pt-3 border-t border-black/5 dark:border-white/10" />
</footer>
{/* Floating Back to Top Button */}
@@ -973,6 +1045,9 @@ export function PublicMenuClient({
<img
src={selectedItem.image_url}
alt={selectedItem.name}
onError={(e) => {
e.currentTarget.src = "https://images.unsplash.com/photo-1546069901-ba9599a7e63c?w=800&auto=format&fit=crop&q=80";
}}
className="w-full h-full object-cover"
/>
<button
@@ -0,0 +1,2 @@
alter table restaurants
add column if not exists cover_url text;