// ══════════════════════════════════════════════════════════════════════════════
// 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
? setStep(s => s - 1)} style={{ padding: '4px 0', fontSize: 13, color: GL.tealDim, fontWeight: 600, background: 'none', border: 'none', cursor: 'pointer' }}>← Back
: go('box')} style={{ padding: '4px 0', fontSize: 13, color: GL.tealDim, fontWeight: 600, background: 'none', border: 'none', cursor: 'pointer' }}>← Box }
{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.
go('home')} style={{ padding: '8px 16px', background: GL.ink, color: '#fff', border: 'none', borderRadius: 8, fontSize: 12, fontWeight: 700, cursor: 'pointer' }}>Browse Menu
) : (
<>
{/* 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 (
setFreq(f.id)}
style={{
flex: 1, padding: '12px 6px', borderRadius: 10,
border: `2px solid ${on ? GL.teal : GL.line}`,
background: on ? (GL.teal + '18') : '#fff',
cursor: 'pointer', display: 'flex', flexDirection: 'column', gap: 2,
}}>
{f.label}
{f.sub}
);
})}
{/* Delivery day */}
DELIVERY DAY
{DAYS.map((d, i) => (
setDelDay(i)}
style={{
minWidth: 44, height: 44, borderRadius: 8, border: 'none',
background: delDay === i ? GL.teal : GL.line,
color: delDay === i ? GL.ink : GL.muted,
fontWeight: 700, fontSize: 11, cursor: 'pointer', flexShrink: 0,
}}>{d}
))}
{/* 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 (
setDuration(t.deliveries)}
style={{
display: 'flex', alignItems: 'center', gap: 12, padding: '12px 14px',
border: `2px solid ${on ? GL.teal : GL.line}`, borderRadius: 12,
background: on ? GL.teal + '18' : '#fff',
cursor: 'pointer', textAlign: 'left',
}}>
{on && ✓ }
{t.deliveries} deliveries
{t.discount > 0 && (
SAVE {t.discount}%
)}
{t.label} · {t.desc}
{fmtThb(tNet)}
{tDisc > 0 && (
{fmtThb(tGross)}
)}
);
})}
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
Scan with your bank app to pay
{fmtThb(prepayTotal)}
{!paid && (
setPaid(true)} style={{
padding: '8px 16px', background: GL.ink, color: '#fff', border: 'none', borderRadius: 8,
fontSize: 11, fontWeight: 800, letterSpacing: '0.1em', cursor: 'pointer',
}}>SIMULATE 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
{}} style={{ fontSize: 12, color: GL.tealDim, fontWeight: 600, background: 'none', border: 'none', cursor: 'pointer', padding: 0 }}>
Pause subscription
{}} style={{ fontSize: 12, color: '#a61b30', fontWeight: 600, background: 'none', border: 'none', cursor: 'pointer', padding: 0 }}>
Cancel
{/* Discontinuation banner — surfaces items removed from the menu */}
{discontinued.length > 0 && (
{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
setReplaceFor({ oldId: id, name })} style={{
padding: '8px 14px', borderRadius: 8, background: GL.ink, color: '#fff',
fontSize: 11, fontWeight: 800, letterSpacing: '0.08em', border: 'none', cursor: 'pointer',
}}>PICK REPLACEMENT
);
})}
)}
{/* 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 &&
}
))}
{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 && (
go('swap')} style={{
flex: 1, padding: '8px 0', borderRadius: 8,
border: `1.5px solid ${GL.ink}`, background: '#fff',
fontWeight: 700, fontSize: 12, cursor: 'pointer',
}}>Edit Box
)}
toggleSkip(order.id)} style={{
padding: '8px 12px', borderRadius: 8,
border: `1.5px solid ${GL.line}`, background: '#fff',
fontWeight: 600, fontSize: 12, cursor: 'pointer',
color: order.status === 'skipped' ? GL.tealDim : '#a61b30',
}}>
{order.status === 'skipped' ? 'Unskip' : 'Skip'}
);
})}
{/* Past orders */}
{MOCK_PAST_ORDERS.map(order => (
{order.week_label}
{order.items.map(i => i.name).join(', ')}
{fmtThb(order.billed_total)}
Reorder
))}
{/* 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
setReplaceFor(null)} style={{ width: 32, height: 32, borderRadius: '50%', background: GL.line, color: GL.ink, fontSize: 18, fontWeight: 700, border: 'none', cursor: 'pointer' }}>×
{replacementOptions.length === 0 ? (
No eligible replacements available right now. Please contact support.
) : (
{replacementOptions.map(p => (
pickReplacement(p)} style={{
display: 'flex', gap: 12, padding: 12,
border: `1.5px solid ${GL.line}`, borderRadius: 12, background: '#fff',
textAlign: 'left', cursor: 'pointer', alignItems: 'center',
}}>
{p.img &&
}
{p.name}
{p.pair}
{p.diet_labels &&
}
))}
)}
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 */}
go('subs')} style={{ width: 32, height: 32, display: 'grid', placeItems: 'center', background: 'none', border: 'none', cursor: 'pointer' }}>
{menu.week_label.toUpperCase()} · EDIT YOUR BOX
{cutoffMs > 0 && (
Locks {fmtCountdown(cutoffMs)}
)}
{/* Current selections (collapsible) */}
setCurrentPanelOpen(o => !o)} style={{
width: '100%', padding: '12px 14px', display: 'flex', justifyContent: 'space-between',
alignItems: 'center', background: 'none', border: 'none', cursor: 'pointer',
}}>
Current Box ({count} item{count !== 1 ? 's' : ''})
{fmtThb(total)}
{currentPanelOpen ? '▲' : '▼'}
{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.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)}/>
: setQty(item.id, 1)} style={{
padding: '6px 14px', borderRadius: 999, background: GL.ink, color: '#fff',
fontWeight: 700, fontSize: 12, border: 'none', cursor: 'pointer',
}}>+ Add
}
{inBox && (
✓ In your box
)}
);
})}
{/* Sticky footer */}
{fmtThb(total)} estimated
{count} item{count !== 1 ? 's' : ''}
{saved
?
✓ Saved
:
Save Box
}
);
}
// ══════════════════════════════════════════════════════════════════════════════
// 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
go('home')} style={{ fontSize: 13, color: GL.muted, fontWeight: 600, background: 'none', border: 'none', cursor: 'pointer', padding: 8 }}>
Back to Home
);
}
// ── Register all screens on window ────────────────────────────────────────────
Object.assign(window, {
GLSubscribeOnboard,
GLSubscriberDash,
GLSwapBox,
GLSubscribeConfirmed,
});
console.log('%cSubscription screens loaded ✓', 'color:#59DFE8;font-weight:bold');