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:
co-authored by
Claude Sonnet 5
parent
6ec4631e82
commit
df250c40e8
@@ -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"]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user