// 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 (
);
}
// ---------- 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