// Onboarding (3 screens) + Phone/OTP/Profile auth // Splash — brief logo animation before onboarding function GLSplash({ onDone }) { React.useEffect(() => { const t = setTimeout(onDone, 1800); return () => clearTimeout(t); }, []); return (
{/* rotating conic accent */}
G
Gustai
Lab
EST. 2024 · PHUKET
); } function GLOnboarding({ onDone }) { const [i, setI] = React.useState(0); const slides = [ { dark: true, bg: 'https://images.unsplash.com/photo-1551183053-bf91a1d81141?w=1200&q=80', kicker: null, title: <>Gustai
Lab, body: "Phuket's high-contrast pasta experience. Freshly extruded daily in Rawai.", cta: 'Next', }, { dark: false, bg: 'https://images.unsplash.com/photo-1587740908075-9e245311e158?w=1200&q=80', kicker: null, title: <>Complete
Dish Sets., body: <>Pasta Signature Sauce Toppings.
We prep. You boil.
5 minutes to perfection., cta: 'Next', }, { dark: true, bg: 'https://images.unsplash.com/photo-1566577739112-5180d4bf9390?w=1200&q=80', kicker: null, title: 'The Drop', body: <>A la carte or weekly drops.
Join the club for 15% off every box.
Pause anytime., cta: 'Explore the Menu', }, ]; const s = slides[i]; const last = i === slides.length - 1; const next = () => last ? onDone() : setI(i + 1); return (
{/* top skip */} {!last && ( )} {/* Image area */} {i === 0 && (
)} {i === 1 && (
)} {i === 2 && (
)} {/* Content */}

{s.title}

{i === 0 &&
}

{s.body}

{/* dots */}
{slides.map((_, idx) => (
))}
} >{s.cta}
); } // ─── Social Login ───────────────────────────────────────────────────────────── // Google · Facebook OAuth + Email OTP (6-digit code, stays in PWA) function GLSocialLogin({ app, onBack }) { const [agreed, setAgreed] = React.useState(false); const [step, setStep] = React.useState('social'); // 'social' | 'otp' const [email, setEmail] = React.useState(''); const [code, setCode] = React.useState(''); const [loading, setLoading] = React.useState(null); // 'google'|'facebook'|'email'|'verify'|null const [err, setErr] = React.useState(''); // Save postAuth to localStorage before any OAuth redirect — survives page reload const savePostAuth = () => { const dest = (app && app.postAuth) || ''; if (dest) localStorage.setItem('gl.postAuth', dest); else localStorage.removeItem('gl.postAuth'); }; const signInGoogle = async () => { if (!agreed) return; setLoading('google'); setErr(''); try { savePostAuth(); const { data, error } = await window.db.auth.signInWithOAuth({ provider: 'google', options: { redirectTo: window.location.origin + '/' }, }); if (error) throw error; if (data?.url) window.location.href = data.url; else throw new Error('Google sign-in is not configured in Supabase.'); } catch (e) { setErr(e.message || String(e)); setLoading(null); } }; const signInFacebook = async () => { if (!agreed) return; setLoading('facebook'); setErr(''); try { savePostAuth(); const { data, error } = await window.db.auth.signInWithOAuth({ provider: 'facebook', options: { redirectTo: window.location.origin + '/' }, }); if (error) throw error; if (data?.url) window.location.href = data.url; else throw new Error('Facebook sign-in is not configured in Supabase.'); } catch (e) { setErr(e.message || String(e)); setLoading(null); } }; const sendEmailCode = async () => { if (!agreed || !email.trim()) return; setLoading('email'); setErr(''); try { const { error } = await window.db.auth.signInWithOtp({ email: email.trim().toLowerCase(), options: { shouldCreateUser: true }, }); if (error) throw error; setStep('otp'); } catch (e) { setErr(e.message || String(e)); } finally { setLoading(null); } }; const verifyCode = async () => { if (code.length !== 6) return; setLoading('verify'); setErr(''); try { const { error } = await window.db.auth.verifyOtp({ email: email.trim().toLowerCase(), token: code, type: 'email', }); if (error) throw error; // Navigate directly — onAuthStateChange has stale postAuth closure const dest = localStorage.getItem('gl.postAuth') || (app && app.postAuth) || 'home'; localStorage.removeItem('gl.postAuth'); app.setIsRegistered(true); app.go(dest); } catch (e) { setErr(e.message || String(e)); setLoading(null); } }; const Spinner = ({ color }) => ( ); // ── OTP code entry step ─────────────────────────────────────────────────── if (step === 'otp') { return (

