// Complete, improved, and type-safe MainMenu with Firebase integration & premium UI // ✅ FIXED: Removed desktop view toggle buttons, kept mobile-only view toggling // ✅ i18n: All UI strings localized (nl, en, de, fr) import React, { useState, useEffect, useMemo, useCallback } from "react"; import { Container, Box, Typography, Stack, Chip, Paper, useTheme, alpha, useMediaQuery, Button, Alert, CircularProgress, Backdrop, Snackbar, Fab, Skeleton, } from "@mui/material"; import { KeyboardArrowUpRounded as ScrollTopIcon, SortRounded as SortIcon, StarRounded as StarIcon, LocationOnRounded as LocationIcon, SetMealRounded as SushiIcon, Co2Rounded as FreshIcon, WorkspacePremiumRounded as PremiumIcon, DiamondRounded as DiamondIcon, StorefrontRounded as SupermarketIcon, PhoneRounded as PhoneIcon, WavesRounded as CoastalIcon, SailingRounded as SailingIcon, RefreshRounded as RefreshIcon, CloudOffRounded as OfflineIcon, ErrorOutlineRounded as ErrorIcon, ShoppingCartRounded as CartIcon, TrendingUpRounded as TrendingIcon, CelebrationRounded as CelebrationIcon, } from "@mui/icons-material"; import { AnimatePresence, motion, LayoutGroup, type Variants, } from "framer-motion"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import Header from "../common/Header"; import SearchBar from "./SearchBar"; import CategoryFilter from "./CategoryFilter"; import ProductCard from "./ProductCard"; import type { Product } from "./ProductCard"; import { useCart, type CartItem } from "../Cart/CartContext"; // Firebase services import { ProductService, CategoryService, AnalyticsService, FirebaseErrorHandler, OfflineService, type AppCategory, } from "../../firebase/services"; /* ============================================================================ Animations ==========================================================================*/ const containerVariants: Variants = { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { staggerChildren: 0.02, delayChildren: 0.05, ease: [0.4, 0, 0.2, 1], }, }, }; const itemVariants: Variants = { hidden: { opacity: 0, y: 8, scale: 0.98 }, visible: { opacity: 1, y: 0, scale: 1, transition: { type: "spring", stiffness: 150, damping: 20, mass: 0.8 }, }, exit: { opacity: 0, y: -4, scale: 0.98, transition: { duration: 0.15, ease: [0.4, 0, 0.2, 1] }, }, }; const heroVariants: Variants = { hidden: { opacity: 0, y: 30 }, visible: { opacity: 1, y: 0, transition: { duration: 0.8, ease: [0.4, 0, 0.2, 1] }, }, }; const cartHelperVariants: Variants = { hidden: { opacity: 0, scale: 0.8, y: -20, filter: "blur(10px)" }, visible: { opacity: 1, scale: 1, y: 0, filter: "blur(0px)", transition: { type: "spring", stiffness: 300, damping: 25, duration: 0.6 }, }, exit: { opacity: 0, scale: 0.9, y: -10, filter: "blur(5px)", transition: { duration: 0.4, ease: [0.4, 0, 0.2, 1] }, }, }; const MotionBox = motion(Box); const MotionSnackbar = motion(Snackbar); /* ============================================================================ Opening Hours Utils (Europe/Amsterdam, structured result for i18n rendering) ==========================================================================*/ type OpeningInterval = { start: `${number}:${number}`; end: `${number}:${number}`; }; type WeeklyOpeningHours = Record<0 | 1 | 2 | 3 | 4 | 5 | 6, OpeningInterval[]>; const AMSTERDAM_TZ = "Europe/Amsterdam"; /** Haftalık saatler — özelleştirilebilir */ const OPENING_HOURS: WeeklyOpeningHours = { 0: [{ start: "08:00", end: "18:00" }], // Sun 1: [{ start: "08:00", end: "18:00" }], // Mon 2: [{ start: "08:00", end: "18:00" }], 3: [{ start: "08:00", end: "18:00" }], 4: [{ start: "08:00", end: "18:00" }], 5: [{ start: "08:00", end: "18:00" }], 6: [{ start: "08:00", end: "18:00" }], // Sat }; const toMinutes = (hhmm: string) => { const [h, m] = hhmm.split(":").map(Number); return (h || 0) * 60 + (m || 0); }; const pad2 = (n: number) => n.toString().padStart(2, "0"); const formatHHMM = (mins: number) => { const h = Math.floor(mins / 60); const m = mins % 60; return `${pad2(h)}:${pad2(m)}`; }; function getAmsterdamNowParts(d = new Date()) { const parts = new Intl.DateTimeFormat("en-GB", { timeZone: AMSTERDAM_TZ, hour: "2-digit", minute: "2-digit", hourCycle: "h23", weekday: "short", timeZoneName: "short", }).formatToParts(d); const get = (type: string) => parts.find((p) => p.type === type)?.value || ""; const weekdayMap: Record = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6, }; const hour = parseInt(get("hour"), 10); const minute = parseInt(get("minute"), 10); const weekday = weekdayMap[get("weekday")] ?? 0; const tzShort = get("timeZoneName") || ""; return { hour, minute, weekday, tzShort }; } function isWithin(nowM: number, startM: number, endM: number) { if (startM === endM) return false; if (endM < startM) { // Overnight interval (e.g., 22:00–02:00) return nowM >= startM || nowM < endM; } return nowM >= startM && nowM < endM; } function findNextOpening( nowM: number, weekday: 0 | 1 | 2 | 3 | 4 | 5 | 6, hours: WeeklyOpeningHours ) { for ( let offset = 0 as 0 | 1 | 2 | 3 | 4 | 5 | 6; offset < 7; offset = ((offset + 1) % 7) as any ) { const day = ((weekday + offset) % 7) as 0 | 1 | 2 | 3 | 4 | 5 | 6; const intervals = hours[day] || []; for (const it of intervals) { const s = toMinutes(it.start); const e = toMinutes(it.end); // If today: only consider future starts if (offset === 0) { if ((e < s && nowM < s) || (e >= s && nowM < s)) { return { dayOffset: offset, startM: s }; } } else { return { dayOffset: offset, startM: s }; } } } return null; } function findCurrentIntervalEnd(nowM: number, todays: OpeningInterval[]) { for (const it of todays) { const s = toMinutes(it.start); const e = toMinutes(it.end); if (isWithin(nowM, s, e)) { const endDayOffset = e < s ? 1 : 0; return { endM: e, endDayOffset }; } } return null; } function computeOpenStatus(hours: WeeklyOpeningHours) { const { hour, minute, weekday, tzShort } = getAmsterdamNowParts(); const nowM = hour * 60 + minute; const todays = hours[weekday] || []; const open = todays.some((it) => isWithin(nowM, toMinutes(it.start), toMinutes(it.end)) ); const currentClose = open ? findCurrentIntervalEnd(nowM, todays) : null; const nextOpen = open ? null : findNextOpening(nowM, weekday, hours); return { isOpen: open, nowM, weekday, tzShort, todays, currentClose, // { endM, endDayOffset } | null nextOpen, // { dayOffset, startM } | null }; } function minutesUntil(fromM: number, toM: number, dayOffset: number) { const minutesInDay = 24 * 60; if (dayOffset === 0) return Math.max(0, toM - fromM); // wrap to next days return Math.max( 0, minutesInDay - fromM + (dayOffset - 1) * minutesInDay + toM ); } function getWeekdayName( dayIndex: number, lng: string, style: "long" | "short" = "long" ) { // Use Intl for proper localized weekday names const ref = new Date(Date.UTC(2025, 7, 17 + ((dayIndex - 0 + 7) % 7))); // arbitrary week, align Sun=0 return new Intl.DateTimeFormat(lng, { weekday: style }).format(ref); } /* ============================================================================ Scroll To Top with Progress Ring (robust: detects scroll container + portal) ==========================================================================*/ const EnhancedScrollTop: React.FC = () => { const theme = useTheme(); const { t } = useTranslation(); const isMobile = useMediaQuery(theme.breakpoints.down("sm")); const isTablet = useMediaQuery(theme.breakpoints.down("md")); const [isVisible, setIsVisible] = useState(false); const [scrollProgress, setScrollProgress] = useState(0); const getScrollableAncestor = useCallback((start?: Element | null) => { if (typeof window === "undefined") return null; let el: Element | null = start || document.getElementById("menu-section") || document.querySelector("main") || document.getElementById("root") || document.body; while (el && el !== document.body) { const style = window.getComputedStyle(el); const oy = style.overflowY; const scrollable = (oy === "auto" || oy === "scroll" || oy === "overlay") && (el as HTMLElement).scrollHeight > (el as HTMLElement).clientHeight; if (scrollable) return el as HTMLElement; el = el.parentElement; } const docEl = document.scrollingElement || document.documentElement; return (docEl as HTMLElement) || document.body; }, []); useEffect(() => { if (typeof window === "undefined") return; const container = getScrollableAncestor( document.getElementById("menu-section") ); const useWindow = container === document.body || container === document.documentElement || container === document.scrollingElement; const target: HTMLElement | Window = useWindow ? window : (container as HTMLElement); let ticking = false; const readMetrics = () => { if (useWindow) { const top = window.pageYOffset || document.documentElement.scrollTop || 0; const height = (document.scrollingElement || document.documentElement).scrollHeight - window.innerHeight; return { top, height: Math.max(1, height) }; } else { const el = container as HTMLElement; return { top: el.scrollTop, height: Math.max(1, el.scrollHeight - el.clientHeight), }; } }; const handleScroll = () => { if (ticking) return; ticking = true; requestAnimationFrame(() => { const { top, height } = readMetrics(); setScrollProgress(Math.min(top / height, 1)); const threshold = isMobile ? 80 : 150; setIsVisible(top > threshold); ticking = false; }); }; // initial measure handleScroll(); target.addEventListener("scroll", handleScroll as any, { passive: true }); window.addEventListener("resize", handleScroll, { passive: true }); return () => { target.removeEventListener("scroll", handleScroll as any); window.removeEventListener("resize", handleScroll); }; }, [getScrollableAncestor, isMobile]); const buttonSize = isMobile ? 56 : isTablet ? 64 : 72; const baseBottom = isMobile ? 20 : isTablet ? 24 : 32; const baseRight = isMobile ? 20 : isTablet ? 24 : 32; const bottomWithSafeArea = `calc(${baseBottom}px + env(safe-area-inset-bottom, 0px))`; const rightWithSafeArea = `calc(${baseRight}px + env(safe-area-inset-right, 0px))`; const primary = theme.palette.primary.main; const primaryDark = theme.palette.primary.dark; const secondary = theme.palette.secondary.main; const secondaryDark = theme.palette.secondary.dark; if (typeof window === "undefined") return null; return createPortal( {isVisible && ( {/* Progress ring */} window.scrollTo({ top: 0, behavior: "smooth" })} sx={{ width: buttonSize, height: buttonSize, background: `linear-gradient(135deg, ${primary} 0%, ${secondary} 50%, ${primaryDark} 100%)`, backdropFilter: "blur(20px)", border: `2px solid ${alpha(primary, 0.3)}`, boxShadow: ` 0 8px 32px ${alpha(primary, 0.4)}, 0 0 0 1px ${alpha("#ffffff", 0.1)} inset, 0 2px 16px ${alpha("#000000", 0.1)} `, color: "white", position: "relative", overflow: "hidden", "&:hover": { background: `linear-gradient(135deg, ${primaryDark} 0%, ${secondaryDark} 50%, ${primary} 100%)`, boxShadow: ` 0 12px 40px ${alpha(primary, 0.5)}, 0 0 0 1px ${alpha("#ffffff", 0.2)} inset, 0 4px 20px ${alpha("#000000", 0.15)} `, }, "&::before": { content: '""', position: "absolute", inset: 0, background: `radial-gradient(circle at 30% 30%, ${alpha( "#ffffff", 0.2 )} 0%, transparent 50%)`, pointerEvents: "none", }, }} > )} , document.body ); }; /* ============================================================================ Product Grid Layout ==========================================================================*/ interface EnhancedProductGridProps { viewMode: "grid" | "list"; children: React.ReactNode; } const EnhancedProductGrid: React.FC = ({ viewMode, children, }) => { const isMobile = useMediaQuery((t: any) => t.breakpoints.down("sm")); const isTablet = useMediaQuery((t: any) => t.breakpoints.down("md")); const isLarge = useMediaQuery((t: any) => t.breakpoints.up("lg")); const isXLarge = useMediaQuery((t: any) => t.breakpoints.up("xl")); const getGridConfig = () => { // ✅ List view only available on mobile if (viewMode === "list" && isMobile) { return { columns: "1fr", gap: 2, minHeight: 180, }; } // Always grid view for desktop/tablet let columns: string; let gap: number; if (isMobile) { columns = "repeat(1, 1fr)"; gap = 3; } else if (isTablet) { columns = "repeat(2, 1fr)"; gap = 3; } else if (isXLarge) { columns = "repeat(4, 1fr)"; gap = 5; } else if (isLarge) { columns = "repeat(3, 1fr)"; gap = 4; } else { columns = "repeat(3, 1fr)"; gap = 4; } return { columns, gap, minHeight: isMobile ? 380 : 420 }; }; const { columns, gap, minHeight } = getGridConfig(); return ( *": { minHeight, height: "100%" }, // leave space for scroll-to-top FAB pb: { xs: "calc(120px + env(safe-area-inset-bottom, 0px))", sm: "calc(130px + env(safe-area-inset-bottom, 0px))", md: "calc(140px + env(safe-area-inset-bottom, 0px))", }, "@media (max-width: 480px)": { gridTemplateColumns: "1fr", gap: 2 }, "@media (min-width: 481px) and (max-width: 768px)": { gridTemplateColumns: "repeat(2, 1fr)", gap: 2.5, }, "@media (min-width: 769px) and (max-width: 1024px)": { gridTemplateColumns: "repeat(2, 1fr)", gap: 3, }, "@media (min-width: 1025px) and (max-width: 1200px)": { gridTemplateColumns: "repeat(3, 1fr)", gap: 3.5, }, "@media (min-width: 1201px)": { gridTemplateColumns: "repeat(4, 1fr)", gap: 4, }, }} > {children} ); }; /* ============================================================================ First Item Cart Helper ==========================================================================*/ interface CartHelperProps { isVisible: boolean; onClose: () => void; addedItem: CartItem | null; } const FirstItemCartHelper: React.FC = ({ isVisible, onClose, addedItem, }) => { const theme = useTheme(); const { t } = useTranslation(); const isMobile = useMediaQuery(theme.breakpoints.down("sm")); return ( {isVisible && ( {t("firstItemAddedTitle", { defaultValue: "First item added!", })} {t("addedToCartMessage", { defaultValue: "{{name}} is in your cart", name: addedItem?.name || "", })} {t("viewCart", { defaultValue: "View cart" })} )} ); }; /* ============================================================================ Loading Skeleton ==========================================================================*/ const SushiProductSkeleton: React.FC = () => { const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down("sm")); return ( ); }; /* ============================================================================ Offline Indicator ==========================================================================*/ const OfflineIndicator: React.FC = () => { const [isOnline, setIsOnline] = useState(navigator.onLine); const theme = useTheme(); const { t } = useTranslation(); useEffect(() => { const handleOnline = () => setIsOnline(true); const handleOffline = () => setIsOnline(false); window.addEventListener("online", handleOnline); window.addEventListener("offline", handleOffline); return () => { window.removeEventListener("online", handleOnline); window.removeEventListener("offline", handleOffline); }; }, []); if (isOnline) return null; return ( } sx={{ position: "fixed", top: 16, left: 16, right: 16, zIndex: 1300, borderRadius: 3, boxShadow: `0 8px 32px ${alpha(theme.palette.warning.main, 0.3)}`, }} > {t("offlineNotice", { defaultValue: "You are offline. Some features may be limited.", })} ); }; /* ============================================================================ Error Display ==========================================================================*/ interface ErrorDisplayProps { error: string; onRetry: () => void; isRetrying: boolean; } const ErrorDisplay: React.FC = ({ error, onRetry, isRetrying, }) => { const theme = useTheme(); const { t } = useTranslation(); return ( {t("errorOccurred", { defaultValue: "An error occurred" })} {error} ); }; /* ============================================================================ Main Component ==========================================================================*/ const MainMenu: React.FC = () => { const theme = useTheme(); const { t, i18n } = useTranslation(); const { totalItems, setFirstItemCallback } = useCart(); const isMobile = useMediaQuery(theme.breakpoints.down("md")); const isSmallMobile = useMediaQuery(theme.breakpoints.down("sm")); // Data const [products, setProducts] = useState([]); const [categories, setCategories] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [retrying, setRetrying] = useState(false); // UI state const [searchQuery, setSearchQuery] = useState(""); const [selectedCategory, setSelectedCategory] = useState(null); const [sortBy, setSortBy] = useState< "name" | "price" | "rating" | "calories" >("name"); const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc"); // ✅ View mode - mobile only (list view disabled on desktop) const [viewMode, setViewMode] = useState<"grid" | "list">(() => { if (typeof window !== "undefined") { const saved = localStorage.getItem("menu-view-mode"); if (saved === "grid" || saved === "list") return saved; } return "grid"; }); useEffect(() => { try { localStorage.setItem("menu-view-mode", viewMode); } catch { /* ignore */ } }, [viewMode]); // First item helper const [showCartHelper, setShowCartHelper] = useState(false); const [addedItem, setAddedItem] = useState(null); // Unsub refs const [unsubscribeProducts, setUnsubscribeProducts] = useState< (() => void) | null >(null); const [unsubscribeCategories, setUnsubscribeCategories] = useState< (() => void) | null >(null); // Opening status (Europe/Amsterdam) – structured, localized in render const [openInfo, setOpenInfo] = useState(() => computeOpenStatus(OPENING_HOURS) ); useEffect(() => { setOpenInfo(computeOpenStatus(OPENING_HOURS)); const id = setInterval(() => { setOpenInfo(computeOpenStatus(OPENING_HOURS)); }, 30_000); return () => clearInterval(id); }, []); // First item callback hookup const handleFirstItemAdded = useCallback((item: CartItem) => { setAddedItem(item); setShowCartHelper(true); window.scrollTo({ top: 0, behavior: "smooth" }); setTimeout(() => setShowCartHelper(false), 4000); }, []); useEffect(() => { setFirstItemCallback(handleFirstItemAdded); return () => setFirstItemCallback(() => {}); }, [setFirstItemCallback, handleFirstItemAdded]); const handleCloseCartHelper = useCallback(() => setShowCartHelper(false), []); // Load Firebase data useEffect(() => { void loadFirebaseData(); return () => { if (unsubscribeProducts) unsubscribeProducts(); if (unsubscribeCategories) unsubscribeCategories(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const loadFirebaseData = async () => { setLoading(true); setError(null); try { const categoriesData = await CategoryService.getCategories(); setCategories(categoriesData); const productUnsubscribe = ProductService.subscribeToProducts( (productsData) => { setProducts(productsData); setLoading(false); }, (err) => { setError(FirebaseErrorHandler.handleError(err)); setLoading(false); } ); const categoryUnsubscribe = CategoryService.subscribeToCategories( (cs) => setCategories(cs), (err) => console.warn("Categories subscription error:", err) ); setUnsubscribeProducts(() => productUnsubscribe); setUnsubscribeCategories(() => categoryUnsubscribe); } catch (err) { setError(FirebaseErrorHandler.handleError(err)); setLoading(false); } }; const handleRetry = async () => { setRetrying(true); try { if (unsubscribeProducts) unsubscribeProducts(); if (unsubscribeCategories) unsubscribeCategories(); try { await OfflineService.enableOffline(); } catch { /* ignore */ } await loadFirebaseData(); } finally { setRetrying(false); } }; const handleRefresh = () => { if (!loading) void handleRetry(); }; // Search analytics (debounced) useEffect(() => { if (searchQuery.trim() && searchQuery.length > 2) { const timeoutId = setTimeout(() => { AnalyticsService.trackSearchQuery(searchQuery.trim()); }, 1000); return () => clearTimeout(timeoutId); } }, [searchQuery]); // Products filtered only by SEARCH (for counts) const productsAfterSearch = useMemo(() => { if (!searchQuery.trim()) return products; const q = searchQuery.toLowerCase(); return products.filter( (p) => p.name.toLowerCase().includes(q) || p.description.toLowerCase().includes(q) || p.ingredients?.some((ing) => ing.toLowerCase().includes(q)) || p.categories.some((catId) => { const category = categories.find((c) => c.id === catId); return category?.name.toLowerCase().includes(q); }) ); }, [products, categories, searchQuery]); // Category counts (based on search results) const productCounts = useMemo(() => { const counts: Record = { _all: productsAfterSearch.length }; for (const c of categories) counts[c.id] = 0; for (const p of productsAfterSearch) { for (const cid of p.categories) { counts[cid] = (counts[cid] || 0) + 1; } } return counts; }, [productsAfterSearch, categories]); // Filter & sort for grid const filteredAndSortedProducts = useMemo(() => { let result = [...productsAfterSearch]; if (selectedCategory) { result = result.filter((p) => p.categories.includes(selectedCategory)); } result.sort((a, b) => { let aValue: string | number; let bValue: string | number; switch (sortBy) { case "price": aValue = a.price; bValue = b.price; break; case "calories": aValue = a.calories ?? 0; bValue = b.calories ?? 0; break; case "rating": aValue = (a as any).rating ?? 4.5; bValue = (b as any).rating ?? 4.5; break; default: aValue = a.name.toLowerCase(); bValue = b.name.toLowerCase(); } if (aValue < bValue) return sortOrder === "asc" ? -1 : 1; if (aValue > bValue) return sortOrder === "asc" ? 1 : -1; return 0; }); return result; }, [productsAfterSearch, selectedCategory, sortBy, sortOrder]); const handleSortChange = (newSortBy: typeof sortBy | string) => { const next = newSortBy as typeof sortBy; if (sortBy === next) { setSortOrder((prev) => (prev === "asc" ? "desc" : "asc")); } else { setSortBy(next); setSortOrder("asc"); } }; const sortOptions = [ { value: "name", label: t("name", { defaultValue: "Name" }), shortLabel: t("name", { defaultValue: "Name" }), icon: , }, { value: "price", label: t("price", { defaultValue: "Price" }), shortLabel: t("price", { defaultValue: "Price" }), icon: , }, { value: "rating", label: t("rating", { defaultValue: "Rating" }), shortLabel: t("rating", { defaultValue: "Rating" }), icon: , }, { value: "calories", label: t("calories", { defaultValue: "Calories" }), shortLabel: t("calories", { defaultValue: "Cal" }), icon: , }, ] as const; // Localized open/close status strings const statusStrings = useMemo(() => { const { isOpen, currentClose, nextOpen, nowM, weekday } = openInfo; const todayLabel = openInfo.todays.length > 0 ? openInfo.todays.map((i) => `${i.start}–${i.end}`).join(" · ") : t("closed", { defaultValue: "Closed" }); if (isOpen && currentClose) { const mins = minutesUntil( nowM, currentClose.endM, currentClose.endDayOffset ); const dayPrefix = currentClose.endDayOffset === 0 ? "" : `${getWeekdayName((weekday + 1) % 7, i18n.language, "short")} `; const timeLabel = `${dayPrefix}${formatHHMM(currentClose.endM)}`; return { title: t("nowOpen", { defaultValue: "Now Open" }), sub: t("closesAt", { defaultValue: "Closes at {{time}} (in {{mins}} mins)", time: timeLabel, mins, }), todayLabel, }; } if (!isOpen) { if (nextOpen) { const mins = minutesUntil(nowM, nextOpen.startM, nextOpen.dayOffset); if (nextOpen.dayOffset === 0) { return { title: t("currentlyClosed", { defaultValue: "Currently Closed" }), sub: t("opensAt", { defaultValue: "Opens at {{time}} (in {{mins}} mins)", time: formatHHMM(nextOpen.startM), mins, }), todayLabel, }; } else if (nextOpen.dayOffset === 1) { return { title: t("currentlyClosed", { defaultValue: "Currently Closed" }), sub: t("opensTomorrowAt", { defaultValue: "Opens tomorrow at {{time}}", time: formatHHMM(nextOpen.startM), }), todayLabel, }; } else { const dayName = getWeekdayName( (weekday + nextOpen.dayOffset) % 7, i18n.language, "long" ); return { title: t("currentlyClosed", { defaultValue: "Currently Closed" }), sub: t("opensOnDayAt", { defaultValue: "Opens on {{day}} at {{time}}", day: dayName, time: formatHHMM(nextOpen.startM), }), todayLabel, }; } } return { title: t("currentlyClosed", { defaultValue: "Currently Closed" }), sub: t("noOpeningHoursAvailable", { defaultValue: "No opening hours available", }), todayLabel, }; } return { title: "", sub: "", todayLabel, }; }, [openInfo, i18n.language, t]); // Early error view (no data) if (error && !products.length) { return (
); } return (
{/* First Item Cart Helper */} {/* Loading Backdrop */} {t("refreshingData", { defaultValue: "Refreshing data..." })} {/* Hero */} {/* Premium Badge */} {t("Premium Sushi Experience", { defaultValue: "Premium Sushi Experience", })} {/* Title */} 寿司布雷斯肯斯 {t("restaurantName", { defaultValue: "Sushi Breskens" })} {t("Authentic Asian Excellence", { defaultValue: "Authentic Asian Excellence", })} {t( "Ontdek de unieke harmonie van traditionele Asian culinaire meesterschap in het hart van Zeeland. Gevestigd in Breskens Winkelhart, waar verse ingrediënten dagelijks worden bereid met authentieke technieken.", { defaultValue: "Discover the unique harmony of traditional Asian culinary mastery in the heart of Zeeland. Located in Breskens Shopping Center, where fresh ingredients are prepared daily with authentic techniques.", } )} {/* Features */} {[ { icon: , label: t("Unique Concept", { defaultValue: "Unique Concept", }), desc: t("Verse sushi in PLUS Lohman supermarkt", { defaultValue: "Fresh sushi in PLUS Lohman supermarket", }), color: theme.palette.primary.main, }, { icon: , label: t("Zeeland Fresh", { defaultValue: "Zeeland Fresh" }), desc: t("Dagelijks verse vis uit de kustwateren", { defaultValue: "Daily fresh fish from coastal waters", }), color: theme.palette.info.main, }, { icon: , label: t("Coastal Heritage", { defaultValue: "Coastal Heritage", }), desc: t("Breskens maritieme traditie sinds 2022", { defaultValue: "Breskens maritime tradition since 2022", }), color: theme.palette.secondary.main, }, ].map((feature, index) => ( {React.cloneElement(feature.icon, { sx: { fontSize: { xs: 32, sm: 36 } }, })} {feature.label} {feature.desc} ))} {/* Contact / Dynamic Open Status */} {/* Dynamic status card */} openInfo.isOpen ? `linear-gradient(135deg, ${alpha( theme.palette.success.main, 0.12 )} 0%, ${alpha(theme.palette.success.main, 0.06)} 100%)` : `linear-gradient(135deg, ${alpha( theme.palette.error.main, 0.1 )} 0%, ${alpha(theme.palette.error.main, 0.05)} 100%)`, border: (theme) => `2px solid ${alpha( openInfo.isOpen ? theme.palette.success.main : theme.palette.error.main, 0.25 )}`, boxShadow: (theme) => `0 12px 40px ${alpha( openInfo.isOpen ? theme.palette.success.main : theme.palette.error.main, 0.18 )}`, }} > ({ width: 14, height: 14, borderRadius: "50%", backgroundColor: openInfo.isOpen ? theme.palette.success.main : theme.palette.error.main, position: "relative", "&::after": { content: '""', position: "absolute", inset: -5, borderRadius: "50%", border: `2px solid ${alpha( openInfo.isOpen ? theme.palette.success.main : theme.palette.error.main, 0.35 )}`, animation: "ripple 2.5s infinite", }, "@keyframes ripple": { "0%": { opacity: 1, transform: "scale(1)" }, "100%": { opacity: 0, transform: "scale(2.2)" }, }, })} /> ({ fontWeight: 800, color: openInfo.isOpen ? theme.palette.success.dark : theme.palette.error.dark, fontSize: { xs: "0.95rem", sm: "1.05rem" }, })} > {statusStrings.title} ({ color: openInfo.isOpen ? theme.palette.success.main : theme.palette.error.main, fontSize: { xs: "0.8rem", sm: "0.85rem" }, fontWeight: 600, })} > {statusStrings.sub} {t("today", { defaultValue: "Today" })}:{" "} {statusStrings.todayLabel} {/* Location card */} {t("locationName", { defaultValue: "PLUS Lohman – Breskens", })} Spuiplein 49, Zeeland {t("Voor vragen of reserveringen:", { defaultValue: "For questions or reservations:", })} {/* Menu Section */} handleSortChange(v)} onSortDirectionChange={(dir) => setSortOrder(dir)} onRefresh={isMobile ? handleRefresh : undefined} viewMode={viewMode} onViewToggle={ isMobile ? () => setViewMode((p) => (p === "grid" ? "list" : "grid")) : undefined } /> {/* ✅ Desktop controls (simplified - NO view toggle) */} {!isMobile && ( {loading ? t("loading", { defaultValue: "Loading..." }) : `${filteredAndSortedProducts.length} ${t( "Premium Gerechten", { defaultValue: "Premium Dishes", } )}`} {filteredAndSortedProducts.length > 0 && !loading && ( c.id === selectedCategory) ?.name || selectedCategory : t("allCategories", { defaultValue: "All Categories", }) } variant="outlined" size="medium" onDelete={ selectedCategory ? () => setSelectedCategory(null) : undefined } sx={{ fontWeight: 600, borderColor: alpha(theme.palette.secondary.main, 0.3), color: theme.palette.secondary.main, "&:hover": { backgroundColor: alpha( theme.palette.secondary.main, 0.08 ), }, }} /> )} {/* ✅ Desktop sorting chips (NO view toggle) */} {( [ { key: "name", labelKey: "name", icon: }, { key: "price", labelKey: "price", icon: }, { key: "rating", labelKey: "rating", icon: , }, { key: "calories", labelKey: "calories", icon: , }, ] as const ).map((option) => ( handleSortChange(option.key as typeof sortBy) } disabled={loading} icon={ sortBy === option.key ? ( {option.icon} ) : ( option.icon ) } sx={{ fontSize: "0.8rem", fontWeight: 600, height: 40, backgroundColor: sortBy === option.key ? theme.palette.primary.main : "transparent", color: sortBy === option.key ? "white" : theme.palette.text.primary, borderColor: alpha(theme.palette.primary.main, 0.3), "&:hover": { backgroundColor: sortBy === option.key ? theme.palette.primary.dark : alpha(theme.palette.primary.main, 0.08), transform: "translateY(-2px)", boxShadow: `0 8px 25px ${alpha( theme.palette.primary.main, 0.25 )}`, }, "&:disabled": { opacity: 0.5 }, }} /> ))} )} {/* Mobile summary */} {isMobile && ( {loading ? t("loading", { defaultValue: "Loading..." }) : `${filteredAndSortedProducts.length} ${t( "gerechten", { defaultValue: "dishes", } )}`} {selectedCategory && ( c.id === selectedCategory) ?.name || selectedCategory } variant="outlined" size="small" onDelete={() => setSelectedCategory(null)} sx={{ fontSize: "0.75rem", fontWeight: 600 }} /> )} )} {/* Products Grid */} {loading || !products.length ? Array.from({ length: isSmallMobile ? 6 : 12 }).map( (_, idx) => ( ) ) : filteredAndSortedProducts.map((product, index) => ( ))} {/* Empty State */} {!loading && filteredAndSortedProducts.length === 0 && products.length > 0 && ( {t("noResults", { defaultValue: "No results found" })} {t( "Probeer uw zoekopdracht aan te passen of bekijk alle categorieën", { defaultValue: "Try adjusting your search or view all categories", } )} )} {/* Scroll to top */} ); }; export default MainMenu;