// cart.jsx — Cart, Checkout, Confirmation. Multi-product-ready.
// Loaded after sections-*.jsx, before main.jsx. Exposes CartProvider, useCart, CartButton,
// CartDrawer, CheckoutOverlay, OrderConfirmation, PRODUCTS, PICKUP_SLOTS, PAYMENT_METHODS.
const { createContext: rdbCreateContext, useContext: rdbUseContext } = React;
// ─── CONFIG — edit here to add products, slots, payment methods ──────────────
// To add a new product later, append an entry. tiers[] is the size/price ladder
// for that product. The cart understands any tier from any product.
const PRODUCTS = {
'real-date-bar': {
id: 'real-date-bar',
name: 'Real Date Bar',
blurb: 'Organic dates + organic cashews. Two ingredients.',
image: 'uploads/DSC02390.jpeg',
tiers: [
{ id: 'single', label: 'Single bar', qty: 1, price: 5 },
{ id: 'five', label: '5-pack', qty: 5, price: 22, featured: true },
{ id: 'ten', label: '10-pack', qty: 10, price: 40 },
],
},
// FUTURE PRODUCT example — uncomment & rename to add a 2nd bar:
// 'chocolate-date-bar': {
// id: 'chocolate-date-bar', name: 'Chocolate Date Bar',
// blurb: 'Dates + cashews dipped in dark chocolate.',
// image: 'uploads/chocolate.jpeg',
// tiers: [
// { id: 'single', label: 'Single bar', qty: 1, price: 6 },
// { id: 'five', label: '5-pack', qty: 5, price: 26, featured: true },
// ],
// },
};
// Weekly recurring pickup windows. Ela picks the actual date with each customer.
// To add/remove slots, edit this list and re-upload cart.jsx.
const PICKUP_SLOTS = [
{ id: 'sat-am', label: 'Saturday morning · 9–11 AM' },
{ id: 'sat-pm', label: 'Saturday afternoon · 1–3 PM' },
{ id: 'sat-eve', label: 'Saturday evening · 4–6 PM' },
{ id: 'sun-am', label: 'Sunday morning · 10 AM–12 PM' },
{ id: 'sun-pm', label: 'Sunday afternoon · 2–4 PM' },
{ id: 'flexible', label: "I'll work out a time with Ela" },
];
const PAYMENT_METHODS = ['Venmo', 'Zelle', 'Apple Pay', 'PayPal', 'Cash'];
// ─── Order number generator ──────────────────────────────────────────────────
// RDB-YYYYMMDD-XXXX where XXXX is 4 unambiguous uppercase chars (no 0/O/1/I).
function makeOrderNumber() {
const d = new Date();
const ymd = `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, '0')}${String(d.getDate()).padStart(2, '0')}`;
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
let suffix = '';
for (let i = 0; i < 4; i++) suffix += chars[Math.floor(Math.random() * chars.length)];
return `RDB-${ymd}-${suffix}`;
}
// ─── Cart context ────────────────────────────────────────────────────────────
const CART_KEY = 'rdb_cart_v1';
const CartContext = rdbCreateContext(null);
function CartProvider({ children }) {
const [items, setItems] = React.useState(() => {
try { return JSON.parse(localStorage.getItem(CART_KEY) || '[]') || []; }
catch { return []; }
});
const [drawerOpen, setDrawerOpen] = React.useState(false);
const [checkoutOpen, setCheckoutOpen] = React.useState(false);
const [confirmation, setConfirmation] = React.useState(null);
React.useEffect(() => {
try { localStorage.setItem(CART_KEY, JSON.stringify(items)); } catch {}
}, [items]);
// Sanitize: drop any line that no longer matches a known product/tier
React.useEffect(() => {
const filtered = items.filter(it => {
const p = PRODUCTS[it.productId];
return !!(p && p.tiers.some(t => t.id === it.tierId));
});
if (filtered.length !== items.length) setItems(filtered);
}, []); // run once on mount
const addItem = (productId, tierId, qty = 1) => {
setItems(prev => {
const idx = prev.findIndex(it => it.productId === productId && it.tierId === tierId);
if (idx >= 0) {
const next = [...prev];
next[idx] = { ...next[idx], qty: next[idx].qty + qty };
return next;
}
return [...prev, { productId, tierId, qty }];
});
setDrawerOpen(true);
};
const setItemQty = (productId, tierId, qty) => {
setItems(prev => {
if (qty <= 0) return prev.filter(it => !(it.productId === productId && it.tierId === tierId));
return prev.map(it =>
(it.productId === productId && it.tierId === tierId) ? { ...it, qty } : it
);
});
};
const removeItem = (productId, tierId) => setItemQty(productId, tierId, 0);
const clearCart = () => setItems([]);
// Hydrate with product/tier details
const lines = items.map(it => {
const product = PRODUCTS[it.productId];
const tier = product && product.tiers.find(t => t.id === it.tierId);
if (!product || !tier) return null;
return {
productId: it.productId, tierId: it.tierId, qty: it.qty,
productName: product.name,
tierLabel: tier.label,
unitPrice: tier.price,
barsPerUnit: tier.qty,
lineTotal: tier.price * it.qty,
lineBars: tier.qty * it.qty,
};
}).filter(Boolean);
const itemCount = lines.reduce((s, l) => s + l.qty, 0);
const totalBars = lines.reduce((s, l) => s + l.lineBars, 0);
const subtotal = lines.reduce((s, l) => s + l.lineTotal, 0);
return (
{children}
);
}
const useCart = () => rdbUseContext(CartContext);
// ─── Cart icon button (for Nav) ──────────────────────────────────────────────
function CartButton() {
const cart = useCart();
if (!cart) return null;
const { itemCount, setDrawerOpen } = cart;
return (
);
}
// ─── Cart drawer ─────────────────────────────────────────────────────────────
const qtyBtnStyle = {
width: 34, height: 34, padding: 0, border: 0,
background: 'transparent', color: 'var(--ink)',
fontFamily: 'var(--display)', fontSize: 18, cursor: 'pointer',
};
function CartDrawer() {
const cart = useCart();
if (!cart) return null;
const { drawerOpen, setDrawerOpen, lines, subtotal, totalBars, setItemQty, removeItem, setCheckoutOpen } = cart;
React.useEffect(() => {
if (!drawerOpen) return;
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = prev; };
}, [drawerOpen]);
React.useEffect(() => {
if (!drawerOpen) return;
const on = (e) => { if (e.key === 'Escape') setDrawerOpen(false); };
window.addEventListener('keydown', on);
return () => window.removeEventListener('keydown', on);
}, [drawerOpen, setDrawerOpen]);
if (!drawerOpen) return null;
const goToCheckout = () => {
setDrawerOpen(false);
setCheckoutOpen(true);
if (window.location.hash !== '#checkout') {
history.pushState({ checkout: true }, '', '#checkout');
}
};
return (
setDrawerOpen(false)} style={{
position: 'fixed', inset: 0, zIndex: 90,
background: 'rgba(20, 12, 4, 0.42)',
backdropFilter: 'blur(4px)', WebkitBackdropFilter: 'blur(4px)',
animation: 'rdbFadeIn .18s ease',
}}>
);
}
// ─── Checkout overlay ────────────────────────────────────────────────────────
function CheckoutOverlay() {
const cart = useCart();
if (!cart) return null;
const { checkoutOpen, setCheckoutOpen, lines, subtotal, totalBars, clearCart,
confirmation, setConfirmation } = cart;
const [name, setName] = React.useState('');
const [email, setEmail] = React.useState('');
const [slot, setSlot] = React.useState('');
const [pay, setPay] = React.useState('Venmo');
const [notes, setNotes] = React.useState('');
const [submitting, setSubmitting] = React.useState(false);
const [err, setErr] = React.useState('');
const closeCheckout = React.useCallback(() => {
setCheckoutOpen(false);
setConfirmation(null);
setErr('');
if (window.location.hash === '#checkout') {
history.replaceState(null, '', window.location.pathname + window.location.search);
}
}, [setCheckoutOpen, setConfirmation]);
// Esc closes
React.useEffect(() => {
if (!checkoutOpen) return;
const on = (e) => { if (e.key === 'Escape' && !submitting) closeCheckout(); };
window.addEventListener('keydown', on);
return () => window.removeEventListener('keydown', on);
}, [checkoutOpen, submitting, closeCheckout]);
// Lock body scroll
React.useEffect(() => {
if (!checkoutOpen) return;
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = prev; };
}, [checkoutOpen]);
// Back button exits checkout but preserves cart
React.useEffect(() => {
if (!checkoutOpen) return;
const on = () => closeCheckout();
window.addEventListener('popstate', on);
return () => window.removeEventListener('popstate', on);
}, [checkoutOpen, closeCheckout]);
if (!checkoutOpen) return null;
// If we have a confirmation, render that instead of the form.
if (confirmation) {
return ;
}
// No items? Bail back to homepage.
if (lines.length === 0) {
return (
Checkout
Your cart is empty.
Add a pack first, then come back.
);
}
const submit = (e) => {
e.preventDefault();
if (!slot) { setErr('Please pick a pickup window.'); return; }
setSubmitting(true); setErr('');
const orderNumber = makeOrderNumber();
const slotLabel = (PICKUP_SLOTS.find(s => s.id === slot) || {}).label || slot;
const packLine = lines.map(l => `${l.qty}× ${l.tierLabel}`).join(' + ');
const payload = {
orderNumber,
// legacy fields preserved for back-compat with old order.php
name, contact: email, carrier: '',
pack: packLine,
price: String(subtotal),
payment: pay,
pickup: slotLabel,
notes: notes || '—',
// new structured fields
items: lines.map(l => ({
productId: l.productId, productName: l.productName,
tierId: l.tierId, tierLabel: l.tierLabel,
qty: l.qty, unitPrice: l.unitPrice, lineTotal: l.lineTotal, bars: l.lineBars,
})),
subtotal, totalBars,
pickupSlot: { id: slot, label: slotLabel },
};
fetch('/order.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
.then(r => r.json())
.then(d => {
if (d.success) {
setConfirmation({
orderNumber, lines: [...lines], subtotal, totalBars,
slotLabel, payment: pay, name, email,
});
clearCart();
setSubmitting(false);
} else {
setErr('Something went wrong. Please email ela@realdatebar.com directly.');
setSubmitting(false);
}
})
.catch(() => {
setErr('Network error. Please email ela@realdatebar.com directly.');
setSubmitting(false);
});
};
const inputStyle = {
width: '100%', padding: '14px 16px', borderRadius: 10,
border: '1px solid var(--line-strong)', background: 'var(--paper)',
fontFamily: 'var(--sans)', fontSize: 15, color: 'var(--ink)', outline: 'none',
};
const labelStyle = {
fontFamily: 'var(--mono)', fontSize: 11, letterSpacing: '.14em',
textTransform: 'uppercase', color: 'var(--ink-mute)',
display: 'block', marginBottom: 8,
};
return (
Real Date Bar
· Checkout
{/* Order summary */}
Order summary
{totalBars} bar{totalBars === 1 ? '' : 's'} · ${subtotal}
{lines.map(l => (
-
{l.productName}
{l.qty}× {l.tierLabel}
))}
Pickup only · 13030 Dolomite Drive, Frisco TX · Pay on pickup
{/* Form */}
);
}
const overlayStyle = {
position: 'fixed', inset: 0, zIndex: 95,
background: 'rgba(20, 12, 4, 0.6)',
backdropFilter: 'blur(6px)', WebkitBackdropFilter: 'blur(6px)',
display: 'flex', alignItems: 'stretch', justifyContent: 'center',
padding: 'clamp(0px, 2vw, 24px)',
animation: 'rdbFadeIn .2s ease',
};
const checkoutCardStyle = {
background: 'var(--bg)', width: '100%', maxWidth: 1100,
borderRadius: 'var(--radius-lg)', overflow: 'hidden',
border: '1px solid var(--line-strong)',
display: 'flex', flexDirection: 'column',
maxHeight: '100%',
boxShadow: '0 40px 80px rgba(20, 12, 4, 0.35)',
animation: 'rdbScaleIn .25s cubic-bezier(.2,.8,.2,1)',
};
// ─── Order confirmation ──────────────────────────────────────────────────────
function OrderConfirmation({ orderNumber, lines, subtotal, totalBars, slotLabel, payment, name, email, onClose }) {
return (
✓
Order confirmed
Thanks, {name.split(' ')[0] || 'friend'}.
We sent a receipt to {email}. Ela will reply within a few hours with your exact pickup time.
Order number
{orderNumber}
Pickup window
{slotLabel}
);
}
// ─── Expose ──────────────────────────────────────────────────────────────────
Object.assign(window, {
PRODUCTS, PICKUP_SLOTS, PAYMENT_METHODS,
CartProvider, useCart, CartButton, CartDrawer, CheckoutOverlay, OrderConfirmation,
makeOrderNumber,
});