// 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 }) => (
{lang === 'es' ? `Enviamos un código de 6 dígitos al ${form.phone}.` : `Отправили 6-значный код на ${form.phone}.`}
{lang === 'es' ? 'CLABE de 18 dígitos — número que identifica tu cuenta. Lo encuentras en la app de tu banco.' : 'CLABE из 18 цифр — номер вашего банковского счёта в Мексике. Найдёте в приложении банка.'}
{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) => ({result.error}
{t.successSub}
{result?.applicationId && (