// 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 (
);
}
// ─── 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.
);
}
// ─── 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 (
);
}
// 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 (
{/* 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 (
);
}
// ─── 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) => (
);
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 (
);
}
// 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 (