Check
your email

6-digit code sent to
{email}

setCode(e.target.value.replace(/\D/g, '').slice(0, 6))} onKeyDown={e => e.key === 'Enter' && verifyCode()} placeholder="000000" style={{ width: '100%', height: 72, border: `2px solid ${GL.ink}`, background: '#fff', textAlign: 'center', fontSize: 36, fontFamily: 'monospace', fontWeight: 700, letterSpacing: '0.35em', color: GL.ink, outline: 'none', boxSizing: 'border-box', boxShadow: `4px 4px 0 ${GL.ink}`, }} /> {err &&
{err}
}
); } // ── Social + email entry ────────────────────────────────────────────────── return (
{onBack && ( )}

Join
The Lab

Sign in to order & track your pasta

{/* PDPA consent */} {/* Google */} {/* Facebook */} {/* Divider */}
or email
{/* Email + Send Code */}
setEmail(e.target.value)} onKeyDown={e => e.key === 'Enter' && sendEmailCode()} style={{ flex: 1, height: 56, padding: '0 16px', border: `2px solid ${GL.ink}`, background: '#fff', fontSize: 14, color: GL.ink, outline: 'none', boxSizing: 'border-box', }} />
{!agreed &&

Please accept the Terms to continue.

} {err &&
{err}
}
); } // ── Legacy stubs — kept so existing references don't crash, redirect to social login ── const GL_COUNTRY_CODES = [ { c: 'TH', dial: '+66', name: 'Thailand' }, { c: 'US', dial: '+1', name: 'United States' }, { c: 'GB', dial: '+44', name: 'United Kingdom' }, { c: 'AU', dial: '+61', name: 'Australia' }, { c: 'DE', dial: '+49', name: 'Germany' }, { c: 'FR', dial: '+33', name: 'France' }, { c: 'IT', dial: '+39', name: 'Italy' }, { c: 'ES', dial: '+34', name: 'Spain' }, { c: 'NL', dial: '+31', name: 'Netherlands' }, { c: 'SE', dial: '+46', name: 'Sweden' }, { c: 'NO', dial: '+47', name: 'Norway' }, { c: 'DK', dial: '+45', name: 'Denmark' }, { c: 'CH', dial: '+41', name: 'Switzerland' }, { c: 'RU', dial: '+7', name: 'Russia' }, { c: 'IL', dial: '+972', name: 'Israel' }, { c: 'IN', dial: '+91', name: 'India' }, { c: 'CN', dial: '+86', name: 'China' }, { c: 'JP', dial: '+81', name: 'Japan' }, { c: 'KR', dial: '+82', name: 'South Korea' }, { c: 'SG', dial: '+65', name: 'Singapore' }, { c: 'HK', dial: '+852', name: 'Hong Kong' }, { c: 'MY', dial: '+60', name: 'Malaysia' }, { c: 'ID', dial: '+62', name: 'Indonesia' }, { c: 'PH', dial: '+63', name: 'Philippines' }, ]; function glNormalizeE164(dial, local) { const digits = (local || '').replace(/\D/g, '').replace(/^0+/, ''); return digits ? `${dial}${digits}` : ''; } // Reusable consent checkbox — PDPA-compliant explicit tick function ConsentBox({ checked, onChange, label, badge, required }) { return (
{badge && ( {badge} )}
{label}
); } // Phone number sign up — country picker + channel choice (SMS / WhatsApp) function GLPhoneSignup({ onSubmit, onBack }) { const [dial, setDial] = React.useState('+66'); const [local, setLocal] = React.useState(''); const [channel, setChannel] = React.useState('sms'); const [agreedTerms, setAgreedTerms] = React.useState(false); const [agreedMarketing, setAgreedMarketing] = React.useState(false); const [busy, setBusy] = React.useState(false); const [err, setErr] = React.useState(''); const e164 = glNormalizeE164(dial, local); const canSend = !!e164 && agreedTerms && !busy; async function handleSend() { if (!canSend) return; setErr(''); setBusy(true); try { await window.GLAuth.sendOTP({ phone: e164, channel, purpose: 'signup' }); onSubmit({ phone: e164, channel, marketingConsent: agreedMarketing }); } catch (e) { let msg = e?.message || String(e); if (e?.try_other_channel) msg += ` — try ${e.try_other_channel}.`; setErr(msg); setBusy(false); } } const CHANNELS = [ { id: 'sms', label: 'Text message', icon: ( ), }, { id: 'whatsapp', label: 'WhatsApp', icon: ( ), }, { id: 'line', label: 'LINE', icon: ( ), }, ]; return (
{/* decorative corner */}

