// Apply form (single-step) + Footer — Round 3 // Single-page form: name, email, phone, preferred contact method, procedure of interest. // "Other" procedure expands a free-text input. Real-time inline validation. // Replace with deployed Apps Script Web App URL when wiring Google Sheets. // Submission gracefully no-ops if URL is empty (still shows confirmation). const SHEETS_ENDPOINT = window.SANNATRIP_SHEETS_ENDPOINT || ''; // ---------- helpers ---------- const isEmail = (s) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test((s || '').trim()); // Phone validation is delegated to intl-tel-input's iti.isValidNumber(). // ---------- Apply (single step) ---------- function Apply({ copy }) { const a = copy.apply; const isOther = (procVal, opts) => procVal && procVal === opts[opts.length - 1]; // last = "Other" const initialData = { name: '', email: '', phone: '', // raw input — what the user types phoneE164: '', // canonical E.164 from iti.getNumber() phoneCountry: 'US',// ISO2 from iti.getSelectedCountryData() phoneValid: false, // from iti.isValidNumber() contactMethod: '', procedure: '', otherProcedure: '', timeline: '', preferredLang: '', consent: false, }; const [data, setData] = React.useState(initialData); const [touched, setTouched] = React.useState({}); const [submitting, setSubmitting] = React.useState(false); const [submitted, setSubmitted] = React.useState(false); const [submitError, setSubmitError] = React.useState(false); // Map localized contact-method labels to canonical wire values. const contactCanonical = { 'Email': 'Email', 'Phone call': 'Phone', 'Llamada telefónica': 'Phone', 'WhatsApp': 'WhatsApp', }; // Map localized timeline labels to canonical wire codes. const timelineCanonical = { 'Within 1 month': '0-1m', 'En 1 mes': '0-1m', '1–3 months': '1-3m', '1–3 meses': '1-3m', '3–6 months': '3-6m', '3–6 meses': '3-6m', '6+ months': '6m+', 'Más de 6 meses': '6m+', 'Just exploring': 'exploring', 'Estoy explorando': 'exploring', }; // Map localized specialty labels to stable snake_case codes. const specialtyCanonical = { 'Cosmetic Surgery': 'cosmetic_surgery', 'Cirugía estética': 'cosmetic_surgery', 'Dental': 'dental', 'Odontología': 'dental', 'Bariatric': 'bariatric', 'Bariátrica': 'bariatric', 'Hip / Knee Replacement': 'hip_knee_replacement', 'Reemplazo de cadera / rodilla': 'hip_knee_replacement', 'Fertility (IVF)': 'fertility_ivf', 'Fertilidad (FIV)': 'fertility_ivf', 'Ophthalmic': 'ophthalmic', 'Oftálmica': 'ophthalmic', 'Hair Transplant': 'hair_transplant', 'Implante Capilar': 'hair_transplant', 'I am not sure yet': 'not_sure', 'No estoy seguro': 'not_sure', 'Other': 'other', 'Otro': 'other', }; const otherSelected = isOther(data.procedure, a.procOpts); // Field-level validation. Returns null if valid, else error key. const fieldErrors = React.useMemo(() => { const e = {}; if (!data.name.trim()) e.name = 'required'; if (!data.email.trim()) e.email = 'required'; else if (!isEmail(data.email)) e.email = 'email'; if (!data.phone.trim()) e.phone = 'required'; else if (!data.phoneValid) e.phone = 'phone'; if (!data.contactMethod) e.contactMethod = 'required'; if (!data.procedure) e.procedure = 'required'; if (otherSelected && !data.otherProcedure.trim()) e.otherProcedure = 'required'; if (!data.timeline) e.timeline = 'required'; if (!data.preferredLang) e.preferredLang = 'required'; if (!data.consent) e.consent = 'consent'; return e; }, [data, otherSelected]); const errorMsg = (key) => { const k = fieldErrors[key]; if (!k) return null; return a.errors[k] || a.errors.required; }; const showErr = (key) => touched[key] && !!fieldErrors[key]; const isValid = Object.keys(fieldErrors).length === 0; const update = (patch) => setData(d => ({ ...d, ...patch })); const markTouched = (key) => setTouched(t => ({ ...t, [key]: true })); const submit = async () => { if (!isValid || submitting) return; setTouched({ name: true, email: true, phone: true, contactMethod: true, procedure: true, otherProcedure: true, timeline: true, preferredLang: true, consent: true, }); setSubmitting(true); setSubmitError(false); const specialty = otherSelected ? 'other' : (specialtyCanonical[data.procedure] || 'other'); const payload = { full_name: data.name.trim(), email: data.email.trim(), phone: data.phoneE164 || data.phone.trim(), specialty, ...(otherSelected ? { specialty_other: data.otherProcedure.trim() } : {}), timeline: timelineCanonical[data.timeline] || data.timeline, preferred_contact_method: contactCanonical[data.contactMethod] || data.contactMethod, preferred_language: data.preferredLang === 'English' ? 'EN' : 'ES', privacy_consent: !!data.consent, country: data.phoneCountry, source: 'Landing Page', }; try { await fetch(SHEETS_ENDPOINT, { method: 'POST', mode: 'no-cors', headers: { 'Content-Type': 'text/plain;charset=utf-8' }, body: JSON.stringify(payload), }); // no-cors: response is opaque. Treat lack of exception as success. setData(initialData); setTouched({}); setSubmitted(true); } catch (err) { console.warn('SannaTrip apply submit failed', err); setSubmitError(true); } finally { setSubmitting(false); } }; // ---------- success state ---------- if (submitted) { const msg = a.thanksTpl; return (
{msg}
); } // ---------- form ---------- return (
{/* Left: copy */}
{a.eyebrow}
{a.title}

