// ══════════════════════════════════════════════════════════════════════════════ // GUSTAI LAB — SUBSCRIPTION SCREENS // lib/screens-subscription.jsx // // Screens: GLSubscribeOnboard | GLSubscriberDash | GLSwapBox | GLSubscribeConfirmed // Requires: design-system.jsx (GL, GLFonts, GLImage, GLButton, GLTabBar, GLTopBar, GL_WEEKLY_MENU) // ══════════════════════════════════════════════════════════════════════════════ // ── Shared helpers ──────────────────────────────────────────────────────────── function fmtThb(n) { return '฿' + Number(n).toLocaleString(); } function dayLabel(d) { return ['Mon','Tue','Wed','Thu','Fri','Sat','Sun'][d] || 'Thu'; } function msUntil(isoStr) { return new Date(isoStr).getTime() - Date.now(); } function fmtCountdown(ms) { if (ms <= 0) return 'Locked'; const h = Math.floor(ms / 3600000); const d = Math.floor(h / 24); const rh = h % 24; if (d > 0) return `${d}d ${rh}h`; const m = Math.floor((ms % 3600000) / 60000); return `${h}h ${m}m`; } function calcDropDates(deliveryDay, frequency) { const DNAMES = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun']; const today = new Date(); const todayIdx = (today.getDay() + 6) % 7; // 0=Mon let diff = deliveryDay - todayIdx; if (diff <= 0) diff += 7; const first = new Date(today); first.setDate(today.getDate() + diff); first.setHours(0, 0, 0, 0); const drops = []; for (let i = 0; i < 3; i++) { let d; if (frequency === 'monthly') { d = new Date(first); d.setMonth(d.getMonth() + i); } else { const gap = frequency === 'biweekly' ? 14 : 7; d = new Date(first); d.setDate(first.getDate() + gap * i); } drops.push(DNAMES[(d.getDay() + 6) % 7] + ' ' + d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })); } return drops; } // Badge chip for menu items function FeatureBadge({ badge }) { if (!badge) return null; const colors = { 'CHEF PICK': { bg: GL.teal, color: GL.ink }, 'NEW': { bg: '#A8E10C', color: GL.ink }, 'LIMITED': { bg: '#FF4D8D', color: '#fff' }, 'BESTSELLER': { bg: GL.ink, color: '#fff' }, 'CLASSIC': { bg: '#E8ECEC', color: GL.ink }, }; const style = colors[badge] || { bg: '#E8ECEC', color: GL.ink }; return ( {badge} ); } // Quantity stepper function QtyStepper({ qty, onInc, onDec, disabled }) { return (
{qty}
); } // Status chip for order cards function StatusChip({ status }) { const map = { upcoming: { bg: '#e4efff', color: '#1d4ed8', label: 'UPCOMING' }, locked: { bg: '#fff2d6', color: '#945200', label: 'LOCKED' }, billed: { bg: '#deffcd', color: '#1f5f04', label: 'BILLED' }, dispatched: { bg: '#ece6ff', color: '#5a3ad6', label: 'OUT' }, delivered: { bg: '#eef0f3', color: '#4a5160', label: 'DELIVERED'}, skipped: { bg: '#fde2e7', color: '#a61b30', label: 'SKIPPED' }, failed: { bg: '#fde2e7', color: '#a61b30', label: 'FAILED' }, }; const s = map[status] || map.upcoming; return ( {s.label} ); } // ══════════════════════════════════════════════════════════════════════════════ // SCREEN 1: GLSubscribeOnboard — 2-step onboarding wizard // (Thailand prepay model: items already chosen via shop mode → // Step 1: Schedule + Duration combined with live calendar preview // Step 2: Prepay for the full term via PromptPay QR.) // Per-product diet labels live on each item card (no wizard step). // Frequency caps total run at ~6 months: weekly 24 / biweekly 12 / monthly 6. // ══════════════════════════════════════════════════════════════════════════════ // Compute every drop date for the full subscription run function calcAllDropDates(deliveryDay, frequency, count) { const today = new Date(); const todayIdx = (today.getDay() + 6) % 7; // 0=Mon let diff = deliveryDay - todayIdx; if (diff <= 0) diff += 7; const first = new Date(today); first.setDate(today.getDate() + diff); first.setHours(0, 0, 0, 0); const drops = []; for (let i = 0; i < count; i++) { const d = new Date(first); if (frequency === 'monthly') d.setMonth(d.getMonth() + i); else if (frequency === 'biweekly') d.setDate(first.getDate() + 14 * i); else d.setDate(first.getDate() + 7 * i); drops.push(d); } return drops; } function fmtDateShort(d) { return d.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short' }); } function GLSubscribeOnboard({ go, app }) { const [step, setStep] = React.useState(0); const [freq, setFreq] = React.useState('weekly'); const [delDay, setDelDay] = React.useState(3); // Thu const [duration, setDuration] = React.useState(8); // # of deliveries const [paid, setPaid] = React.useState(false); // Items come from the global Subscribe-mode template const subItems = (app && app.subTemplate && app.subTemplate.length > 0) ? app.subTemplate.map(it => ({ ...it, qty: it.qty || 1 })) : []; const perBoxTotal = subItems.reduce((s, it) => s + (it.price || 0) * (it.qty || 1), 0); // Per-frequency tiers — cap each at ~6 months // weekly: 4/8/12/24 (max 24 wks ≈ 6mo) // biweekly: 4/8/12 (max 12 × 2wk = 24wks ≈ 6mo) // monthly: 2/4/6 (max 6 × 1mo = 6mo) const TIER_MAP = { weekly: [ { deliveries: 4, discount: 0, label: 'Starter', desc: '~1 month' }, { deliveries: 8, discount: 3, label: 'Regular', desc: '~2 months' }, { deliveries: 12, discount: 7, label: 'Committed', desc: '~3 months' }, { deliveries: 24, discount: 12, label: 'Best Value', desc: '~6 months' }, ], biweekly: [ { deliveries: 4, discount: 0, label: 'Starter', desc: '~2 months' }, { deliveries: 8, discount: 5, label: 'Regular', desc: '~4 months' }, { deliveries: 12, discount: 12, label: 'Best Value', desc: '~6 months' }, ], monthly: [ { deliveries: 2, discount: 0, label: 'Starter', desc: '2 months' }, { deliveries: 4, discount: 5, label: 'Regular', desc: '4 months' }, { deliveries: 6, discount: 12, label: 'Best Value', desc: '6 months' }, ], }; const tiers = TIER_MAP[freq]; // Re-snap duration when frequency changes so it stays valid React.useEffect(() => { if (!tiers.find(t => t.deliveries === duration)) { setDuration(tiers[Math.min(1, tiers.length - 1)].deliveries); } }, [freq]); const tier = tiers.find(t => t.deliveries === duration) || tiers[0]; const grossTotal = perBoxTotal * duration; const discountAmt = Math.round(grossTotal * (tier.discount / 100)); const prepayTotal = grossTotal - discountAmt; const perDeliveryCap = duration > 0 ? Math.round(prepayTotal / duration) : 0; // Live calendar preview const allDrops = calcAllDropDates(delDay, freq, duration); const firstDrop = allDrops[0]; const lastDrop = allDrops[allDrops.length - 1]; // Coverage (matches tier desc): each box covers one cycle. // weekly: count × 7d, biweekly: count × 14d, monthly: count × 30d. const cycleDays = freq === 'monthly' ? 30 : freq === 'biweekly' ? 14 : 7; const coverageDays = duration * cycleDays; const totalMonths = Math.max(1, Math.round(coverageDays / 30)); const FREQS = [ { id: 'weekly', label: 'Weekly', sub: 'Every week' }, { id: 'biweekly', label: 'Bi-weekly', sub: 'Every 2 weeks' }, { id: 'monthly', label: 'Monthly', sub: 'Once a month' }, ]; const DAYS = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun']; const titles = ['SCHEDULE & DURATION', 'PREPAY VIA QR']; const canNext = [duration > 0 && perBoxTotal > 0, paid][step]; const progress = ((step + 1) / 2) * 100; function handleSubscribe() { if (window.GLSubscription) { window.GLSubscription.createPlan({ frequency: freq, delivery_day: delDay, duration_deliveries: duration, per_delivery_cap: perDeliveryCap, prepay_total: prepayTotal, discount_pct: tier.discount, first_drop: firstDrop ? firstDrop.toISOString() : null, last_drop: lastDrop ? lastDrop.toISOString() : null, standing_items: subItems.map(it => ({ id: it.id, qty: it.qty, price: it.price, name: it.name, diet_labels: it.diet_labels || [] })), payment_method: 'promptpay_qr', }); } if (app && app.clearSubTemplate) app.clearSubTemplate(); go('subscribe-confirmed'); } return (
{/* Header */}
{step > 0 ? : } {step + 1} / 2
{titles[step]}
{/* Body */}
{/* Step 1: Schedule + Duration combined */} {step === 0 && (
{perBoxTotal === 0 ? (
No items in your standing order
Go back and add at least one pasta set to your subscription.
) : ( <> {/* Standing order recap */}
EVERY BOX CONTAINS
{subItems.map((it, i) => (
{it.name} × {it.qty || 1} {fmtThb((it.price || 0) * (it.qty || 1))}
))}
Per box{fmtThb(perBoxTotal)}
{/* Frequency */}
HOW OFTEN?
{FREQS.map(f => { const on = freq === f.id; return ( ); })}
{/* Delivery day */}
DELIVERY DAY
{DAYS.map((d, i) => ( ))}
{/* Duration tier picker */}
HOW MANY DELIVERIES?
{tiers.map(t => { const on = t.deliveries === duration; const tGross = perBoxTotal * t.deliveries; const tDisc = Math.round(tGross * (t.discount / 100)); const tNet = tGross - tDisc; return ( ); })}
Subscriptions cap at ~6 months so you come back to see new pasta drops.
{/* LIVE CALENDAR PREVIEW */}
YOUR SCHEDULE
FIRST DROP
{firstDrop ? fmtDateShort(firstDrop) : '—'}
LAST DROP
{lastDrop ? fmtDateShort(lastDrop) : '—'}
Total run ~{totalMonths} {totalMonths === 1 ? 'month' : 'months'} · {duration} boxes
{/* Mini drop list — first 6, then "+N more" */}
{allDrops.slice(0, 6).map((d, i) => ( {fmtDateShort(d)} ))} {allDrops.length > 6 && ( +{allDrops.length - 6} more )}
PAY TODAY
{duration} × {fmtThb(perDeliveryCap)} cap/box
{fmtThb(prepayTotal)}
)}
)} {/* Step 2: Prepay via QR */} {step === 1 && (
{/* Summary */}
YOUR PLAN
Schedule {freq} · {DAYS[delDay]}
First / last drop {firstDrop ? fmtDateShort(firstDrop) : '—'} → {lastDrop ? fmtDateShort(lastDrop) : '—'}
Duration {duration} deliveries · ~{totalMonths}mo
Per-box cap {fmtThb(perDeliveryCap)}
{/* Charge breakdown */}
CHARGE BREAKDOWN
{fmtThb(perBoxTotal)} × {duration} boxes {fmtThb(grossTotal)}
{tier.discount > 0 && (
Tier discount ({tier.discount}%) −{fmtThb(discountAmt)}
)}
Pay today {fmtThb(prepayTotal)}
{/* PromptPay QR placeholder */}
PROMPTPAY · ONE-TIME SCAN
{paid ? (
PAID
) : (
)}
Scan with your bank app to pay
{fmtThb(prepayTotal)}
{!paid && ( )}
We'll remind you 7 days before your last delivery to renew. Cancel anytime — unused balance becomes account credit (never expires).
)}
{/* Footer CTA */}
{step === 0 && perBoxTotal > 0 && (
Pay today {fmtThb(prepayTotal)} {duration} × {fmtThb(perDeliveryCap)}/box
)} setStep(s => s + 1) : handleSubscribe} disabled={!canNext} variant="teal" > {step < 1 ? (perBoxTotal === 0 ? 'Add items first' : 'Continue to payment') : (paid ? 'Activate Subscription' : 'Awaiting payment…')}
); } // ══════════════════════════════════════════════════════════════════════════════ // SCREEN 2: GLSubscriberDash — subscriber account home // ══════════════════════════════════════════════════════════════════════════════ // Mock upcoming orders data const MOCK_UPCOMING_ORDERS = [ { id: 'so-1', week_label: 'Week of 21 Apr', dispatch_date: '2026-04-24', status: 'upcoming', estimated_total: 960, cutoff_at: '2026-04-22T18:00:00+07:00', items: [ { name: 'Truffle Mafaldine', img: 'https://images.unsplash.com/photo-1621996346565-e3dbc646d9a9?w=100' }, { name: 'Squid Ink Tagliolini', img: 'https://images.unsplash.com/photo-1563379926898-05f4575a45d8?w=100' }, { name: 'Burrata & Pomodoro', img: 'https://images.unsplash.com/photo-1598866594230-a7c12756260f?w=100' }, { name: 'Cacio e Pepe', img: 'https://images.unsplash.com/photo-1612874742237-6526221588e3?w=100' }, ], }, { id: 'so-2', week_label: 'Week of 28 Apr', dispatch_date: '2026-05-01', status: 'locked', estimated_total: 680, cutoff_at: '2026-04-29T18:00:00+07:00', items: [ { name: 'Cacio e Pepe', img: 'https://images.unsplash.com/photo-1612874742237-6526221588e3?w=100' }, { name: 'Pesto alla Genovese', img: 'https://images.unsplash.com/photo-1473093295043-cdd812d0e601?w=100' }, ], }, { id: 'so-3', week_label: 'Week of 5 May', dispatch_date: '2026-05-08', status: 'upcoming', estimated_total: 580, cutoff_at: '2026-05-06T18:00:00+07:00', items: [ { name: 'Pesto alla Genovese', img: 'https://images.unsplash.com/photo-1473093295043-cdd812d0e601?w=100' }, { name: 'Truffle Mafaldine', img: 'https://images.unsplash.com/photo-1621996346565-e3dbc646d9a9?w=100' }, ], }, { id: 'so-4', week_label: 'Week of 12 May', dispatch_date: '2026-05-15', status: 'skipped', estimated_total: 0, cutoff_at: '2026-05-13T18:00:00+07:00', items: [], }, ]; const MOCK_PAST_ORDERS = [ { id: 'so-p1', week_label: 'Week of 14 Apr', dispatch_date: '2026-04-17', status: 'delivered', billed_total: 760, items: [{ name: 'Truffle Mafaldine' }, { name: 'Rigatoni' }] }, { id: 'so-p2', week_label: 'Week of 7 Apr', dispatch_date: '2026-04-10', status: 'delivered', billed_total: 580, items: [{ name: 'Cacio e Pepe' }, { name: 'Pesto' }] }, { id: 'so-p3', week_label: 'Week of 31 Mar', dispatch_date: '2026-04-03', status: 'delivered', billed_total: 680, items: [{ name: 'Squid Ink' }] }, ]; function GLSubscriberDash({ go, app }) { const [skipped, setSkipped] = React.useState({}); const [orders, setOrders] = React.useState(MOCK_UPCOMING_ORDERS); const [loading, setLoading] = React.useState(false); const [discontinued, setDiscontinued] = React.useState([]); // product ids const [replaceFor, setReplaceFor] = React.useState(null); // {oldId, name} | null const plan = { status: 'ACTIVE', frequency: 'Weekly', day: 'Thu' }; // Mock standing items for this subscriber (used by discontinuation check). // In production this comes from the active plan record. const standingItemIds = React.useMemo(() => { const set = new Set(); orders.forEach(o => (o.items || []).forEach(i => { // map demo names back to product ids const map = { 'Squid Ink Tagliolini': 'squid', 'Truffle Mafaldine': 'truffle', 'Burrata & Pomodoro': 'burrata', 'Cacio e Pepe': 'cacio', 'Pesto alla Genovese': 'pesto', 'Classic Rigatoni': 'rigatoni', }; const id = map[i.name]; if (id) set.add(id); })); return Array.from(set); }, [orders]); React.useEffect(() => { let alive = true; if (window.GLSubscription && standingItemIds.length > 0) { window.GLSubscription.getDiscontinuedInOrder(standingItemIds).then(ids => { if (alive) setDiscontinued(ids); }); } return () => { alive = false; }; }, [standingItemIds.join(',')]); // Replacement candidates: same category (sets), within per-delivery cap, // not already in standing order, not discontinued. const PER_BOX_CAP = 1200; // mock cap from active plan const replacementOptions = React.useMemo(() => { if (!replaceFor) return []; const pool = (window.GL_PASTA || []).filter(p => p.id !== replaceFor.oldId && !discontinued.includes(p.id) && p.price <= PER_BOX_CAP ); return pool; }, [replaceFor, discontinued]); function pickReplacement(newItem) { if (window.GLSubscription && replaceFor) { window.GLSubscription.replaceDiscontinuedItem('so-1', replaceFor.oldId, newItem.id); } // Optimistically update local state: drop the discontinued name from upcoming setOrders(os => os.map(o => ({ ...o, items: (o.items || []).map(i => { const nameMap = { 'squid': 'Squid Ink Tagliolini' }; if (i.name === nameMap[replaceFor.oldId]) return { ...i, name: newItem.name, img: newItem.img }; return i; }), }))); setDiscontinued(d => d.filter(id => id !== replaceFor.oldId)); setReplaceFor(null); } const toggleSkip = (orderId) => { setOrders(os => os.map(o => { if (o.id !== orderId) return o; return { ...o, status: o.status === 'skipped' ? 'upcoming' : 'skipped' }; })); }; return (
go('me')} dark={false}/>
{/* Plan status bar */}
{plan.status} SUBSCRIPTION
{plan.frequency} · {plan.day} delivery
{/* Discontinuation banner — surfaces items removed from the menu */} {discontinued.length > 0 && (
⚠️
ACTION NEEDED
{discontinued.length === 1 ? 'An item' : `${discontinued.length} items`} in your standing order {discontinued.length === 1 ? 'is' : 'are'} no longer on the menu. Pick a replacement so your next box ships on time.
{discontinued.map(id => { const product = (window.GL_PASTA || []).find(p => p.id === id); const name = product ? product.name : id; return (
{name}
NO LONGER AVAILABLE
); })}
)} {/* Upcoming deliveries */}
UPCOMING DELIVERIES
{orders.map((order) => { const cutoffMs = msUntil(order.cutoff_at); const lockingSoon = cutoffMs > 0 && cutoffMs < 72 * 3600000; const isUpcoming = order.status === 'upcoming'; return (
{/* Week header */}
{order.week_label}
Ships {new Date(order.dispatch_date).toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })}
{/* Items preview */} {order.items.length > 0 && (
{order.items.slice(0, 3).map((item, i) => (
{item.img && {item.name}}
))} {order.items.length > 3 && ( +{order.items.length - 3} more )} {order.items.length === 0 && ( No items yet )}
)} {/* Estimated total + lock countdown */}
{order.estimated_total > 0 ? ~{fmtThb(order.estimated_total)} : } {cutoffMs > 0 && order.status === 'upcoming' && ( {lockingSoon ? '⚡ ' : ''}Locks in {fmtCountdown(cutoffMs)} )}
{/* Actions */}
{isUpcoming && ( )}
); })} {/* Past orders */}
PAST ORDERS
{MOCK_PAST_ORDERS.map(order => (
{order.week_label}
{order.items.map(i => i.name).join(', ')}
{fmtThb(order.billed_total)}
))}
{/* Replacement picker modal */} {replaceFor && (
setReplaceFor(null)} style={{ position: 'absolute', inset: 0, background: 'rgba(0,0,0,0.55)', zIndex: 100, display: 'flex', flexDirection: 'column', justifyContent: 'flex-end', }}>
e.stopPropagation()} style={{ background: '#fff', borderTopLeftRadius: 20, borderTopRightRadius: 20, maxHeight: '85%', display: 'flex', flexDirection: 'column', }}>
PICK A REPLACEMENT
Replacing {replaceFor.name} · cap ฿{PER_BOX_CAP.toLocaleString()}/box
{replacementOptions.length === 0 ? (
No eligible replacements available right now. Please contact support.
) : (
{replacementOptions.map(p => ( ))}
)}
Your replacement applies to all upcoming undelivered boxes. Cancel anytime.
)}
); } // ══════════════════════════════════════════════════════════════════════════════ // SCREEN 3: GLSwapBox — edit items for an upcoming week // ══════════════════════════════════════════════════════════════════════════════ function GLSwapBox({ go, app }) { const menu = window.GL_WEEKLY_MENU || { items: [], theme: 'This Week', week_label: 'This Week', cutoff_at: '' }; // Pre-populate with 2 items const [basket, setBasket] = React.useState({ 'wmi-1': 1, 'wmi-3': 1, }); const [saved, setSaved] = React.useState(false); const [currentPanelOpen, setCurrentPanelOpen] = React.useState(true); const cutoffMs = msUntil(menu.cutoff_at); const lockingSoon = cutoffMs > 0 && cutoffMs < 72 * 3600000; const selectedItems = menu.items.filter(i => (basket[i.id] || 0) > 0); const total = selectedItems.reduce((s, i) => s + i.price * (basket[i.id] || 0), 0); const count = Object.values(basket).reduce((s, q) => s + q, 0); const setQty = (id, q) => { setSaved(false); setBasket(b => { const n = { ...b }; if (q <= 0) delete n[id]; else n[id] = q; return n; }); }; const handleSave = async () => { if (window.GLSubscription) { await window.GLSubscription.updateOrderItems('so-1', basket); } setSaved(true); setTimeout(() => { go('subs'); }, 1200); }; return (
{/* Header */}
{menu.week_label.toUpperCase()} · EDIT YOUR BOX
{cutoffMs > 0 && ( Locks {fmtCountdown(cutoffMs)} )}
{/* Current selections (collapsible) */}
{currentPanelOpen && (
{selectedItems.length === 0 && (
No items selected yet
)} {selectedItems.map(item => (
{item.name}
{item.variant}
{fmtThb(item.price * basket[item.id])} setQty(item.id, basket[item.id] + 1)} onDec={() => setQty(item.id, basket[item.id] - 1)}/>
))}
)}
{/* Week theme header */}
{menu.theme}
{menu.week_label}
{/* Menu items */}
{menu.items.map(item => { const qty = basket[item.id] || 0; const remaining = (item.available_qty || 0) - (item.reserved_qty || 0); const outOfStock = remaining <= 0; const inBox = qty > 0; return (
{/* Image 2:3 ratio */}
{item.img && {item.name}} {item.feature_badge && (
)}
{item.name}
{item.variant}
{remaining < 15 && remaining > 0 && (
{remaining} left
)}
{fmtThb(item.price)} {outOfStock ? Sold out : inBox ? setQty(item.id, qty + 1)} onDec={() => setQty(item.id, qty - 1)}/> : }
{inBox && (
✓ In your box
)}
); })}
{/* Sticky footer */}
{fmtThb(total)} estimated
{count} item{count !== 1 ? 's' : ''}
{saved ?
✓ Saved
: }
); } // ══════════════════════════════════════════════════════════════════════════════ // SCREEN 4: GLSubscribeConfirmed — post-signup success // ══════════════════════════════════════════════════════════════════════════════ function GLSubscribeConfirmed({ go }) { return (
🍝
YOU'RE IN!
Your pasta subscription is live. We'll send you a reminder before each cutoff so you can swap your box.
go('subs')} variant="teal">View My Subscription
); } // ── Register all screens on window ──────────────────────────────────────────── Object.assign(window, { GLSubscribeOnboard, GLSubscriberDash, GLSwapBox, GLSubscribeConfirmed, }); console.log('%cSubscription screens loaded ✓', 'color:#59DFE8;font-weight:bold');