// pesito — Apply form sheet (8 real steps, real inputs, real submission) const ApplySheet = ({ lang, open, onClose, principal, term, quincenal, total }) => { const [step, setStep] = React.useState(0); const [done, setDone] = React.useState(false); const [submitting, setSubmitting] = React.useState(false); const [result, setResult] = React.useState(null); // OTP state — phone is verified by SMS code via /apply/{id}/otp/{send,verify} const [appId, setAppId] = React.useState(null); const [otpSending, setOtpSending] = React.useState(false); const [otpVerifying, setOtpVerifying] = React.useState(false); const [otpSent, setOtpSent] = React.useState(false); const [otpCode, setOtpCode] = React.useState(''); const [otpError, setOtpError] = React.useState(''); const [otpCooldown, setOtpCooldown] = React.useState(0); React.useEffect(() => { if (otpCooldown > 0) { const id = setTimeout(() => setOtpCooldown(otpCooldown - 1), 1000); return () => clearTimeout(id); } }, [otpCooldown]); const [form, setForm] = React.useState({ // step 1 — Tú firstName: '', lastName: '', secondLastName: '', gender: '', dob: '', maritalStatus: '', nationality: 'MX', // step 2 — Contacto phone: '+52 ', phoneVerified: false, email: '', secondaryPhone: '', // step 2.5 — Address (lives with contact in MX flow) addrStreet: '', addrExterior: '', addrInterior: '', addrColonia: '', addrCity: '', addrState: '', addrCp: '', addrYears: '', // step 3 — Trabajo employmentStatus: '', employer: '', jobTitle: '', monthlyIncome: '', yearsAtJob: '', incomeType: '', // step 4 — Banco clabe: '', // step 5 — Documentos idDocType: 'INE', idDocNumber: '', idDocExpiry: '', curp: '', rfc: '', // step 6 — Referencias refs: [ { name: '', phone: '', relation: '' }, { name: '', phone: '', relation: '' }, ], // step 7 — Confirmar (consent) contractAccepted: true, privacyAccepted: true, buroAccepted: true, }); React.useEffect(() => { if (open) { setStep(0); setDone(false); setSubmitting(false); setResult(null); setAppId(null); setOtpSent(false); setOtpCode(''); setOtpError(''); setOtpCooldown(0); } }, [open]); // Lazy-create the Application so we have an id for OTP send/verify. // Idempotent within a single sheet open: first call creates, subsequent return cached id. async function ensureApp() { if (appId) return appId; const r = await fetch('/api/v1/apply/start', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ amount: principal, termCount: term, termUnit: 'quincena' }), }); if (!r.ok) throw new Error('start failed: ' + r.status); const j = await r.json(); setAppId(j.applicationId); return j.applicationId; } async function sendOtp() { setOtpError(''); setOtpSending(true); try { const id = await ensureApp(); const phone = (form.phone || '').replace(/\s+/g, ''); const r = await fetch('/api/v1/apply/' + id + '/otp/send', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ phone }), }); const j = await r.json().catch(() => ({})); if (!r.ok || j.ok === false) { if (j.reason === 'cooldown' && j.cooldown) { setOtpCooldown(j.cooldown); setOtpError(lang === 'es' ? `Espera ${j.cooldown}s para reenviar` : `Подождите ${j.cooldown} с до повторной отправки`); } else { setOtpError(lang === 'es' ? 'No se pudo enviar el código' : 'Не удалось отправить код'); } return; } setOtpSent(true); setOtpCooldown(30); } catch (e) { setOtpError(String(e.message || e)); } finally { setOtpSending(false); } } async function verifyOtp() { setOtpError(''); setOtpVerifying(true); try { const id = await ensureApp(); const phone = (form.phone || '').replace(/\s+/g, ''); const r = await fetch('/api/v1/apply/' + id + '/otp/verify', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ phone, code: otpCode }), }); const j = await r.json().catch(() => ({})); if (!r.ok || j.ok === false) { setOtpError(lang === 'es' ? 'Código incorrecto' : 'Неверный код'); return; } set('phoneVerified', true); } catch (e) { setOtpError(String(e.message || e)); } finally { setOtpVerifying(false); } } const set = (k, v) => setForm(prev => ({ ...prev, [k]: v })); const setRef = (i, k, v) => setForm(prev => ({ ...prev, refs: prev.refs.map((r, idx) => idx === i ? { ...r, [k]: v } : r), })); // Sandbox demo: phones in +52 55 0000 0001..0999 auto-mark verified // and prefill identity to keep the path runnable end-to-end. function fillDemoIfSandboxPhone(phoneValue) { // React state is async — callers must pass the current phone string // directly (e.g. from the input event) rather than relying on form.phone closure. const norm = (phoneValue || form.phone || '').replace(/\s+/g, ''); if (/^\+?525500000\d{3}$/.test(norm)) { // matches +52 55 0000 0001..0999 // NB: phoneVerified is no longer set here — user still has to send and // verify the OTP. Demo phones get TEST_MAGIC_CODE 000000 from the backend // so it stays trivial to verify, but the verification flow still runs. setForm(prev => ({ ...prev, firstName: prev.firstName || 'Demo', lastName: prev.lastName || 'Borrower', secondLastName: prev.secondLastName || 'Test', gender: prev.gender || 'F', dob: prev.dob || '1990-01-15', maritalStatus: prev.maritalStatus || 'single', email: prev.email || 'demo-' + Date.now() + '@test.mx', addrStreet: prev.addrStreet || 'Av Reforma', addrExterior: prev.addrExterior || '100', addrColonia: prev.addrColonia || 'Centro', addrCity: prev.addrCity || 'CDMX', addrState: prev.addrState || 'CDMX', addrCp: prev.addrCp || '06600', addrYears: prev.addrYears || '5', employmentStatus: prev.employmentStatus || 'employed', employer: prev.employer || 'Test Co', jobTitle: prev.jobTitle || 'Engineer', monthlyIncome: prev.monthlyIncome || '25000', yearsAtJob: prev.yearsAtJob || '3', incomeType: prev.incomeType || 'salary', clabe: prev.clabe || '012180001234567899', idDocNumber: prev.idDocNumber || '0000000000', idDocExpiry: prev.idDocExpiry || '2030-01-01', curp: prev.curp || 'HEGJ850315HDFLNSA9', rfc: prev.rfc || 'XAXX010101000', refs: prev.refs[0].name ? prev.refs : [ { name: 'Carlos Reyes', phone: '+525500000088', relation: 'family' }, { name: 'Lucía Méndez', phone: '+525500000077', relation: 'friend' }, ], })); } } // Per-step gate: which fields are required before "Siguiente" enables. function canProceed() { if (step === 0) return true; // monto/plazo come from calculator if (step === 1) return form.firstName && form.lastName && form.dob && form.gender; if (step === 2) return /\d{10,}/.test(form.phone) && /\S+@\S+\.\S+/.test(form.email) && form.addrStreet && form.addrCity && form.addrCp && form.phoneVerified; if (step === 3) return form.employmentStatus && (form.employmentStatus === 'unemployed' || form.monthlyIncome); if (step === 4) return /^\d{18}$/.test((form.clabe || '').replace(/\s+/g, '')); if (step === 5) return form.idDocNumber && form.curp && form.rfc; if (step === 6) return form.refs[0].name && form.refs[0].phone && form.refs[1].name && form.refs[1].phone; if (step === 7) return form.contractAccepted && form.privacyAccepted && form.buroAccepted; return true; } async function submitApplication() { setSubmitting(true); try { const applicationId = await ensureApp(); const data = { firstName: form.firstName, lastName: form.lastName, secondLastName: form.secondLastName, gender: form.gender, maritalStatus: form.maritalStatus, nationality: form.nationality, dob: form.dob, phone: form.phone.replace(/\s+/g, ''), phoneVerified: form.phoneVerified, email: form.email, secondaryPhone: form.secondaryPhone.replace(/\s+/g, '') || undefined, address: { street: form.addrStreet, exterior: form.addrExterior, interior: form.addrInterior, colonia: form.addrColonia, city: form.addrCity, state: form.addrState, cp: form.addrCp, yearsAtAddress: Number(form.addrYears) || 0, }, employment: { status: form.employmentStatus, employer: form.employer, jobTitle: form.jobTitle, monthlyIncome: Number(form.monthlyIncome) || 0, yearsAtJob: Number(form.yearsAtJob) || 0, incomeType: form.incomeType, }, clabe: form.clabe.replace(/\s+/g, ''), idDocType: form.idDocType, idDocNumber: form.idDocNumber, idDocExpiry: form.idDocExpiry, curp: form.curp.toUpperCase(), rfc: form.rfc.toUpperCase(), references: form.refs.filter(r => r.name && r.phone) .map(r => ({ ...r, phone: r.phone.replace(/\s+/g, '') })), consents: { contract: form.contractAccepted, privacy: form.privacyAccepted, buroDeCredito: form.buroAccepted, }, }; const dataRes = await fetch('/api/v1/apply/' + applicationId + '/data', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(data), }); if (!dataRes.ok) throw new Error('data failed: ' + dataRes.status); const submitRes = await fetch('/api/v1/apply/' + applicationId + '/submit', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}', }); const sb = await submitRes.json().catch(() => ({})); if (!submitRes.ok) throw new Error(sb.error || ('submit failed: ' + submitRes.status)); setResult({ applicationId, status: sb.decision || 'SUBMITTED', offer: sb.offer }); setDone(true); } catch (e) { setResult({ error: String(e.message || e) }); setDone(true); } finally { setSubmitting(false); } } if (!open) return null; const stepsEs = ['Monto y plazo', 'Tú', 'Contacto', 'Trabajo', 'Banco', 'Documentos', 'Referencias', 'Confirmar']; const stepsRu = ['Сумма и срок', 'Вы', 'Контакт', 'Работа', 'Банк', 'Документы', 'Контакты', 'Подтвердить']; const steps = lang === 'es' ? stepsEs : stepsRu; const t = lang === 'es' ? { title: 'Solicitud express', sub: '8 pasos. La mayoría tarda 3 minutos.', summary: 'Tu préstamo', contract: 'He leído y acepto el contrato y el aviso de privacidad.', privacy: 'Acepto el tratamiento de datos personales.', buro: 'Autorizo consulta a Buró de Crédito.', sign: 'Firmar y solicitar', next: 'Siguiente', back: 'Volver', confirmTitle: '¿Todo en orden?', confirmSub: 'Esto es lo que vas a firmar. Léelo, dilo en voz alta si quieres. Nadie te apura.', successTitle: '¡Listo!', successSub: 'Tu solicitud está en revisión. Te avisamos por SMS y WhatsApp en menos de 2 minutos.', } : { title: 'Экспресс-заявка', sub: '8 шагов. У большинства уходит 3 минуты.', summary: 'Ваш заём', contract: 'Прочитал и принимаю договор и уведомление о приватности.', privacy: 'Согласен на обработку персональных данных.', buro: 'Разрешаю запрос в Бюро Кредитных Историй.', sign: 'Подписать и подать', next: 'Далее', back: 'Назад', confirmTitle: 'Всё в порядке?', confirmSub: 'Вот что вы подпишете. Читайте, не торопим.', successTitle: 'Готово!', successSub: 'Заявка на проверке. Сообщим по SMS и WhatsApp за 2 минуты.', }; const cat = 518; // ─── Field primitives ───────────────────────────────────────────── const fieldStyle = { width: '100%', font: '500 16px var(--font-body)', background: 'var(--surface-2)', border: '1px solid var(--line)', borderRadius: 10, padding: '13px 14px', color: 'var(--ink)', transition: 'border-color 160ms ease, background 160ms ease', outline: 'none', }; const labelStyle = { display: 'block', fontFamily: 'var(--font-body)', fontWeight: 600, fontSize: 11, letterSpacing: '0.12em', textTransform: 'uppercase', color: 'var(--ink-soft)', marginBottom: 6, }; const Field = ({ label, children, hint }) => ( ); const Input = (p) => ; const Select = ({ children, ...p }) => ; const Row = ({ children, cols = 2 }) => (
{children}
); return (
e.stopPropagation()} style={{ background: 'var(--surface)', borderRadius: '24px 24px 0 0', width: '100%', maxWidth: 580, padding: 24, paddingBottom: 20, animation: 'sheetIn 360ms cubic-bezier(0.2, 0.8, 0.2, 1)', boxShadow: '0 -10px 40px rgba(63,38,24,0.2)', maxHeight: '94vh', overflow: 'auto', }}>
{!done ? (
{t.title}
{t.sub}
{/* Stepper bar */}
{steps.map((s, i) => (
))}
{String(step + 1).padStart(2, '0')} / {String(steps.length).padStart(2, '0')} · {steps[step]}
{/* ─── STEP 0 — Monto y plazo (read-only summary; sliders live on landing) ─── */} {step === 0 && (
{lang === 'es' ? '¿Cuánto y por cuánto?' : 'Сколько и насколько?'}
{lang === 'es' ? 'Monto' : 'Сумма'}
${principal.toLocaleString('en-US')} MXN
{lang === 'es' ? 'Plazo' : 'Срок'}
{term} {lang === 'es' ? 'quincenas' : 'двухнед.'}
{lang === 'es' ? 'Para cambiar, cierra y mueve los sliders en la calculadora.' : 'Чтобы изменить, закрой и подвинь ползунки в калькуляторе.'}
)} {/* ─── STEP 1 — Tú (identity) ─── */} {step === 1 && (
{lang === 'es' ? 'Cuéntanos de ti' : 'Расскажите о себе'}
set('firstName', e.target.value)} /> set('lastName', e.target.value)} /> set('secondLastName', e.target.value)} /> set('dob', e.target.value)} />
)} {/* ─── STEP 2 — Contacto + dirección ─── */} {step === 2 && (
{lang === 'es' ? '¿Cómo te contactamos?' : 'Как с вами связаться?'}
{ set('phone', e.target.value); // changing the phone invalidates any prior verification if (form.phoneVerified) set('phoneVerified', false); if (otpSent) { setOtpSent(false); setOtpCode(''); setOtpError(''); } fillDemoIfSandboxPhone(e.target.value); }} placeholder="+52 55 1234 5678" /> {/* OTP verification — phone must be confirmed by SMS code */} {form.phoneVerified ? (
{lang === 'es' ? 'Teléfono verificado' : 'Телефон подтверждён'}
) : !otpSent ? (
{otpError && (
{otpError}
)}
) : (
{lang === 'es' ? 'Código SMS' : 'Код из SMS'}

{lang === 'es' ? `Enviamos un código de 6 dígitos al ${form.phone}.` : `Отправили 6-значный код на ${form.phone}.`}

{ setOtpCode(e.target.value.replace(/\D/g, '')); setOtpError(''); }} style={{ flex: 1, fontFamily: 'var(--font-mono)', fontSize: 18, letterSpacing: '0.4em', textAlign: 'center' }} placeholder="000000" />
{otpError && (
{otpError}
)}
)} set('email', e.target.value)} placeholder="tu@correo.com" /> set('secondaryPhone', e.target.value)} />
{lang === 'es' ? 'Dirección actual' : 'Текущий адрес'}
set('addrStreet', e.target.value)} /> set('addrExterior', e.target.value)} /> set('addrInterior', e.target.value)} /> set('addrColonia', e.target.value)} /> set('addrCp', e.target.value)} placeholder="06600" /> set('addrCity', e.target.value)} /> set('addrState', e.target.value)} /> set('addrYears', e.target.value)} />
)} {/* ─── STEP 3 — Trabajo ─── */} {step === 3 && (
{lang === 'es' ? '¿De qué vives?' : 'Где работаете?'}
{form.employmentStatus && form.employmentStatus !== 'unemployed' && ( set('employer', e.target.value)} /> set('jobTitle', e.target.value)} /> set('monthlyIncome', e.target.value)} /> set('yearsAtJob', e.target.value)} /> )}
)} {/* ─── STEP 4 — Banco ─── */} {step === 4 && (
{lang === 'es' ? '¿A dónde te depositamos?' : 'Куда переводить?'}

{lang === 'es' ? 'CLABE de 18 dígitos — número que identifica tu cuenta. Lo encuentras en la app de tu banco.' : 'CLABE из 18 цифр — номер вашего банковского счёта в Мексике. Найдёте в приложении банка.'}

set('clabe', e.target.value.replace(/\D/g, ''))} style={{ fontFamily: 'var(--font-mono)', letterSpacing: '0.05em' }} placeholder="012180001234567899" />
{lang === 'es' ? 'La cuenta debe estar a tu nombre. No aceptamos transferencias a terceros.' : 'Счёт должен быть на ваше имя. Переводы на третьих лиц не принимаем.'}
)} {/* ─── STEP 5 — Documentos ─── */} {step === 5 && (
{lang === 'es' ? 'Documentos oficiales' : 'Документы'}
set('idDocExpiry', e.target.value)} /> set('idDocNumber', e.target.value)} style={{ fontFamily: 'var(--font-mono)' }} /> set('curp', e.target.value.toUpperCase())} maxLength={18} style={{ fontFamily: 'var(--font-mono)', textTransform: 'uppercase', letterSpacing: '0.05em' }} placeholder="HEGJ850315HDFLNSA9" /> set('rfc', e.target.value.toUpperCase())} maxLength={13} style={{ fontFamily: 'var(--font-mono)', textTransform: 'uppercase', letterSpacing: '0.05em' }} placeholder="XAXX010101000" />
)} {/* ─── STEP 6 — Referencias ─── */} {step === 6 && (
{lang === 'es' ? 'Dos contactos' : 'Два контакта'}

{lang === 'es' ? 'Personas que te conocen — un familiar y otra persona. No las llamamos a menos que no podamos contactarte a ti.' : 'Люди которые вас знают — один родственник и ещё кто-то. Звоним им только если не можем дозвониться до вас.'}

{form.refs.map((r, i) => (
{lang === 'es' ? `Contacto ${i + 1}` : `Контакт ${i + 1}`}
setRef(i, 'name', e.target.value)} /> setRef(i, 'phone', e.target.value)} />
))}
)} {/* ─── STEP 7 — Confirmar ─── */} {step === 7 && (
{t.confirmTitle}
{t.confirmSub}
{t.summary}
{lang === 'es' ? 'Monto' : 'Сумма'}
${principal.toLocaleString('en-US')}
{lang === 'es' ? 'Pago quincenal' : 'Платёж'}
${quincenal.toLocaleString('en-US')}
CAT
{cat}%
{lang === 'es' ? 'Total' : 'Всего'}
${total.toLocaleString('en-US')}
{[ ['contractAccepted', t.contract], ['privacyAccepted', t.privacy], ['buroAccepted', t.buro], ].map(([k, label]) => ( ))}
)}
{step > 0 && ( )}
) : (
{result?.error ? (
{lang === 'es' ? 'Algo salió mal' : 'Что-то пошло не так'}

{result.error}

) : (
{t.successTitle}

{t.successSub}

{result?.applicationId && (
{lang === 'es' ? 'Aplicación' : 'Заявка'} {' '} #{String(result.applicationId).slice(-8)} {' · '} {result.status}
)}
)}
)}
); }; /* Add field-focus styling globally for sheet inputs */ if (typeof document !== 'undefined' && !document.getElementById('apply-sheet-style')) { const s = document.createElement('style'); s.id = 'apply-sheet-style'; s.textContent = ` input:focus, select:focus { border-color: var(--brand) !important; background: var(--surface) !important; } `; document.head.appendChild(s); } Object.assign(window, { ApplySheet });