Join
The Lab

Enter your number — we'll send a 6-digit code.

{/* Phone row: country dial + local digits */}
setLocal(e.target.value.replace(/[^\d\s]/g, ''))} placeholder="81 234 5678" inputMode="tel" style={{ flex: 1, border: 'none', outline: 'none', padding: '0 18px', fontSize: 16, fontWeight: 600, letterSpacing: '0.05em' }} />
{e164 && (
We'll send to: {e164}
)} {/* Channel picker — SMS / WhatsApp */}
Send code via
{CHANNELS.map((opt, idx) => { const active = channel === opt.id; return ( ); })}
{err && (
{err}
)} {/* ── Consent checkboxes ─────────────────────────────────────────── */}
{/* REQUIRED — Terms + Privacy */} I have read and agree to the{' '} { e.preventDefault(); onLegal && onLegal('terms'); }} style={{ color: GL.ink, fontWeight: 700, textDecoration: 'underline' }}>Terms of Service {' '}and{' '} { e.preventDefault(); onLegal && onLegal('privacy'); }} style={{ color: GL.ink, fontWeight: 700, textDecoration: 'underline' }}>Privacy Policy. } badge="Required" /> {/* OPTIONAL — Marketing */} I'd like to receive "The Drop" alerts and promotions via {channel === 'whatsapp' ? 'WhatsApp' : 'SMS'}. } badge="Optional" />
}>{busy ? 'Sending…' : 'Send Verification Code'} {!agreedTerms && e164 && !busy && (

Please agree to the Terms & Privacy Policy to continue.

)}
); } // OTP verification — real 6-digit entry against phone-otp-verify Edge Function function GLOTP({ phone, channel, onVerify, onBack }) { const [code, setCode] = React.useState(['','','','','','']); const [busy, setBusy] = React.useState(false); const [err, setErr] = React.useState(''); const [info, setInfo] = React.useState(''); const [resendIn, setResendIn] = React.useState(30); const refs = React.useRef([]); React.useEffect(() => { if (resendIn <= 0) return; const t = setTimeout(() => setResendIn(s => s - 1), 1000); return () => clearTimeout(t); }, [resendIn]); // Auto-submit when all 6 digits filled React.useEffect(() => { if (code.every(d => /\d/.test(d)) && !busy) handleVerify(); // eslint-disable-next-line }, [code]); function handleChange(i, v) { const d = (v || '').replace(/\D/g, '').slice(0, 1); setErr(''); setCode(prev => { const n = [...prev]; n[i] = d; return n; }); if (d && i < 5 && refs.current[i + 1]) refs.current[i + 1].focus(); } function handleKeyDown(i, e) { if (e.key === 'Backspace' && !code[i] && i > 0) { refs.current[i - 1]?.focus(); } else if (e.key === 'ArrowLeft' && i > 0) { refs.current[i - 1]?.focus(); } else if (e.key === 'ArrowRight' && i < 5) { refs.current[i + 1]?.focus(); } } function handlePaste(e) { const txt = (e.clipboardData?.getData('text') || '').replace(/\D/g, '').slice(0, 6); if (!txt) return; e.preventDefault(); const arr = ['','','','','','']; for (let i = 0; i < txt.length; i++) arr[i] = txt[i]; setCode(arr); refs.current[Math.min(txt.length, 5)]?.focus(); } async function handleVerify() { const c = code.join(''); if (c.length !== 6) return; setBusy(true); setErr(''); try { const r = await window.GLAuth.verifyOTP({ phone, code: c, channel }); onVerify({ user_id: r.user_id, is_new: r.is_new }); } catch (e) { if (typeof e?.attempts_left === 'number') { setErr(`Wrong code — ${e.attempts_left} attempt${e.attempts_left === 1 ? '' : 's'} left.`); } else { setErr(e?.message || String(e)); } setBusy(false); setCode(['','','','','','']); refs.current[0]?.focus(); } } async function handleResend() { if (resendIn > 0) return; setErr(''); setInfo(''); try { await window.GLAuth.sendOTP({ phone, channel }); setInfo('New code sent.'); setResendIn(30); } catch (e) { let msg = e?.message || String(e); if (e?.try_other_channel) msg += ` — try ${e.try_other_channel}.`; setErr(msg); } } // Mask phone for display: keep last 4 digits visible const maskedPhone = phone && phone.length > 4 ? `${phone.slice(0, phone.length - 4).replace(/\d/g, '•')}${phone.slice(-4)}` : phone; const channelLabel = channel === 'whatsapp' ? 'WhatsApp' : channel === 'line' ? 'LINE' : 'SMS'; return (
{/* teal top accent */}
EST. 2024
GUSTAI LAB
}/>

