// Checkout → Payment → PromptPay QR → Order confirmed → Track My Pasta
// ─── PromptPay EMVCo payload generator ───────────────────────────────────────
const PROMPTPAY_PHONE = '0954766235';
function ppTLV(id, val) { return id + String(val.length).padStart(2, '0') + val; }
function buildPromptPayPayload(phone, amount) {
const digits = phone.replace(/\D/g, '');
const acct = ('0066' + (digits.startsWith('0') ? digits.slice(1) : digits)).padStart(13, '0');
const merchantInfo = ppTLV('00', 'A000000677010111') + ppTLV('01', acct);
const body = [
ppTLV('00', '01'),
ppTLV('01', '12'),
ppTLV('29', merchantInfo),
ppTLV('53', '764'),
ppTLV('54', amount.toFixed(2)),
ppTLV('58', 'TH'),
ppTLV('59', 'GUSTAI LAB'),
ppTLV('60', 'PHUKET'),
'6304',
].join('');
let crc = 0xFFFF;
for (let i = 0; i < body.length; i++) {
crc ^= body.charCodeAt(i) << 8;
for (let j = 0; j < 8; j++) crc = (crc & 0x8000) ? ((crc << 1) ^ 0x1021) & 0xFFFF : (crc << 1) & 0xFFFF;
}
return body + crc.toString(16).toUpperCase().padStart(4, '0');
}
function PromptPayQR({ amount }) {
const ref = React.useRef(null);
React.useEffect(() => {
if (!ref.current || !window.QRCode) return;
ref.current.innerHTML = '';
new window.QRCode(ref.current, {
text: buildPromptPayPayload(PROMPTPAY_PHONE, amount),
width: 220, height: 220,
colorDark: '#0A0A0A', colorLight: '#ffffff',
correctLevel: window.QRCode.CorrectLevel.M,
});
// qrcodejs creates a canvas; make it fill the container
const canvas = ref.current.querySelector('canvas');
if (canvas) { canvas.style.width = '100%'; canvas.style.height = 'auto'; canvas.style.display = 'block'; }
}, [amount]);
return
;
}
// ─────────────────────────────────────────────────────────────────────────────
function GLCheckout({ app, go }) {
const [address, setAddress] = React.useState(app.address);
const [place, setPlace] = React.useState('home');
const [timing, setTiming] = React.useState(0);
const [payment, setPayment] = React.useState('promptpay');
const [deliveryAck, setDeliveryAck] = React.useState(false);
const [locating, setLocating] = React.useState(false);
const [addingLabel, setAddingLabel] = React.useState(false);
const [newLabel, setNewLabel] = React.useState('');
const [customLabels, setCustomLabels] = React.useState([]);
// Generate time slots based on current time
const slots = React.useMemo(() => {
const now = new Date();
const pad = n => String(n).padStart(2, '0');
const fmt = d => `${pad(d.getHours())}:${pad(d.getMinutes())}`;
const addMin = (d, m) => new Date(d.getTime() + m * 60000);
const roundUp30 = (d) => {
const m = d.getMinutes();
const add = m < 30 ? 30 - m : 60 - m;
return addMin(d, add);
};
const asapEnd = addMin(now, 45);
const slot2Start = roundUp30(addMin(now, 60));
const slot3Start = addMin(slot2Start, 30);
const slot4Start = addMin(slot3Start, 30);
return [
{ kicker: 'ASAP', big: 'Now', sub: `${fmt(addMin(now, 30))} – ${fmt(asapEnd)}` },
{ kicker: 'Today', big: fmt(slot2Start), sub: `${fmt(slot2Start)} – ${fmt(addMin(slot2Start, 30))}` },
{ kicker: 'Today', big: fmt(slot3Start), sub: `${fmt(slot3Start)} – ${fmt(addMin(slot3Start, 30))}` },
{ kicker: 'Tmrw', big: fmt(slot4Start), sub: `${fmt(slot4Start)} – ${fmt(addMin(slot4Start, 30))}` },
];
}, []);
const useCurrentLocation = () => {
if (!navigator.geolocation) return;
setLocating(true);
navigator.geolocation.getCurrentPosition(
pos => {
fetch(`https://nominatim.openstreetmap.org/reverse?lat=${pos.coords.latitude}&lon=${pos.coords.longitude}&format=json&accept-language=en`)
.then(r => r.json())
.then(d => {
const a = d.address || {};
const parts = [a.road, a.village || a.suburb || a.neighbourhood, a.city || a.town || a.county].filter(Boolean);
const addr = parts.join(', ') || d.display_name || `${pos.coords.latitude.toFixed(4)}, ${pos.coords.longitude.toFixed(4)}`;
setAddress(addr); app.setAddress(addr);
})
.catch(() => {
const addr = `${pos.coords.latitude.toFixed(5)}, ${pos.coords.longitude.toFixed(5)}`;
setAddress(addr); app.setAddress(addr);
})
.finally(() => setLocating(false));
},
() => { setLocating(false); alert('Please enable location access in your browser settings.'); }
);
};
const openInMaps = () => {
const q = encodeURIComponent(address || 'Phuket, Thailand');
window.open(`https://maps.google.com/?q=${q}`, '_blank');
};
const addCustomLabel = () => {
const label = newLabel.trim();
if (!label) return;
setCustomLabels(prev => [...prev, label]);
setPlace(label.toLowerCase());
setNewLabel('');
setAddingLabel(false);
};
const allLabels = ['Home', 'Office', 'Villa 5', ...customLabels];
const subtotal = app.cart.reduce((s, i) => s + i.price * i.qty, 0);
const total = subtotal + 60;
return (
go('box')} center={
CHECKOUT
}/>
{/* Delivery */}
Delivery
Address
Phuket Area Only
{allLabels.map(p => {
const on = p.toLowerCase() === place;
return (
);
})}
{addingLabel ? (
setNewLabel(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') addCustomLabel(); if (e.key === 'Escape') setAddingLabel(false); }}
placeholder="Label…"
style={{ width: 80, border: `2px solid ${GL.ink}`, padding: '6px 8px', fontSize: 11, fontWeight: 700, outline: 'none', textTransform: 'uppercase', letterSpacing: '0.1em' }}/>
) : (
)}
{/* Timing */}
Timing
{timing === 0 ? 'Est. 30–45 mins' : slots[timing].sub}
{slots.map((s, i) => {
const on = i === timing;
return (
);
})}
{/* Payment */}
Payment
setPayment('promptpay')}
logo={
}
label="PromptPay QR" sub="Scan & pay via any Thai banking app"/>
setPayment('cod')}
logo={
}
label="Cash on Delivery" sub="Pay the driver upon arrival"/>
{/* Delivery fee acknowledgment — lives in scroll area, collapses once ticked */}
{!deliveryAck ? (
) : (
Grab delivery fee acknowledged 🛵
)}
{/* Sticky footer — total + confirm only */}
Total
Excl. Grab delivery
฿ {total.toLocaleString()}
);
}
function PaymentOption({ selected, onSelect, logo, label, sub }) {
return (
);
}
// PromptPay QR Payment screen — with receipt upload + admin confirmation polling
function GLQRPayment({ app, go }) {
const total = app.cart.reduce((s, i) => s + i.price * i.qty, 0) + 60;
const orderNo = app.orderNo;
// Timer state
const [remaining, setRemaining] = React.useState(899);
// Receipt upload state
const [file, setFile] = React.useState(null);
const [preview, setPreview] = React.useState(null); // data-url for images
const [uploading, setUploading] = React.useState(false);
const [uploaded, setUploaded] = React.useState(false);
const [pollStatus, setPollStatus] = React.useState('pending'); // 'pending'|'confirmed'|'rejected'
const [uploadErr, setUploadErr] = React.useState('');
const [dragOver, setDragOver] = React.useState(false);
const fileRef = React.useRef(null);
// Countdown — pauses after receipt uploaded
React.useEffect(() => {
if (uploaded || remaining <= 0) return;
const t = setInterval(() => setRemaining(r => Math.max(0, r - 1)), 1000);
return () => clearInterval(t);
}, [uploaded, remaining]);
const mm = String(Math.floor(remaining / 60)).padStart(2, '0');
const ss = String(remaining % 60).padStart(2, '0');
// Poll for admin confirmation every 15 s (up to 10 min = 40 tries)
React.useEffect(() => {
if (!uploaded) return;
let tries = 0;
const iv = setInterval(async () => {
tries++;
if (tries > 40) { clearInterval(iv); return; }
try {
const { data } = await window.db
.from('payment_receipts')
.select('status')
.eq('order_no', orderNo)
.order('created_at', { ascending: false })
.limit(1)
.single();
if (data?.status === 'confirmed' || data?.status === 'auto_confirmed') {
clearInterval(iv);
setPollStatus('confirmed');
setTimeout(() => go('confirmed'), 1400);
} else if (data?.status === 'rejected') {
clearInterval(iv);
setPollStatus('rejected');
}
} catch (_) {}
}, 8000);
return () => clearInterval(iv);
}, [uploaded]);
// File validation + preview
function chooseFile(f) {
if (!f) return;
const ALLOWED = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf'];
const MAX = 5 * 1024 * 1024;
if (!ALLOWED.includes(f.type)) { setUploadErr('Please choose a JPG, PNG, WebP, or PDF.'); return; }
if (f.size > MAX) { setUploadErr('File must be under 5 MB.'); return; }
setUploadErr('');
setFile(f);
if (f.type.startsWith('image/')) {
const reader = new FileReader();
reader.onload = e => setPreview(e.target.result);
reader.readAsDataURL(f);
} else {
setPreview(null);
}
}
// Save QR code to camera roll
function saveQR() {
const canvas = document.querySelector('.gl-qr-wrap canvas');
if (!canvas) return;
const a = document.createElement('a');
a.href = canvas.toDataURL('image/png');
a.download = `gustai-${orderNo}.png`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
// Upload receipt to Supabase Storage + record in payment_receipts table
async function handleUpload() {
if (!file || uploading) return;
setUploading(true);
setUploadErr('');
try {
const ext = file.name.split('.').pop().toLowerCase() || 'jpg';
const path = `${orderNo}/${Date.now()}.${ext}`;
const { error: storErr } = await window.db.storage
.from('receipts')
.upload(path, file, { contentType: file.type, upsert: false });
if (storErr) throw storErr;
const { data: { session } } = await window.db.auth.getSession();
const { error: dbErr } = await window.db.from('payment_receipts').insert({
order_no: orderNo,
amount: total,
receipt_path: path,
receipt_mime: file.type,
customer_id: session?.user?.id || null,
});
if (dbErr) throw dbErr;
setUploaded(true);
} catch (e) {
setUploadErr(e?.message || 'Upload failed — please try again.');
} finally {
setUploading(false);
}
}
// ── Confirmed banner (after admin approves) ───────────────────────────────
if (pollStatus === 'confirmed') {
return (
Payment
Confirmed!
Taking you to your order…
);
}
const maskedPhone = '+66 ' + PROMPTPAY_PHONE.replace(/^0/, '') .replace(/(\d{2})(\d{3})(\d{4})/, '$1-XXX-$3');
return (
go('checkout')} center={
SCAN TO PAY
}/>
{/* Amount */}
Order Total
฿{Math.floor(total).toLocaleString()}
.00
Gustai Lab · {maskedPhone}
{/* QR frame */}
{/* PromptPay header */}
{/* Recipient info */}
{[
{ l: 'Recipient', v: 'Gustai Lab' },
{ l: 'PromptPay', v: maskedPhone },
{ l: 'Amount', v: `฿${total.toFixed(2)}` },
].map(r => (
{r.l}
{r.v}
))}
{/* Steps */}
{['Open Banking App', 'Scan QR', 'Transfer'].map((s, i) => (
))}
{/* Timer */}
{!uploaded && (
)}
{/* ── Receipt upload section ───────────────────────────────────────── */}
STEP 4
Upload Your Receipt
{uploaded ? (
/* ── After upload ──────────────────────────────────────────── */
{pollStatus === 'rejected' ? (
✗ Receipt rejected — please contact us via LINE @GustaiLab.
) : (
Receipt received ✓
We'll verify your payment within a few minutes.
Waiting for confirmation…
)}
) : (
/* ── Before upload ─────────────────────────────────────────── */
After transferring, upload a screenshot or PDF of the bank confirmation. We verify within minutes.
{/* Drop zone */}
{uploadErr && (
{uploadErr}
)}
:
}
>
{uploading ? 'Uploading…' : 'Upload Receipt'}
)}
Need help? Message us on LINE @GustaiLab
);
}
// Order confirmed
function GLConfirmed({ app, go }) {
return (
Order
Confirmed
Thank You For Your Order
Order No.
#GUSTAI-{app.orderNo}
Destination
Delivery to Rawai
{app.address}
Estimated Arrival
45 mins
{/* Cook timer promo */}
Pasta Cook Timer
Perfect al dente — every time
go('track')} variant="primary"
icon={}>
Track My Pasta
);
}
// Track My Pasta — animated map
function GLTrack({ app, go }) {
const [progress, setProgress] = React.useState(0.15);
const [stage, setStage] = React.useState(2); // 0 extruded 1 boxed 2 on the road
React.useEffect(() => {
const i = setInterval(() => setProgress(p => Math.min(0.98, p + 0.003)), 80);
return () => clearInterval(i);
}, []);
return (
go('home')} center={
TRACK MY PASTA
} right={
}/>
{/* Map */}
{/* coastline shapes */}
{/* En route card */}
{/* Driver */}
Your Driver
Somchai
★ 4.9/5.0
{/* Progress bar */}
{['Extruded', 'Boxed', 'On The Road'].map((s, i) => (
{s}
))}
{[0,1,2].map(i => (
{i < 2 && }
))}
Order #{app.orderNo}
{app.cart.length} items in your box
);
}
Object.assign(window, { GLCheckout, GLQRPayment, GLConfirmed, GLTrack });