// CalGrillz — shared shell (header, footer, ticker, common UI). Loaded on every page. const { useState, useEffect, useRef, useMemo } = React; // Laravel-friendly URL resolver. The Blade wrapper injects window.CG_URLS // with route-generated paths; we fall back to the original *.html names so // the template still previews standalone (e.g. opening index.html directly). const CG_URL = (key, fallback) => (window.CG_URLS && window.CG_URLS[key]) || fallback; // Asset resolver — same fallback pattern. Lets the Blade host serve assets // from /template2/assets/... while the standalone preview uses ./assets/... const CG_ASSET = (name) => { if (!name) return name; // Uploaded photos are stored as absolute URLs (https://…/storage/…) or // root-anchored paths (/storage/…), and data: URIs are self-contained — use // any of those as-is. Prepending the template asset base to a full URL is what // produced the doubled "website address twice" link. Only bare filenames // (the template's own bundled assets) get the base. if (/^(https?:)?\/\//i.test(name) || name.charAt(0) === '/' || name.slice(0, 5) === 'data:') return name; return (window.CG_ASSET_BASE || 'assets') + '/' + name; }; // ── Shared CMS helpers (available to EVERY page via the shell) ── // cgC(key, fallback): read an owner-edited value from window.CG_CONTENT. // cgImg(value): use an absolute CMS URL (/storage/… or http…) as-is; only // a bare bundled-asset filename gets the /template2/assets prefix. // cgHtml(value): render a sanitized CMS html string (server allowlists tags). function cgC(key, fallback = '') { const v = (window.CG_CONTENT || {})[key]; return (v === undefined || v === null || v === '') ? fallback : v; } const cgImg = (v) => (!v ? '' : (/^(https?:|\/)/.test(v) ? v : CG_ASSET(v))); function cgHtml(value) { return ; } // ── Social links ───────────────────────────────────────────────────────── // Keep the public profiles in one place so the header, footer, and Contact // page always show the same real destinations and recognizable brand marks. function CGSocialIcon({ name, size = 16 }) { const iconProps = { width: size, height: size, viewBox: '0 0 24 24', 'aria-hidden': 'true', focusable: 'false', }; switch (name) { case 'instagram': return ; case 'tiktok': return ; case 'facebook': return ; case 'whatsapp': return ; default: return null; } } function CGSocialLinks({ className = '', size = 16 }) { const whatsappNumber = String(cgC('contact.whatsapp', '2347085701097')).replace(/\D/g, ''); const links = [ { id: 'instagram', label: 'Instagram', href: 'https://instagram.com/calgrillz' }, { id: 'tiktok', label: 'TikTok', href: 'https://tiktok.com/@calgrillz' }, { id: 'facebook', label: 'Facebook', href: 'https://facebook.com/calgrillz' }, ...(whatsappNumber ? [{ id: 'whatsapp', label: 'WhatsApp', href: 'https://wa.me/' + whatsappNumber }] : []), ]; return (
{links.map((link) => ( ))}
); } // Live cart count for the header badge — re-renders whenever the CGCart // store changes (add/remove/qty change, including from other tabs). function CGHeaderCartCount() { const cart = useCart(); return <>{cart.count}; } // ─── Shared sign-out helper ────────────────────────────────────────────── // One implementation used by every Sign-out button (header user-menu, // mobile drawer, the profile-tab fallback). POSTs to /logout, clears the // local cart + voucher stores, then redirects home. Designed to never // throw — even if the network drops, the user still gets logged out // client-side and bounced to a safe page. async function cgSignOut(opts) { const o = opts || {}; if (o.confirm !== false) { if (typeof window !== 'undefined' && !window.confirm('Sign out of your CalGrillz account?')) return false; } const url = (window.CG_URLS && window.CG_URLS.logout) || '/logout'; const csrf = (window.CG_URLS && window.CG_URLS.csrf) || ''; try { await fetch(url, { method: 'POST', headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': csrf, 'X-Requested-With': 'XMLHttpRequest', }, credentials: 'same-origin', }); } catch (e) { /* offline / blocked — fall through, navigate anyway */ } try { if (window.CGCart) window.CGCart.clear(); } catch (e) {} try { if (window.CGVouchers) window.CGVouchers.clear(); } catch (e) {} window.location.href = (window.CG_URLS && window.CG_URLS.home) || '/'; return true; } // ─── Header user-menu dropdown ─────────────────────────────────────────── // Replaces the bare user-icon link with a click-to-open menu so Sign out // lives in the obvious place every web user reaches for. Anonymous users // still see the icon as a Sign-in shortcut (no dropdown). function CGHeaderUserMenu() { const isLoggedIn = !!(window.CG_DATA && window.CG_DATA.isLoggedIn); const customerName = (window.CG_DATA && window.CG_DATA.customer && window.CG_DATA.customer.name) || null; const [open, setOpen] = useState(false); const wrapRef = useRef(null); // Close on outside click / Escape key — standard popover hygiene. useEffect(() => { if (!open) return; function onDocClick(e) { if (wrapRef.current && !wrapRef.current.contains(e.target)) setOpen(false); } function onKey(e) { if (e.key === 'Escape') setOpen(false); } document.addEventListener('mousedown', onDocClick); document.addEventListener('keydown', onKey); return () => { document.removeEventListener('mousedown', onDocClick); document.removeEventListener('keydown', onKey); }; }, [open]); // Anonymous: render the plain Sign-in link, no dropdown. if (!isLoggedIn) { return ( ); } // Logged in: icon button toggles a dropdown. return (
{open && (
{customerName && (
Signed in as
{customerName}
)} setOpen(false)}> My account setOpen(false)}> Daily check-in setOpen(false)}> 🎰 Spin the wheel setOpen(false)}> Predict & Chop setOpen(false)}> 🎁 Mystery Box setOpen(false)}> 🎟 Weekly Raffle
{/* Services — book the venue for an event, or a Cocokalina room. */} setOpen(false)}> 🎉 Book an event {!(window.CG_DATA && window.CG_DATA.cocokalinaOn === false) && ( setOpen(false)}> 🛏️ Book a room )}
setOpen(false)}> My orders setOpen(false)}> My vouchers setOpen(false)}> Buy a gift voucher setOpen(false)}> Refer a friend
)}
); } // ─── Web Push subscribe button (top of the bell drawer) ──────────────── // Only renders if: // 1. VAPID key is configured server-side (window.CG_URLS.vapidPublicKey) // 2. Browser supports Notification + PushManager + service worker // 3. Customer hasn't already subscribed on this device (Notification.permission) // One-tap "Enable notifications" → asks OS for permission → subscribes to // PushManager → POSTs the subscription to the server. Errors are silent — // the in-app bell still works even if push is refused. function CGPushEnable() { const CG = window.CG_URLS || {}; const vapid = CG.vapidPublicKey || ''; const subscribeUrl = CG.pushSubscribe || null; const csrf = CG.csrf || ''; const supported = typeof window !== 'undefined' && 'Notification' in window && 'serviceWorker' in navigator && 'PushManager' in window && vapid !== '' && subscribeUrl; const [perm, setPerm] = React.useState(() => (supported ? Notification.permission : 'denied')); const [busy, setBusy] = React.useState(false); React.useEffect(() => { if (!supported) return; // If already subscribed on this device, don't nag. navigator.serviceWorker.ready.then(reg => reg.pushManager.getSubscription().then(sub => { if (sub) setPerm('subscribed'); }) ).catch(() => {}); }, [supported]); if (!supported) return null; if (perm === 'granted' || perm === 'subscribed') return null; if (perm === 'denied') return (
Push notifications are blocked in your browser settings. Turn them on for calgrillz.com to get updates when the app is closed.
); const enable = async () => { if (busy) return; setBusy(true); try { const p = await Notification.requestPermission(); if (p !== 'granted') { setPerm(p); return; } const reg = await navigator.serviceWorker.ready; const sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(vapid), }); const raw = sub.toJSON(); const res = await fetch(subscribeUrl, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrf, Accept: 'application/json' }, body: JSON.stringify(raw), }); if (res.ok) setPerm('subscribed'); } catch (e) { /* silent — bell still works */ } finally { setBusy(false); } }; return (
🔔
Get notifications on your phone the moment there's an update.
); } // VAPID public key comes back as URL-safe base64; PushManager wants a Uint8Array. function urlBase64ToUint8Array(base64) { const padding = '='.repeat((4 - base64.length % 4) % 4); const b64 = (base64 + padding).replace(/-/g, '+').replace(/_/g, '/'); const raw = atob(b64); const arr = new Uint8Array(raw.length); for (let i = 0; i < raw.length; ++i) arr[i] = raw.charCodeAt(i); return arr; } // ─── Notification bell ─────────────────────────────────────────────────── // Header icon that shows an unread badge + opens a right-side drawer with // the latest 20 notifications. Fetches from GET /notifications/inbox, // marks-read on tap (posts to /notifications/{id}/read), and offers a // "Mark all read" button. Silently self-hides for anonymous visitors and // on backends that lie about the inbox route (defensive). function CGHeaderBell() { const [open, setOpen] = React.useState(false); const [items, setItems] = React.useState([]); const [unread, setUnread] = React.useState(0); const [loading, setLoading] = React.useState(false); const wrapRef = React.useRef(null); const isLoggedIn = !!(window.CG_DATA && window.CG_DATA.isLoggedIn); const inboxUrl = (window.CG_URLS && window.CG_URLS.notifInbox) || null; const readAllUrl = (window.CG_URLS && window.CG_URLS.notifReadAll) || null; const readBase = (window.CG_URLS && window.CG_URLS.notifReadBase) || '/notifications'; const csrf = (window.CG_URLS && window.CG_URLS.csrf) || ''; const fetchInbox = React.useCallback(() => { if (!isLoggedIn || !inboxUrl) return; setLoading(true); fetch(inboxUrl, { credentials: 'same-origin', headers: { Accept: 'application/json' } }) .then(r => r.ok ? r.json() : { ok: false }) .then(d => { if (d && d.ok) { setItems(d.items || []); setUnread(d.unread || 0); } }) .catch(() => {}) .finally(() => setLoading(false)); }, [isLoggedIn, inboxUrl]); // Prime the unread badge on first render + refresh every 60s when the // drawer is closed. Chatty enough to feel live, cheap enough not to // matter (single tiny JSON payload). React.useEffect(() => { if (!isLoggedIn) return; fetchInbox(); const t = setInterval(() => { if (! open) fetchInbox(); }, 60_000); return () => clearInterval(t); }, [fetchInbox, isLoggedIn, open]); // Refresh whenever the drawer opens. React.useEffect(() => { if (open) fetchInbox(); }, [open, fetchInbox]); // Dismiss the drawer on outside-click / Escape. React.useEffect(() => { if (!open) return; const onDoc = (e) => { if (wrapRef.current && !wrapRef.current.contains(e.target)) setOpen(false); }; const onEsc = (e) => { if (e.key === 'Escape') setOpen(false); }; document.addEventListener('mousedown', onDoc); document.addEventListener('keydown', onEsc); return () => { document.removeEventListener('mousedown', onDoc); document.removeEventListener('keydown', onEsc); }; }, [open]); if (!isLoggedIn) return null; const markOne = (n) => { if (n.read) return; fetch(readBase + '/' + n.id + '/read', { method: 'POST', credentials: 'same-origin', headers: { 'X-CSRF-TOKEN': csrf, Accept: 'application/json' }, }).then(r => r.ok ? r.json() : null).then(d => { if (d && d.ok) { setUnread(d.unread || 0); setItems(items.map(x => x.id === n.id ? { ...x, read: true } : x)); } }).catch(() => {}); }; const markAll = () => { if (!readAllUrl) return; fetch(readAllUrl, { method: 'POST', credentials: 'same-origin', headers: { 'X-CSRF-TOKEN': csrf, Accept: 'application/json' }, }).then(r => r.ok ? r.json() : null).then(d => { if (d && d.ok) { setUnread(0); setItems(items.map(x => ({ ...x, read: true }))); } }).catch(() => {}); }; return (
{open && ( // Position: fixed to the viewport top-right corner so a bell button // that sits mid-header on mobile doesn't push the drawer off-screen. // Width caps at 360px, and the right/top offsets shrink on narrow // viewports for a full-width app-drawer feel.
Notifications {unread > 0 && ( )}
{loading && items.length === 0 && (
Loading…
)} {!loading && items.length === 0 && (
No notifications yet. Order something, pay for a tournament, check in for points — updates land here.
)} {items.map(n => ( markOne(n)} style={{ display: 'block', padding: '12px 14px', textDecoration: 'none', color: 'inherit', borderBottom: '1px solid rgba(58,51,42,.5)', background: n.read ? 'transparent' : 'rgba(249,115,22,.06)', position: 'relative', }}>
{n.icon}
{n.title}
{n.body &&
{n.body}
}
{n.when}
{!n.read && ( )}
))}
)}
); } // ─── Desktop nav dropdown group (Games / Services) ─────────────────────── // Renders a nav label with a caret that opens a small popover of sub-links. // Opens on hover (desktop) and on click/tap; closes on outside-click, Escape, // or picking an item. Modeled on CGHeaderUserMenu. The parent shows as active // when the current page id is one of the group's `match` ids. function CGWNavGroup({ group, activeId }) { const [open, setOpen] = useState(false); const wrapRef = useRef(null); const isActive = (group.match || []).includes(activeId); // Click-to-toggle on every device — the menu stays collapsed until the // trigger is clicked (no hover-open, which pops menus as the pointer drifts). useEffect(() => { if (!open) return; function onDocClick(e) { if (wrapRef.current && !wrapRef.current.contains(e.target)) setOpen(false); } function onKey(e) { if (e.key === 'Escape') setOpen(false); } document.addEventListener('mousedown', onDocClick); document.addEventListener('keydown', onKey); return () => { document.removeEventListener('mousedown', onDocClick); document.removeEventListener('keydown', onKey); }; }, [open]); return (
{ e.preventDefault(); setOpen(v => !v); }} style={{ display: 'inline-flex', alignItems: 'center', gap: 5, cursor: 'pointer' }}> {group.label} {open && ( // Outer wrapper carries the visual gap as padding so there's no dead // zone between the trigger and the panel (hover stays alive across it). )}
); } // Mobile nav group (Games / Services): collapsed by default; tap the label to // expand it (accordion), so the drawer isn't a long wall of every sub-link. function CGWMobileNavGroup({ group, activeId, onNavigate }) { const [open, setOpen] = useState(false); const hasActive = (group.match || []).includes(activeId); return (
{open && group.children.map(c => ( {c.icon && {c.icon}} {c.label} ))}
); } // ─── Header ────────────────────────────────────────────────────────────── function CGWHeader({ active, minimal = false }) { const [menuOpen, setMenuOpen] = useState(false); const isLoggedIn = !!(window.CG_DATA && window.CG_DATA.isLoggedIn); const contactAddress = cgC('home.find.address', 'No 1 Cocokalina Close, Agbor 330221, Delta State'); const contactPhone = cgC('home.find.phone', '0708 570 1097'); const contactPhoneHref = String(contactPhone).replace(/[^\d+]/g, ''); // Pages like /me, /spin, /refer all pass active="rewards" but the nav // shows "My Account" instead of "Rewards" when signed-in. Map the legacy // id so the highlight still lands on the right tab. const activeId = (isLoggedIn && active === 'rewards') ? 'account' : active; // Rewards / My Account share the same URL (/me/{token}), so we show only // one entry: "Rewards" when logged out (acts as login gateway with a // peek at what's behind the door) and "My Account" when logged in (the // dashboard already serves as the rewards hub). // When signed in, the menu switches to the links a customer actually needs — // their account, orders, vouchers — instead of the marketing pages. (The // #orders / #vouchers deep-links open the matching tab on the dashboard.) const meUrl = CG_URL('rewards', 'me.html'); // Games + Services are grouped into dropdowns (desktop) / labelled sections // (mobile) instead of a long flat row. Shared by both nav variants. const gamesGroup = { id: 'games', label: 'Games', group: true, match: ['spin', 'predict', 'raffle', 'mystery'], children: [ { id: 'spin', href: CG_URL('spin', 'spin.html'), label: 'Spin the wheel', icon: '🎰' }, { id: 'predict', href: '/predict', label: 'Predict & Chop', icon: '⚽' }, { id: 'raffle', href: '/raffle', label: 'Weekly Raffle', icon: '🎟️' }, { id: 'mystery', href: '/mystery-box', label: 'Mystery Box', icon: '🎁' }, ], }; // "Book a Room" (Cocokalina) hides everywhere when the owner disables the // module (window.CG_DATA.cocokalinaOn === false). Undefined → shown. const cocokalinaOn = !(window.CG_DATA && window.CG_DATA.cocokalinaOn === false); const servicesGroup = { id: 'services', label: 'Services', group: true, match: ['services', 'events'], children: [ { id: 'events', href: CG_URL('events', 'events.html'), label: 'Book an Event', icon: '🎉' }, ...(cocokalinaOn ? [{ id: 'services', href: CG_URL('bookRoom', '/book-room'), label: 'Book a Room', icon: '🛏️' }] : []), ], }; const nav = isLoggedIn ? [ { id: 'menu', href: CG_URL('menu', 'menu.html'), label: 'Menu' }, { id: 'account', href: meUrl, label: 'My Account', accent: true }, { id: 'checkin', href: '/checkin', label: 'Check in' }, gamesGroup, servicesGroup, { id: 'orders', href: meUrl + '#orders', label: 'My Orders' }, { id: 'refer', href: CG_URL('refer', 'refer.html'), label: 'Refer' }, ] : [ { id: 'home', href: CG_URL('home', 'index.html'), label: 'Home' }, { id: 'menu', href: CG_URL('menu', 'menu.html'), label: 'Menu' }, gamesGroup, servicesGroup, { id: 'rewards', href: meUrl, label: 'Rewards' }, { id: 'voucher', href: CG_URL('voucher', 'voucher.html'), label: 'Gift voucher' }, { id: 'about', href: CG_URL('about', 'about.html'), label: 'About' }, { id: 'contact', href: CG_URL('contact', 'contact.html'), label: 'Contact' }, ]; // Close the mobile drawer if the viewport gets stretched to desktop. useEffect(() => { function onResize() { if (window.innerWidth > 980) setMenuOpen(false); } window.addEventListener('resize', onResize); return () => window.removeEventListener('resize', onResize); }, []); return (
{!minimal && ( )}
CalGrillz {!minimal && ( )} {minimal ? (
Secure checkout
) : (
{/* Notification bell — visible to logged-in customers only. Click opens a drawer with the latest inbox items; the red dot shows unread count. Backed by CustomerNotificationService on the PHP side. See CGHeaderBell in cgw-shell.jsx. */} {/* User icon — anonymous customers see a Sign-in shortcut; signed-in customers get a click-to-open dropdown with My Account / Gift vouchers / Refer / Sign out. The Sign out is right where users expect it. */} Order now {/* Mobile-only Order CTA — sits between the cart icon and the hamburger on phones (the desktop button above is hidden by CSS at ≤980px). Compact pill that still reads as primary. */} Order now {/* Hamburger — visible only ≤980px via CSS */}
)}
{/* Mobile slide-down drawer — kept inside the header so it stacks naturally */} {!minimal && (
)}
); } // ─── Footer ────────────────────────────────────────────────────────────── function CGWFooter() { return ( ); } // ─── Ticker ───────────────────────────────────────────────────────── function CGWTicker() { const items = [ 'Slow-grilled over real charcoal', 'Point-and-kill catfish daily', 'Earn points on every order', 'Free delivery over ₦15,000', 'Open every day · 10am – 9pm', ]; const Flame = () => ( ); return (
{[...items, ...items].map((it, i) => {it})}
); } // ─── Page hero (small banner under header) ────────────────────────── function CGWPageHero({ eyebrow, title, sub, breadcrumbs, image }) { return (
{image && ( <>
)}
{breadcrumbs && (
{breadcrumbs.map((b, i) => ( {i > 0 && /} {b.href ? {b.label} : {b.label}} ))}
)} {eyebrow &&
{eyebrow}
}