Verify Your
Lab Access

{channel === 'line' ? ( /* LINE instructions — user must message the OA first */
HOW TO GET YOUR CODE
{[ { n: '1', t: 'Open LINE and add us as a friend' }, { n: '2', t: `Send your phone number: ${phone.replace('+66', '0')}` }, { n: '3', t: 'We\'ll reply with your 6-digit code' }, ].map(s => (
{s.n}
{s.t}
))}
Open LINE → @GustaiLab
) : (

Enter the 6-digit code sent via {channelLabel} to
{maskedPhone}

)}
{code.map((d, i) => ( refs.current[i] = el} value={d} onChange={e => handleChange(i, e.target.value)} onKeyDown={e => handleKeyDown(i, e)} inputMode="numeric" maxLength={1} autoFocus={i === 0} disabled={busy} style={{ width: 46, height: 56, border: `1.5px solid ${d ? GL.ink : GL.line}`, textAlign: 'center', outline: 'none', fontFamily: GL.display, fontSize: 24, color: GL.ink, transition: 'border .2s', background: d ? '#fff' : GL.offwhite, }}/> ))}
{err && (
{err}
)} {info && !err && (
{info}
)}
Secure Session {channelLabel}
}> {busy ? 'Verifying…' : 'Verify & Continue'}

By verifying, you agree to our Lab Terms

); } // Account / Profile setup — works for both social auth and legacy phone auth function GLProfileSetup({ phone, channel, marketingConsent, initialName, onDone, onBack, onLegal }) { const [name, setName] = React.useState(initialName || ''); const [area, setArea] = React.useState(''); const [busy, setBusy] = React.useState(false); const [err, setErr] = React.useState(''); async function handleSave() { setErr(''); setBusy(true); try { // Persist profile fields if a Supabase session is active const db = window.db; const session = db?.auth ? (await db.auth.getSession()).data?.session : null; if (session?.user?.id) { const updates = { display_name: name || null, delivery_area: area || null, notify_drops: !!marketingConsent, // from explicit signup consent marketing_consent: !!marketingConsent, // timestamped record preferred_channel: channel || 'whatsapp', }; const { error } = await db .from('profiles') .update(updates) .eq('id', session.user.id); if (error) { // Surface the error but don't block — user can still continue console.warn('profile update failed:', error); } } onDone({ name, area, marketingConsent }); } catch (e) { setErr(String(e?.message || e)); } finally { setBusy(false); } } return (

Lab Profile
Setup

{phone && (
Verified via
{phone}
)} {[ { label: 'Full Name', v: name, s: setName, ph: 'Enter your full name' }, { label: 'Preferred Delivery Area', v: area, s: setArea, ph: 'e.g., Rawai, Chalong' }, ].map((f, i) => (
f.s(e.target.value)} placeholder={f.ph} style={{ width: '100%', border: 'none', borderBottom: `1px solid ${GL.ink}`, background: 'transparent', padding: '14px 0', fontSize: 16, outline: 'none', marginTop: 6 }}/>
))} {/* Show the marketing preference they already chose at sign-up — read only */}
{marketingConsent && ( )}
{marketingConsent ? 'Opted in to Drop alerts' : 'Not subscribed to Drop alerts'}
You can change this anytime in your profile settings.
{err && (
{err}
)}
{busy ? 'Saving…' : 'Create Account'}

By continuing, you agree to our Terms of Service.

); } Object.assign(window, { GLSplash, GLOnboarding, GLSocialLogin, GLProfileSetup, // Legacy exports kept so old references don't crash GLPhoneSignup: GLSocialLogin, GLOTP: GLSocialLogin, GL_COUNTRY_CODES, glNormalizeE164, });