{a.sub}

{/* Right: form */}
{/* Name */} update({ name: e.target.value })} onBlur={() => markTouched('name')} placeholder={a.placeholders.name} invalid={showErr('name')} /> {/* Email */} update({ email: e.target.value })} onBlur={() => markTouched('email')} placeholder={a.placeholders.email} invalid={showErr('email')} /> {/* Phone */} update({ phone: raw, phoneE164: e164, phoneCountry: country, phoneValid: valid, })} onBlur={() => markTouched('phone')} placeholder={a.placeholders.phone} invalid={showErr('phone')} /> {/* Procedure of interest */} { update({ procedure: v }); markTouched('procedure'); }} invalid={showErr('procedure')} /> {/* Other procedure (conditional) */} {otherSelected && (
update({ otherProcedure: e.target.value })} onBlur={() => markTouched('otherProcedure')} placeholder={a.placeholders.other} invalid={showErr('otherProcedure')} autoFocus /> {showErr('otherProcedure') && ( {errorMsg('otherProcedure')} )}
)} {/* Timeline */} { update({ timeline: v }); markTouched('timeline'); }} invalid={showErr('timeline')} /> {/* Preferred contact method */} { update({ contactMethod: v }); markTouched('contactMethod'); }} invalid={showErr('contactMethod')} /> {/* Preferred language */} { update({ preferredLang: v }); markTouched('preferredLang'); }} invalid={showErr('preferredLang')} /> {/* Consent */}
{ update({ consent: v }); markTouched('consent'); }} text={a.consentText} policy={a.consentPolicy} policyHref={a.consentPolicyHref} invalid={showErr('consent')} /> {showErr('consent') && {errorMsg('consent')}}
{/* Submit */}
{submitError && (
{a.errors.submit}
)}
{ if (isValid && !submitting) submit(); }} style={{ opacity: (!isValid || submitting) ? 0.4 : 1, cursor: (!isValid || submitting) ? 'not-allowed' : 'pointer', pointerEvents: submitting ? 'none' : 'auto', }} > {submitting ? '…' : (submitError ? a.labels.retry : a.labels.submit)}
); } // ---------- field wrapper ---------- function Field({ label, error, children }) { return (
{label} {children} {error && {error}}
); } function FieldLabel({ children }) { return ( ); } function ErrorText({ children }) { return (
{children}
); } // ---------- text input with invalid state ---------- function TextInput({ invalid, style, ...props }) { const baseBorder = invalid ? '#B8312F' : TOKENS.color.hairline; return ( ); } // ---------- phone input — intl-tel-input v23 (global, searchable, E.164) ---- // The wrapping .iti element holds the bottom hairline (see CSS in ). // We feature-detect window.intlTelInput so Cloudflare Rocket Loader / lazy // CDN delivery doesn't race the React mount. function PhoneInput({ value, onChange, onBlur, placeholder, invalid }) { const inputRef = React.useRef(null); const itiRef = React.useRef(null); const onChangeRef = React.useRef(onChange); React.useEffect(() => { onChangeRef.current = onChange; }, [onChange]); // Initialize once. React.useEffect(() => { const el = inputRef.current; if (!el) return; let cancelled = false; let cleanupFns = []; const boot = () => { if (cancelled || !window.intlTelInput) { if (!cancelled) setTimeout(boot, 50); return; } const iti = window.intlTelInput(el, { initialCountry: 'us', separateDialCode: true, countrySearch: true, formatAsYouType: true, // IP geolocation fallback — only used if initialCountry can't resolve. geoIpLookup: (success) => { fetch('https://ipapi.co/json/') .then(r => r.ok ? r.json() : Promise.reject()) .then(d => success((d && d.country_code) ? d.country_code : 'us')) .catch(() => success('us')); }, // utils is bundled into intlTelInputWithUtils.min.js (loaded in ), // so no loadUtils call is needed — isValidNumber + getNumber work immediately. }); itiRef.current = iti; const emit = () => { const valid = !!(iti.isValidNumber && iti.isValidNumber()); const country = (iti.getSelectedCountryData().iso2 || 'us').toUpperCase(); const e164 = valid && iti.getNumber ? iti.getNumber() : ''; onChangeRef.current && onChangeRef.current({ raw: el.value, e164, country, valid, }); }; const onInput = () => emit(); const onCountry = () => emit(); el.addEventListener('input', onInput); el.addEventListener('countrychange', onCountry); cleanupFns.push(() => { el.removeEventListener('input', onInput); el.removeEventListener('countrychange', onCountry); try { iti.destroy(); } catch (_) {} }); // Initial emit so country defaults propagate up. emit(); // Re-emit once utils.js finishes loading so validation flips for any // value the user typed before utils was ready. if (iti.promise && typeof iti.promise.then === 'function') { iti.promise.then(() => { if (!cancelled) emit(); }).catch(() => {}); } }; boot(); return () => { cancelled = true; cleanupFns.forEach(fn => fn()); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Toggle invalid class on the wrapper when the field-level error flips. React.useEffect(() => { const el = inputRef.current; if (!el) return; const wrap = el.closest('.iti'); if (wrap) wrap.classList.toggle('is-invalid', !!invalid); }, [invalid]); return ( ); } // Tag the .iti wrapper with our scoping class once it's been built. // (intl-tel-input doesn't take a wrapperClass option; we add it post-init.) if (typeof document !== 'undefined') { const tagWrapper = () => { document.querySelectorAll('input[data-st-iti]').forEach((el) => { const wrap = el.closest('.iti'); if (wrap && !wrap.classList.contains('st-iti')) wrap.classList.add('st-iti'); }); }; // Observe the form area for inserted .iti wrappers. const mo = new MutationObserver(tagWrapper); mo.observe(document.documentElement, { childList: true, subtree: true }); } // ---------- pill option group ---------- function PillGroup({ options, value, onChange, invalid }) { return (
{options.map(o => { const sel = value === o; return ( ); })}
); } // ---------- privacy consent checkbox ---------- // Renders `text` with the substring `{policy}` replaced by an . // The checkbox button and the label are siblings so the link click does not toggle // the check (a nested inside a e.stopPropagation()} style={{ color: TOKENS.color.navy, textDecoration: 'underline', textDecorationColor: TOKENS.color.pink, textUnderlineOffset: '3px', }} > {policy} {suffix} ); } // ---------- footer (unchanged) ---------- function Footer({ copy }) { return ( ); } Object.assign(window, { Apply, Footer });