{title}

{sub &&

{sub}

}
); } // ─── Page shell wrapper ───────────────────────────────────────────── // ─── Mobile bottom tab bar ─────────────────────────────────────────── // The app-like footer menu: Menu · Cart · HOME (raised, centre) · Orders · // Settings. Phones only (hidden from 768px up), hidden on minimal pages // (login/checkout) so it never covers a pay button. Orders and Settings // deep-link into the account page's own tabs; logged-out taps land on login. function CGWMobileTabBar({ active }) { const isLoggedIn = !!(window.CG_DATA && window.CG_DATA.isLoggedIn); const meUrl = CG_URL('rewards', 'me.html'); const loginUrl = CG_URL('login', 'login.html'); const { count } = (typeof useCart === 'function' ? useCart() : { count: 0 }); // Highlight follows the page. The account page distinguishes its tabs by // hash, so listen for changes while the customer moves between them. const [hash, setHash] = React.useState((window.location.hash || '').replace('#', '')); React.useEffect(() => { const h = () => setHash((window.location.hash || '').replace('#', '')); window.addEventListener('hashchange', h); return () => window.removeEventListener('hashchange', h); }, []); const path = window.location.pathname; const onMe = /^\/me(\/|$)/.test(path); const current = active === 'menu' || /^\/(menu|item)(\/|$)/.test(path) ? 'menu' : /^\/cart(\/|$)/.test(path) ? 'cart' : /^\/order(\/|$)/.test(path) || (onMe && hash === 'orders') ? 'orders' : onMe ? 'settings' : (active === 'home' || path === '/') ? 'home' : ''; const S = { icon: { display: 'block' } }; const ic = (d, filled) => ( {d} ); const item = (id, href, label, icon, badge) => ( {icon} {badge > 0 && {badge > 9 ? '9+' : badge}} {label} ); return ( ); } function CGWPage({ children, active, minimal, hideFooter, hideTicker }) { return (
{children} {!hideFooter && } {!minimal && } {/* PWA install prompt — slides up from the bottom of the viewport on Android/Chrome when the browser fires beforeinstallprompt, or on iOS Safari with a "tap Share → Add to Home Screen" hint (iOS never fires the event so we can't call prompt() there). Hides itself once the app is running standalone. */}
); } // ─── PWA "Install app" prompt ──────────────────────────────────────── // Two flows in one component: // 1. Android / Chromium — window fires beforeinstallprompt; we capture // the event, show a custom pill, and on tap call event.prompt(). // 2. iOS Safari — the event NEVER fires (Apple decision). We detect // iPhone/iPad + non-standalone and show a short "tap Share, then // Add to Home Screen" hint instead. No programmatic install exists. // Dismissible; stays hidden for 24h after dismissal (localStorage day key). // Silently hides once display-mode: standalone (app is already installed). function CGInstallPrompt() { const [deferred, setDeferred] = React.useState(null); const [iosHint, setIosHint] = React.useState(false); const [installed, setInstalled] = React.useState(false); const [dismissed, setDismissed] = React.useState(false); React.useEffect(() => { const KEY = 'cg-install-dismiss-day'; const today = new Date().toISOString().slice(0, 10); try { if (localStorage.getItem(KEY) === today) setDismissed(true); } catch { /* private mode */ } // Already installed / running as PWA → never show. const isStandalone = window.matchMedia('(display-mode: standalone)').matches || window.navigator.standalone === true; if (isStandalone) { setInstalled(true); return; } // iOS Safari: no beforeinstallprompt, detect + hint. const ua = navigator.userAgent; const isIOS = /iPhone|iPad|iPod/.test(ua) && !window.MSStream; const isIOSSafari = isIOS && /Safari/.test(ua) && !/CriOS|FxiOS|EdgiOS/.test(ua); if (isIOSSafari) setIosHint(true); const onPrompt = (e) => { e.preventDefault(); setDeferred(e); }; const onInstalled = () => { setInstalled(true); setDeferred(null); }; window.addEventListener('beforeinstallprompt', onPrompt); window.addEventListener('appinstalled', onInstalled); return () => { window.removeEventListener('beforeinstallprompt', onPrompt); window.removeEventListener('appinstalled', onInstalled); }; }, []); const dismiss = () => { try { localStorage.setItem('cg-install-dismiss-day', new Date().toISOString().slice(0, 10)); } catch {} setDismissed(true); }; const install = async () => { if (!deferred) return; deferred.prompt(); try { await deferred.userChoice; } catch {} setDeferred(null); }; if (installed || dismissed) return null; if (!deferred && !iosHint) return null; return (
Install CalGrillz
{iosHint ? <>Tap Share then Add to Home Screen. : 'One tap to add it to your home screen.'}
{deferred && ( )}
); } // ─── Money formatter ──────────────────────────────────────────────── function nairaW(n) { return '₦' + Math.round(n).toLocaleString('en-NG'); } // ─── Menu data (shared across pages) ──────────────────────────────── // EMPTY on purpose. The real menu is always injected by the backend // (window.CG_DATA.menu = cg_public_menu()) on every live page, so this fallback // is never used there. It used to hold a dozen fake sample dishes with // hardcoded stock photos (whole-tilapia/grill-closeup.jpg, suya-platter, chapman // jug, …) which could surface as fake items/pictures — removed. const CGW_MENU_FALLBACK = []; // Prefer the REAL menu injected by the backend (window.CG_DATA.menu) so prices // and items match what checkout actually charges. Fall back to the sample menu // above only for the standalone static preview (no backend data present). const CGW_MENU = (typeof window !== 'undefined' && window.CG_DATA && Array.isArray(window.CG_DATA.menu) && window.CG_DATA.menu.length) ? window.CG_DATA.menu : CGW_MENU_FALLBACK; const CGW_CATS = [ { id: 'all', label: 'All', count: CGW_MENU.length }, { id: 'fish', label: 'Fish', count: CGW_MENU.filter(m => m.cat === 'fish').length }, { id: 'chicken', label: 'Chicken & suya', count: CGW_MENU.filter(m => m.cat === 'chicken').length }, { id: 'shawarma', label: 'Shawarma', count: CGW_MENU.filter(m => m.cat === 'shawarma').length }, { id: 'parfait', label: 'Parfait', count: CGW_MENU.filter(m => m.cat === 'parfait').length }, { id: 'sides', label: 'Sides', count: CGW_MENU.filter(m => m.cat === 'sides').length }, { id: 'drinks', label: 'Drinks', count: CGW_MENU.filter(m => m.cat === 'drinks').length }, ].filter(c => c.id === 'all' || c.count > 0); // ─── Photo or placeholder helper ──────────────────────────────────── function CGWDishMedia({ item, height, tagPrefix = '' }) { if (item.img) { return (
); } // placeholder — guard against a missing slug/name so a data gap never crashes the card const code = (tagPrefix + String(item.slug || item.name || 'item').toUpperCase()).slice(0, 8); return (
{code}
SHOOT NEEDED {item.placeholder || 'Photo TBD'}
); } // ─── Shared chip ──────────────────────────────────────────────────── function CGWTag({ kind }) { if (kind === 'fire') return 🔥 GOING FAST; if (kind === 'signature') return ⭐ SIGNATURE; if (kind === 'vip') return 👑 VIP; if (kind === 'spicy') return 🌶 SPICY; return null; } // ════════════════════════════════════════════════════════════════════════ // VOUCHER CARD COMPONENTS — premium gift-card design system, shared across // the dashboard, voucher page, and purchase-success modal. Lifted from // voucher/vouchers.html so this is the single source of truth. // ════════════════════════════════════════════════════════════════════════ // Real scannable QR code, generated by qrcode-generator (loaded in layout.blade.php // as window.qrcode). The payload is the voucher redemption URL so any phone // camera can scan it and land on the right page. // // The `size` prop is retained for API compatibility with the previous FauxQR // (it used to control module count) but now only acts as a hint when the QR // library isn't loaded yet — we fall back to a simple deterministic pattern // so the card never renders empty. function CGFauxQR({ code = 'CG-VOUCHER', size = 21 }) { const data = React.useMemo(() => { // Always encode an absolute URL so a phone camera opens it directly. // Falls back to the bare code if window isn't ready (SSR-safe). if (typeof window === 'undefined') return code; const base = (window.CG_URLS && window.CG_URLS.voucher) || (window.location.origin + '/voucher'); const sep = base.includes('?') ? '&' : '?'; return `${base}${sep}code=${encodeURIComponent(code)}`; }, [code]); // Build the module grid via qrcode-generator. Type 0 = auto-pick the smallest // QR version that fits the data; 'M' = ~15% error-correction (still scans // even though we carve a small quiet zone for the centre logo). const matrix = React.useMemo(() => { if (typeof window === 'undefined' || !window.qrcode) return null; try { const qr = window.qrcode(0, 'M'); qr.addData(data); qr.make(); const count = qr.getModuleCount(); const grid = new Array(count * count); for (let r = 0; r < count; r++) { for (let c = 0; c < count; c++) { grid[r * count + c] = qr.isDark(r, c); } } return { count, grid }; } catch (e) { return null; } }, [data]); // Fallback deterministic pattern — only used while qrcode-generator loads. const fallback = React.useMemo(() => { if (matrix) return null; const n = size * size; const grid = new Array(n).fill(false); let h = 2166136261; for (let i = 0; i < code.length; i++) { h ^= code.charCodeAt(i); h = Math.imul(h, 16777619); } for (let i = 0; i < n; i++) { h ^= h << 13; h ^= h >>> 17; h ^= h << 5; grid[i] = ((h >>> 0) & 1) === 1; } const stamp = (cx, cy) => { for (let y = 0; y < 7; y++) for (let x = 0; x < 7; x++) { const onEdge = x === 0 || y === 0 || x === 6 || y === 6; const inner = x >= 2 && x <= 4 && y >= 2 && y <= 4; const ring = (x === 1 || y === 1 || x === 5 || y === 5); const on = onEdge || inner; const off = ring && !onEdge && !inner; grid[(cy + y) * size + (cx + x)] = on && !off; } }; stamp(0, 0); stamp(size - 7, 0); stamp(0, size - 7); return { count: size, grid }; }, [matrix, code, size]); const m = matrix || fallback; const count = m.count; const cell = 100 / count; return ( {m.grid.map((on, i) => on ? ( ) : null)} ); } // Strip a trailing "off" / "OFF" (with optional " any order") from a value // string so it doesn't render twice — the card already shows a dedicated // "OFF / any order" badge column next to the big number. function stripOff(s) { if (!s) return s; return String(s).replace(/\s*off(\s+any\s+order)?\s*$/i, '').trim(); } // Full-size gift-card display. Props mirror the API: // state: 'available' | 'redeemed' | 'revoked' // amount: pre-formatted display string (e.g. '₦5,000' or '15% off') // — a trailing "off" is stripped so it doesn't double up with // the card's built-in OFF badge. // code: monospace voucher code // from: optional gifter name // issuedDate / expires: pre-formatted date strings function CGVoucherCard({ state = 'available', pending = false, amount, code, from, expires, issuedDate, refEl, copyable = true }) { const cleanAmount = stripOff(amount); const [copied, setCopied] = React.useState(false); function copy() { if (!copyable || state !== 'available') return; if (navigator.clipboard) navigator.clipboard.writeText(code); setCopied(true); setTimeout(() => setCopied(false), 1500); } return (
{state === 'available' && !pending &&
Active
} {pending &&
Pending
} {state === 'available' &&
} {state === 'available' && (
{Array.from({ length: 6 }).map((_, i) => ( ))}
)}
{state === 'redeemed' &&
Redeemed
} {state === 'revoked' &&
Revoked
}
{pending ? 'Pending payment' : state === 'available' ? 'Active' : state === 'redeemed' ? 'Redeemed' : 'Revoked'}
Gift voucher{from ? · from {from} : null}
{cleanAmount} OFF any order
{code}
{issuedDate && Issued {issuedDate}} {expires && {state === 'available' ? <>Expires {expires} : state === 'redeemed' ? <>Redeemed {expires} : <>Revoked {expires}}}
Scan to redeem
); } // Compact row variant for wallet lists / dashboards. function CGVoucherMini({ state = 'avail', amount, code, sub }) { return (
{stripOff(amount)}
{code}
{sub &&
{sub}
}
{state === 'avail' ? 'Active' : state === 'redeemed' ? 'Used' : 'Revoked'}
); } // Download toolbar — captures a referenced VoucherCard DOM node and emits // PNG, JPEG, or PDF. Relies on html2canvas + jsPDF (loaded from CDN by the // Blade layout). Falls back gracefully if either library isn't available. function CGVoucherDownloads({ targetRef, fileBase = 'voucher' }) { const [busy, setBusy] = React.useState(null); // 'png' | 'jpeg' | 'pdf' | null // ─── Export-DOM builder ────────────────────────────────────────────────── // Trying to make html2canvas render the live `.vc` element kept hitting // walls — the gradient-border `::before` mask-composite, the `border-image` // ring, and `backdrop-filter`-blurred pills are all unrenderable, and CSS // overrides in onclone proved unreliable. So instead of fighting cloned // stylesheets, we extract the data from the live card (amount, code, // expiry, etc.) and build a brand-new offscreen DOM that uses ONLY inline // styles and html2canvas-safe primitives. html2canvas captures that, then // we throw it away. The live page is untouched. function buildExportCard() { const live = targetRef.current; if (!live) throw new Error('no live card to capture'); // Pull the data straight from the live DOM so we don't need new props. const state = live.classList.contains('vc--available') ? 'available' : live.classList.contains('vc--redeemed') ? 'redeemed' : live.classList.contains('vc--revoked') ? 'revoked' : 'available'; const txt = (sel) => { const el = live.querySelector(sel); return el ? el.textContent.trim() : ''; }; const amount = txt('.vc__amount'); const code = txt('.vc__code-val'); const typeRaw = txt('.vc__type'); const metaSpans = live.querySelectorAll('.vc__meta span'); const issuedTxt = metaSpans[0] ? metaSpans[0].textContent.trim() : ''; const expiresTxt = (metaSpans.length > 1 ? metaSpans[metaSpans.length - 1] : metaSpans[0]) ? (metaSpans[metaSpans.length - 1] || metaSpans[0]).textContent.trim() : ''; const liveQrSvg = live.querySelector('.vc__qr svg'); const qrSvgHtml = liveQrSvg ? liveQrSvg.outerHTML : ''; const logoSrc = (live.querySelector('.vc__brand img') || {}).src || ((window.CG_ASSET_BASE || 'assets') + '/calgrillz-logo.png'); // Colour palette per state — concrete hex / rgba only. const palette = state === 'redeemed' ? { bg: 'linear-gradient(135deg, #1a1612 0%, #13100d 100%)', border: '1px solid #322a23', amount: '#bcaa90', off: '#8c7f70', type: '#8c7f70', sealText: 'Used', sealBg: '#8c7f70', sealFg: '#13100d', statusBg: '#2a2620', statusFg: '#8c7f70', statusBorder: 'rgba(140,127,112,0.3)', codeBg: 'rgba(0,0,0,0.3)', codeBorder: '1px dashed rgba(140,127,112,0.3)', codeFg: '#8c7f70', meta: '#bcaa90', } : state === 'revoked' ? { bg: 'linear-gradient(135deg, #1a0e0c 0%, #0f0808 100%)', border: '1.5px solid rgba(216,99,44,0.45)', amount: '#d8632c', off: '#d8632c', type: '#d8632c', sealText: 'Cancelled', sealBg: '#d8632c', sealFg: '#13100d', statusBg: '#3a201a', statusFg: '#d8632c', statusBorder: 'rgba(216,99,44,0.45)', codeBg: 'rgba(0,0,0,0.45)', codeBorder: '1px dashed rgba(216,99,44,0.4)', codeFg: '#d8632c', meta: '#d8632c', } : { bg: 'radial-gradient(ellipse at top right, rgba(216,99,44,0.35) 0%, transparent 55%),'+ 'radial-gradient(ellipse at bottom left, rgba(200,16,46,0.25) 0%, transparent 55%),'+ 'linear-gradient(135deg, #2a1410 0%, #1a0a08 50%, #0a0a0a 100%)', border: '1.5px solid #d8632c', amount: '#f5ecdc', off: '#d8632c', type: '#d4a02e', sealText: 'Active', sealBg: '#22c55e', sealFg: '#0a0a0a', statusBg: '#1d3329', statusFg: '#6fc18b', statusBorder: 'rgba(95,163,119,0.4)', codeBg: 'rgba(0,0,0,0.4)', codeBorder: '1px dashed rgba(212,160,46,0.3)', codeFg: '#f5ecdc', meta: '#d4a02e', }; // 5:3 aspect, generous size for crisp downscale. const W = 720, H = Math.round(W * 3 / 5); const card = document.createElement('div'); card.style.cssText = [ `width:${W}px`, `height:${H}px`, 'position:fixed', 'left:-10000px', 'top:0', 'z-index:-1', `background:${palette.bg}`, `border:${palette.border}`, 'border-radius:24px', `box-shadow:0 24px 70px -22px rgba(200,16,46,0.55)`, "font-family:'Inter Tight', system-ui, sans-serif", `color:${palette.amount}`, 'box-sizing:border-box', 'padding:32px 36px', 'display:flex', 'flex-direction:column', 'justify-content:space-between', 'overflow:hidden', ].join(';'); // Active / Used / Cancelled corner banner. const seal = document.createElement('div'); seal.textContent = palette.sealText.toUpperCase(); seal.style.cssText = [ 'position:absolute', 'top:18px', 'left:-44px', 'transform:rotate(-45deg)', 'transform-origin:center', `background:${palette.sealBg}`, `color:${palette.sealFg}`, 'font-size:11px', 'font-weight:800', 'letter-spacing:0.18em', 'padding:6px 60px', 'text-align:center', 'box-shadow:0 2px 6px rgba(0,0,0,0.4)', ].join(';'); // Status pill (top-right). const status = document.createElement('div'); status.style.cssText = [ 'position:absolute', 'top:24px', 'right:32px', `background:${palette.statusBg}`, `color:${palette.statusFg}`, `border:1px solid ${palette.statusBorder}`, 'padding:7px 14px', 'border-radius:999px', 'font-size:11px', 'font-weight:700', 'letter-spacing:0.14em', 'display:flex', 'align-items:center', 'gap:8px', ].join(';'); const dot = document.createElement('span'); dot.style.cssText = `width:7px;height:7px;border-radius:50%;background:${palette.statusFg};display:inline-block`; status.appendChild(dot); status.appendChild(document.createTextNode(palette.sealText.toUpperCase())); // Header row: logo + (status sits absolutely above this). const header = document.createElement('div'); header.style.cssText = 'display:flex;align-items:center;gap:12px;margin-top:8px'; const logo = document.createElement('img'); logo.src = logoSrc; logo.crossOrigin = 'anonymous'; logo.style.cssText = 'height:36px;width:auto;display:block'; header.appendChild(logo); // Main row: amount + OFF / any order + QR (right side). const main = document.createElement('div'); main.style.cssText = 'display:flex;align-items:flex-end;gap:24px;flex:1'; const left = document.createElement('div'); left.style.cssText = 'flex:1;display:flex;flex-direction:column;gap:14px'; // "Gift voucher" eyebrow. const eyebrow = document.createElement('div'); eyebrow.textContent = typeRaw || 'Gift voucher'; eyebrow.style.cssText = `font-size:11px;font-weight:700;letter-spacing:0.22em;text-transform:uppercase;color:${palette.type};line-height:1`; // Amount + OFF column. const amtRow = document.createElement('div'); amtRow.style.cssText = 'display:flex;align-items:flex-end;gap:18px'; const amt = document.createElement('span'); amt.textContent = stripOff(amount); amt.style.cssText = `font-family:'DM Serif Display', Georgia, serif;font-size:72px;line-height:0.9;letter-spacing:-0.015em;color:${palette.amount}`; const meta = document.createElement('div'); meta.style.cssText = 'display:flex;flex-direction:column;gap:4px;padding-bottom:10px'; const off = document.createElement('span'); off.textContent = 'OFF'; off.style.cssText = `font-size:22px;font-weight:800;letter-spacing:0.06em;color:${palette.off};line-height:1`; const sub = document.createElement('span'); sub.textContent = 'ANY ORDER'; sub.style.cssText = `font-size:10px;font-weight:600;letter-spacing:0.22em;color:#d4c4ad;line-height:1`; meta.appendChild(off); meta.appendChild(sub); amtRow.appendChild(amt); amtRow.appendChild(meta); // Code pill. const codeRow = document.createElement('div'); codeRow.style.cssText = [ 'display:flex','align-items:center','justify-content:space-between', `background:${palette.codeBg}`, `border:${palette.codeBorder}`, 'border-radius:12px','padding:12px 18px','margin-top:4px', ].join(';'); const codeVal = document.createElement('span'); codeVal.textContent = code; codeVal.style.cssText = `font-family:'JetBrains Mono', ui-monospace, monospace;font-size:16px;letter-spacing:0.1em;font-weight:500;color:${palette.codeFg}`; const codeTag = document.createElement('span'); codeTag.textContent = state === 'available' ? 'Tap to copy' : state === 'redeemed' ? 'Used' : 'Cancelled'; codeTag.style.cssText = `font-size:11px;font-weight:700;letter-spacing:0.12em;text-transform:uppercase;color:${palette.off}`; codeRow.appendChild(codeVal); codeRow.appendChild(codeTag); // Issued / expires meta (bottom strip). const dates = document.createElement('div'); dates.style.cssText = `display:flex;gap:18px;font-size:11px;color:${palette.statusFg};margin-top:2px;letter-spacing:0.04em`; if (issuedTxt) { const a = document.createElement('span'); a.textContent = issuedTxt; a.style.color = palette.meta; dates.appendChild(a); } if (expiresTxt && expiresTxt !== issuedTxt) { const b = document.createElement('span'); b.textContent = expiresTxt; b.style.color = palette.meta; dates.appendChild(b); } left.appendChild(eyebrow); left.appendChild(amtRow); left.appendChild(codeRow); left.appendChild(dates); // Right: QR + label. const right = document.createElement('div'); right.style.cssText = 'display:flex;flex-direction:column;align-items:center;gap:10px'; const qrFrame = document.createElement('div'); qrFrame.style.cssText = [ 'width:128px','height:128px', 'background:#ffffff', 'border-radius:14px', 'padding:8px','box-sizing:border-box', 'border:2px solid #d4a02e', 'display:flex','align-items:center','justify-content:center', ].join(';'); if (qrSvgHtml) { const wrap = document.createElement('div'); wrap.style.cssText = 'width:100%;height:100%;display:flex;align-items:center;justify-content:center'; wrap.innerHTML = qrSvgHtml; const innerSvg = wrap.querySelector('svg'); if (innerSvg) { innerSvg.setAttribute('width', '100%'); innerSvg.setAttribute('height', '100%'); innerSvg.style.display = 'block'; } qrFrame.appendChild(wrap); } const qrLabel = document.createElement('div'); qrLabel.textContent = 'SCAN TO REDEEM'; qrLabel.style.cssText = `font-size:10px;font-weight:700;letter-spacing:0.18em;color:${palette.statusFg}`; right.appendChild(qrFrame); right.appendChild(qrLabel); main.appendChild(left); main.appendChild(right); card.appendChild(seal); card.appendChild(status); card.appendChild(header); card.appendChild(main); document.body.appendChild(card); return card; } // Build the export DOM, capture it with html2canvas, then remove it. async function render() { if (!window.html2canvas) throw new Error('html2canvas not loaded'); if (!targetRef?.current) throw new Error('no card to capture'); // Wait for web fonts so the amount glyph isn't substituted in the snapshot. if (document.fonts && document.fonts.ready) { try { await document.fonts.ready; } catch (e) { /* not fatal */ } } const card = buildExportCard(); // Give the browser a paint tick so the image loads + layout settles. await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))); try { return await window.html2canvas(card, { backgroundColor: null, scale: 2, useCORS: true, logging: false, allowTaint: false, }); } finally { // Always clean up, even if html2canvas threw. if (card.parentNode) card.parentNode.removeChild(card); } } async function downloadImage(format) { setBusy(format); try { const canvas = await render(); const mime = format === 'jpeg' ? 'image/jpeg' : 'image/png'; const quality = format === 'jpeg' ? 0.92 : undefined; const link = document.createElement('a'); link.download = `${fileBase}.${format}`; link.href = canvas.toDataURL(mime, quality); link.click(); } catch (e) { alert('Could not render the card: ' + e.message); } finally { setBusy(null); } } async function downloadPdf() { setBusy('pdf'); try { const canvas = await render(); const img = canvas.toDataURL('image/png'); // jsPDF lives at window.jspdf.jsPDF when loaded from the UMD bundle const JsPDF = (window.jspdf && window.jspdf.jsPDF) || window.jsPDF; if (!JsPDF) throw new Error('jsPDF not loaded'); // 5:3 aspect ratio → use a landscape page sized to the card const wMm = 148, hMm = wMm * 3 / 5; const pdf = new JsPDF({ orientation: 'landscape', unit: 'mm', format: [wMm, hMm] }); pdf.addImage(img, 'PNG', 0, 0, wMm, hMm); pdf.save(`${fileBase}.pdf`); } catch (e) { alert('Could not generate PDF: ' + e.message); } finally { setBusy(null); } } return (
); } // ════════════════════════════════════════════════════════════════════════ // CART STORE — single source of truth, backed by localStorage so the cart // state survives page navigation between menu / item / cart / checkout. // Pages subscribe via the `cgCart:changed` window event. // ════════════════════════════════════════════════════════════════════════ const CGCart = (() => { const KEY = 'cg_cart_v1'; function read() { try { const raw = localStorage.getItem(KEY); const parsed = raw ? JSON.parse(raw) : []; return Array.isArray(parsed) ? parsed : []; } catch (e) { return []; } } function write(items) { try { localStorage.setItem(KEY, JSON.stringify(items)); } catch (e) { /* private mode etc */ } window.dispatchEvent(new CustomEvent('cgCart:changed', { detail: items })); } function get() { return read(); } function count() { return read().reduce((s, x) => s + (x.qty || 0), 0); } function subtotal() { return read().reduce((s, x) => s + (x.price || 0) * (x.qty || 0), 0); } function add(item) { if (!item || !item.slug) return; // Pixel/analytics — every add on the whole site funnels through here. try { const qty = item.qty || 1; window.CG_TRACK && window.CG_TRACK.event('AddToCart', { content_ids: [item.slug], content_name: item.name || item.slug, content_type: 'product', value: (item.price || 0) * qty, currency: 'NGN', num_items: qty, }); } catch (e) {} const items = read(); const variantKey = item.variant || ''; const existing = items.find(x => x.slug === item.slug && (x.variant || '') === variantKey); if (existing) { existing.qty = (existing.qty || 1) + (item.qty || 1); } else { items.push({ id: Date.now() + Math.random(), slug: item.slug, name: item.name || item.slug, variant: item.variant || '', sides: item.sides || [], qty: item.qty || 1, price: item.price || 0, img: item.img || null, }); } write(items); } function setQty(id, qty) { const items = read(); const x = items.find(it => it.id === id); if (!x) return; x.qty = Math.max(1, qty); write(items); } function inc(id) { const x = read().find(it => it.id === id); if (x) setQty(id, (x.qty || 1) + 1); } function dec(id) { const x = read().find(it => it.id === id); if (x) setQty(id, Math.max(1, (x.qty || 1) - 1)); } function remove(id) { write(read().filter(it => it.id !== id)); } function clear() { write([]); } // Cross-tab sync: storage events fire when another tab edits the cart. window.addEventListener('storage', (e) => { if (e.key === KEY) window.dispatchEvent(new CustomEvent('cgCart:changed', { detail: read() })); }); return { get, count, subtotal, add, setQty, inc, dec, remove, clear }; })(); // React hook — subscribes to the cart and re-renders on change. function useCart() { const [items, setItems] = React.useState(() => CGCart.get()); React.useEffect(() => { const handler = (e) => setItems(e.detail || CGCart.get()); window.addEventListener('cgCart:changed', handler); return () => window.removeEventListener('cgCart:changed', handler); }, []); return { items, count: items.reduce((s, x) => s + (x.qty || 0), 0), subtotal: items.reduce((s, x) => s + (x.price || 0) * (x.qty || 0), 0), add: CGCart.add, setQty: CGCart.setQty, inc: CGCart.inc, dec: CGCart.dec, remove: CGCart.remove, clear: CGCart.clear, }; } // ════════════════════════════════════════════════════════════════════════ // WISHLIST STORE — items a customer saves for later. Same localStorage + // event pattern as the cart, so it persists on the device and every page // (menu cards, saved strip, header count) stays in sync. Stores just enough // to render + add-to-cart later: slug, name, priceFrom, img. // ════════════════════════════════════════════════════════════════════════ const CGWish = (() => { const KEY = 'cg_wish_v1'; function read() { try { const raw = localStorage.getItem(KEY); const p = raw ? JSON.parse(raw) : []; return Array.isArray(p) ? p : []; } catch (e) { return []; } } function write(items) { try { localStorage.setItem(KEY, JSON.stringify(items)); } catch (e) { /* private mode */ } window.dispatchEvent(new CustomEvent('cgWish:changed', { detail: items })); } function get() { return read(); } function has(slug) { return read().some(x => x.slug === slug); } function toggle(item) { if (!item || !item.slug) return; const items = read(); const i = items.findIndex(x => x.slug === item.slug); if (i >= 0) items.splice(i, 1); else items.push({ slug: item.slug, name: item.name || item.slug, priceFrom: item.priceFrom || 0, img: item.img || null }); write(items); } function remove(slug) { write(read().filter(x => x.slug !== slug)); } function clear() { write([]); } window.addEventListener('storage', (e) => { if (e.key === KEY) window.dispatchEvent(new CustomEvent('cgWish:changed', { detail: read() })); }); return { get, has, toggle, remove, clear }; })(); // React hook — subscribes to the wishlist and re-renders on change. function useWish() { const [items, setItems] = React.useState(() => CGWish.get()); React.useEffect(() => { const handler = (e) => setItems(e.detail || CGWish.get()); window.addEventListener('cgWish:changed', handler); return () => window.removeEventListener('cgWish:changed', handler); }, []); return { items, count: items.length, has: (slug) => items.some(x => x.slug === slug), toggle: CGWish.toggle, remove: CGWish.remove, clear: CGWish.clear, }; } // ════════════════════════════════════════════════════════════════════════ // VOUCHER STORE — applied vouchers survive page navigation between // /cart and /checkout so the discount preview stays in sync. // // computeDiscount() mirrors app/Models/Voucher.php (effectiveMinOrderKobo + // discountFor) so the client-side preview matches what OrderService applies // at order commit. Money-typed vouchers require subtotal ≥ a fraction of the // face value (kept as a quiet implementation detail — never surfaced in copy). // ════════════════════════════════════════════════════════════════════════ const CG_VOUCHER_FLOOR = 0.85; function cgVoucherDiscount(voucher, remainingSubtotal) { if (!voucher || remainingSubtotal <= 0) return 0; const percent = Number(voucher.percent) || 0; if (voucher.type === 'percentage' && percent > 0) { return Math.floor(remainingSubtotal * (percent / 100)); } const value = Number(voucher.value) || 0; if (value <= 0) return 0; const minOrder = Math.ceil(value * CG_VOUCHER_FLOOR); if (remainingSubtotal < minOrder) return 0; return Math.min(value, remainingSubtotal); } // Run a list of applied vouchers against a subtotal and return both the // total discount and a per-voucher breakdown ({ applied: kobo }) so the UI // can subtly grey out an idle voucher without spelling out why. function cgComputeVouchers(applied, subtotal) { let remaining = subtotal; const lines = []; for (const v of (applied || [])) { const d = cgVoucherDiscount(v, remaining); lines.push({ ...v, applied: d }); remaining = Math.max(0, remaining - d); } return { discount: subtotal - remaining, lines }; } // Live server-side voucher validation — called the moment a code is typed on // the cart OR checkout. Runs the real ownership/expiry/status/min-order rules // server-side (mirrors what OrderService re-runs at commit), so an invalid, // expired, used, or someone-else's code is rejected UP FRONT instead of being // accepted blindly (value 0) and only failing at place-order. On success it // returns the canonical voucher normalized to the store shape (money value in // naira, percent 0–100), identical to how CG_DATA.ownedVouchers is normalized. async function cgValidateVoucher(code, subtotalNaira, appliedCodes) { const url = window.CG_URLS && window.CG_URLS.validateVoucher; if (!url) return { ok: false, error: 'Voucher check is unavailable right now.' }; try { const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-CSRF-TOKEN': (window.CG_URLS && window.CG_URLS.csrf) || '', 'X-Requested-With': 'XMLHttpRequest', }, credentials: 'same-origin', body: JSON.stringify({ code: String(code || '').trim().toUpperCase(), subtotal_kobo: Math.max(0, Math.round((Number(subtotalNaira) || 0) * 100)), applied_codes: (appliedCodes || []).map(c => String(c).toUpperCase()), }), }); const data = await res.json().catch(() => ({})); if (!res.ok || !data.ok) { return { ok: false, error: (data && data.error) || 'Could not check that voucher.', needsLogin: !!(data && data.needsLogin) }; } const v = data.voucher || {}; const isPct = v.type === 'percentage'; return { ok: true, voucher: { code: v.code, type: v.type || 'fixed', value: isPct ? 0 : Math.round((Number(v.value) || 0) / 100), // kobo → naira percent: isPct ? Number(v.value) || 0 : 0, label: v.label || v.code, discountNaira: Number(v.discount_naira) || 0, }, }; } catch (e) { return { ok: false, error: 'Could not reach the server to check that voucher. Try again.' }; } } const CGVouchers = (() => { const KEY = 'cg_vouchers_v1'; function read() { try { const raw = localStorage.getItem(KEY); const parsed = raw ? JSON.parse(raw) : []; return Array.isArray(parsed) ? parsed : []; } catch (e) { return []; } } function write(list) { try { localStorage.setItem(KEY, JSON.stringify(list)); } catch (e) { /* private mode */ } window.dispatchEvent(new CustomEvent('cgVouchers:changed', { detail: list })); } function get() { return read(); } function apply(voucher) { if (!voucher || !voucher.code) return; const list = read(); if (list.find(v => v.code === voucher.code)) return; write([...list, { code: voucher.code, value: Number(voucher.value) || 0, type: voucher.type || 'fixed', percent: Number(voucher.percent) || 0, label: voucher.label || voucher.code, }]); } function remove(code) { write(read().filter(v => v.code !== code)); } function clear() { write([]); } window.addEventListener('storage', (e) => { if (e.key === KEY) window.dispatchEvent(new CustomEvent('cgVouchers:changed', { detail: read() })); }); return { get, apply, remove, clear }; })(); function useVouchers() { const [list, setList] = React.useState(() => CGVouchers.get()); React.useEffect(() => { const handler = (e) => setList(e.detail || CGVouchers.get()); window.addEventListener('cgVouchers:changed', handler); return () => window.removeEventListener('cgVouchers:changed', handler); }, []); return { list, apply: CGVouchers.apply, remove: CGVouchers.remove, clear: CGVouchers.clear, }; } // ════════════════════════════════════════════════════════════════════════ // ADDRESS BOOK — saved delivery addresses, shared by checkout + profile. // localStorage-backed (key: cg_addresses_v1) so additions survive refresh // and stay in sync between tabs. // // Starts EMPTY — a new customer has no saved addresses and is prompted to // add their real one at checkout. (We used to seed two demo addresses here, // "Plot 14, Ekei Crescent" / "Old Lagos Rd, Block C", which showed up as // fake "saved" addresses for every real user — removed.) // // Shape: { id, label, sub, isDefault? } // ════════════════════════════════════════════════════════════════════════ const CG_DEFAULT_ADDRESSES = []; const CGAddresses = (() => { // v2: bumped from v1 to flush the old demo seed addresses // ("Plot 14, Ekei Crescent" / "Old Lagos Rd, Block C") that returning // visitors still had cached in localStorage. const KEY = 'cg_addresses_v2'; function read() { try { const raw = localStorage.getItem(KEY); const parsed = raw ? JSON.parse(raw) : null; if (Array.isArray(parsed) && parsed.length) return parsed; } catch (e) { /* fall through */ } return CG_DEFAULT_ADDRESSES.slice(); } function write(list) { try { localStorage.setItem(KEY, JSON.stringify(list)); } catch (e) { /* private mode */ } window.dispatchEvent(new CustomEvent('cgAddresses:changed', { detail: list })); } function get() { return read(); } function add({ label, sub }) { const cleanLabel = String(label || '').trim(); if (!cleanLabel) return null; const id = 'a' + Date.now() + Math.floor(Math.random() * 100); const next = [...read(), { id, label: cleanLabel, sub: String(sub || '').trim() || 'New address' }]; write(next); return id; } function update(id, patch) { const list = read(); const idx = list.findIndex(a => a.id === id); if (idx < 0) return; list[idx] = { ...list[idx], ...patch }; write(list); } function remove(id) { const a = read().find(x => x.id === id); if (a && a.isSeed) return; // seeds are immutable write(read().filter(x => x.id !== id)); } function clear() { write(CG_DEFAULT_ADDRESSES.slice()); } window.addEventListener('storage', (e) => { if (e.key === KEY) window.dispatchEvent(new CustomEvent('cgAddresses:changed', { detail: read() })); }); return { get, add, update, remove, clear }; })(); function useAddresses() { const [list, setList] = React.useState(() => CGAddresses.get()); React.useEffect(() => { const handler = (e) => setList(e.detail || CGAddresses.get()); window.addEventListener('cgAddresses:changed', handler); return () => window.removeEventListener('cgAddresses:changed', handler); }, []); return { list, add: CGAddresses.add, update: CGAddresses.update, remove: CGAddresses.remove, clear: CGAddresses.clear, }; } // Export Object.assign(window, { CGWHeader, CGWFooter, CGWTicker, CGWPageHero, CGWPage, CGWMobileTabBar, CGSocialIcon, CGSocialLinks, CGW_MENU, CGW_CATS, CGWDishMedia, CGWTag, nairaW, CG_URL, CG_ASSET, CGFauxQR, CGVoucherCard, CGVoucherMini, CGVoucherDownloads, CGCart, useCart, CGVouchers, useVouchers, cgVoucherDiscount, cgComputeVouchers, CGAddresses, useAddresses, cgSignOut, });