// payment.jsx — Page 2: Payment // Provider hosted forms (Lava.top / Stripe) open in an overlay after "Оплатить". function PaymentPage({ navigate, formData, setFormData, tweaks, devMode }) { const [method, setMethod] = React.useState("lava"); const [checkoutOpen, setCheckoutOpen] = React.useState(false); const [loginOpen, setLoginOpen] = React.useState(false); const [emailErr, setEmailErr] = React.useState(""); const [priceLabel, setPriceLabel] = React.useState(""); const [pageContent, setPageContent] = React.useState(null); const [lavaWaiting, setLavaWaiting] = React.useState(false); const [lavaStatus, setLavaStatus] = React.useState("opening"); // opening | paying | blocked | failed | timeout | success const emailCardRef = React.useRef(null); const emailInputRef = React.useRef(null); const lavaPopupRef = React.useRef(null); const lavaUrlRef = React.useRef(""); const lavaSinceRef = React.useRef(""); const lavaIntervalRef = React.useRef(null); React.useEffect(() => { fetch("/api/payment-page") .then(r => r.ok ? r.json() : null) .then(d => { if (!d) return; setPageContent(d); if (d.price) setPriceLabel("$" + d.price); if (d.destination_default) setFormData(prev => ({...prev, destination: d.destination_default})); }) .catch(() => {}); }, []); const pc = pageContent || {}; React.useEffect(() => { if (method === "lava" && pc.lava_enabled === false) setMethod("stripe"); if (method === "stripe" && pc.stripe_enabled === false) setMethod("lava"); }, [pc.lava_enabled, pc.stripe_enabled]); const isValidEmail = (e) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test((e||"").trim()); const emailOk = isValidEmail(formData.email) && formData.emailVerified; const guardEmail = () => { setEmailErr(formData.emailVerified ? "Укажите корректный email перед оплатой" : (pc.pay_button_locked_text || "Сначала подтвердите email") + " кодом из письма"); emailCardRef.current && emailCardRef.current.scrollIntoView({block:"center", behavior:"smooth"}); setTimeout(() => emailInputRef.current && emailInputRef.current.focus(), 350); }; const openLava = () => { if (!emailOk) { guardEmail(); return; } const sw = screen.width, sh = screen.height; const pw = Math.min(780, sw - 40), ph = Math.min(700, sh - 40); const win = window.open("about:blank", "lava_checkout", `width=${pw},height=${ph},left=${Math.round((sw-pw)/2)},top=${Math.round((sh-ph)/2)}`); lavaPopupRef.current = win; lavaUrlRef.current = ""; setLavaStatus(win ? "opening" : "blocked"); setLavaWaiting(true); const meta = collectClientMeta(); (async () => { try { const customerName = [formData.firstName, formData.lastName].filter(Boolean).join(" ").trim(); const res = await fetch("/api/payments/lava/create-invoice", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: formData.email, name: customerName || undefined, meta }), }); if (!res.ok) throw new Error("invoice_error"); const data = await res.json(); if (!data.url) throw new Error("no_url"); lavaUrlRef.current = data.url; lavaSinceRef.current = data.since || ""; if (win && !win.closed) { win.location.href = data.url; setLavaStatus("paying"); } else { setLavaStatus("blocked"); } const startTime = Date.now(); const id = setInterval(async () => { if (Date.now() - startTime > 5 * 60 * 1000) { clearInterval(id); setLavaStatus(s => s === "success" ? s : "timeout"); return; } try { const sinceParam = lavaSinceRef.current ? `&since=${encodeURIComponent(lavaSinceRef.current)}` : ""; const pr = await fetch(`/api/payments/poll?email=${encodeURIComponent(formData.email)}${sinceParam}`); const pd = await pr.json(); if (pd.status === "succeeded") { clearInterval(id); const w = lavaPopupRef.current; if (w && !w.closed) w.close(); setLavaStatus("success"); setTimeout(async () => { try { const cr = await fetch("/api/auth/claim-payment", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: formData.email, since: lavaSinceRef.current }), }); const cd = await cr.json(); if (cd.token) { window.location.href = `/?token=${cd.token}`; return; } } catch {} setLavaWaiting(false); navigate("setup"); }, 1100); } else if (pd.status === "failed") { clearInterval(id); setLavaStatus("failed"); } } catch {} }, 3000); lavaIntervalRef.current = id; } catch { setLavaStatus("failed"); } })(); }; const closeLava = () => { if (lavaIntervalRef.current) clearInterval(lavaIntervalRef.current); const w = lavaPopupRef.current; if (w && !w.closed) w.close(); setLavaWaiting(false); }; const openCheckout = () => { if (!emailOk) { guardEmail(); return; } if (method === "lava") { openLava(); return; } setCheckoutOpen(true); }; const openLogin = () => setLoginOpen(true); const onCheckoutSuccess = () => { setCheckoutOpen(false); setTimeout(() => navigate("setup"), 200); }; const onLoginSuccess = () => { setLoginOpen(false); setTimeout(() => navigate("setup"), 200); }; return (
{!pageContent && (
)}
Шаг 1 из 3 · Оплата

{pc.heading || "Подтверждение консультации"}

{pc.subheading || "Оплатите консультацию, чтобы получить доступ к подготовке и бронированию времени."}

{/* Returning-user entry point (device-bound magic links → user is always sent here) */}
{/* Left: email + included + method */}
{/* Email для входа — критическое поле для device-bound magic links */} { setFormData({...formData, email: v}); if (emailErr) setEmailErr(""); }} onVerifiedChange={(v) => setFormData({...formData, emailVerified: v})} error={emailErr} isValidEmail={isValidEmail} devMode={devMode} pc={pc} />
{pc.bullets_section_title || "Что входит в консультацию"}

{pc.bullets_heading || "60-минутный личный разбор"}

    {(pc.bullets || [ "Анализ вашей ситуации и профиля", "Подбор оптимальной стратегии переезда", "Ответы на все ваши вопросы", "План дальнейших шагов", "Рекомендации и полезные ресурсы", ]).map((b, i) => (
  • {b}
  • ))}
Длительность
{pc.duration || "60 минут"}
Формат
{pc.format || "Zoom"}
Язык
{pc.language || "RU / EN"}

{pc.payment_method_title || "Способ оплаты"}

{pc.payment_method_subtitle || "Выберите тип карты — после нажатия «Оплатить» откроется защищённая форма провайдера."}

{(pc.lava_enabled === false && pc.stripe_enabled === false) ? (
Оплата временно недоступна. Пожалуйста, свяжитесь с нами.
) : (
{pc.lava_enabled !== false && ( )} {pc.stripe_enabled !== false && ( )}
)}
{pc.card_data_note || "Данные карты вводятся на стороне провайдера. Мы не получаем и не храним их."}
{/* Right: order summary */}
{lavaWaiting && ( )} {checkoutOpen && ( setCheckoutOpen(false)} onSuccess={onCheckoutSuccess}/> )} {loginOpen && ( setLoginOpen(false)} onSuccess={onLoginSuccess} devMode={devMode} pc={pc}/> )}
); } /* ───── Mail icon (local — not in shared.jsx) ───── */ function MailIcon(props) { return ( ); } /* ───── Magic-link login modal ───── Device-bound magic links: returning users on a new device enter their email to receive a fresh sign-in link. Simulated 2-step flow. */ function MagicLinkModal({ defaultEmail, onClose, onSuccess, devMode, pc = {} }) { const [email, setEmail] = React.useState(""); const [err, setErr] = React.useState(""); const [sending, setSending] = React.useState(false); const [step, setStep] = React.useState("form"); // form | sent const inputRef = React.useRef(null); React.useEffect(() => { const onKey = (e) => e.key === "Escape" && !sending && onClose(); window.addEventListener("keydown", onKey); document.body.style.overflow = "hidden"; setTimeout(() => inputRef.current && inputRef.current.focus(), 80); return () => { window.removeEventListener("keydown", onKey); document.body.style.overflow = ""; }; }, [sending, onClose]); const validEmail = (e) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e.trim()); const sendLink = async (e) => { e && e.preventDefault(); if (!validEmail(email)) { setErr("Введите корректный email"); return; } setErr(""); setSending(true); try { const res = await fetch("/api/auth/request", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: email.trim().toLowerCase() }), }); if (res.status === 429) { setErr("Слишком много запросов. Попробуйте через 10 минут."); setSending(false); return; } if (!res.ok) { setErr("Ошибка сервера. Попробуйте позже."); setSending(false); return; } setStep("sent"); } catch { setErr("Нет соединения. Проверьте интернет и попробуйте снова."); } finally { setSending(false); } }; // Demo helper: "open the link" → continue into the app const openLink = () => { setSending(true); setTimeout(onSuccess, 600); }; return (
{ if (e.target === e.currentTarget && !sending) onClose(); }}>
e.stopPropagation()}>
{pc.login_modal_title || "Вход по email"}
{!sending && ( )}
{step === "form" && (

{pc.login_modal_heading || "Войдите на этом устройстве"}

{pc.login_modal_description || "Введите email, который вы указывали при оплате. Мы отправим ссылку для входа — действительна 15 минут."}

{setEmail(e.target.value); if(err) setErr("");}}/> {err &&
{err}
}
{defaultEmail && defaultEmail !== email && ( )}
{pc.login_modal_note || "Ссылка для входа привязана к устройству, на котором её открыли. Никто другой не сможет войти по ней."}
)} {step === "sent" && (

Проверьте почту

Мы отправили ссылку для входа на {email}.

Откройте письмо на этом устройстве, чтобы войти. Ссылка действительна 15 минут.

{/* Demo CTA — only in dev mode */} {devMode && ( <>
· demo · в реальном продукте этот шаг происходит из почты
)}
Не получили письмо? Проверьте «Спам» или{" "} .
)}
); } function RadioDot({ sel }) { return (
); } function CardBadge({ type }) { const styles = { width: 32, height: 22, borderRadius: 4, display: "inline-grid", placeItems: "center", fontSize: 9, fontWeight: 700, color: "#fff", letterSpacing: ".02em", }; if (type === "visa") return VISA; if (type === "mc") return MC; if (type === "amex") return AMEX; if (type === "mir") return МИР; return null; } /* ───── Checkout modal ───── Simulated provider-hosted form. Visually differentiated: - Lava.top: white/coral accent header with Lava branding - Stripe: dark Stripe header, Stripe-style form */ function CheckoutModal({ provider, amount, formData, onClose, onSuccess }) { const [processing, setProcessing] = React.useState(false); const [step, setStep] = React.useState("form"); // form | success // Stripe — store instances in refs to avoid re-render on assignment const stripeRef = React.useRef({ inst: null, elems: null }); const stripeContainerRef = React.useRef(null); const [stripeStatus, setStripeStatus] = React.useState("loading"); // loading | ready | error const [stripeError, setStripeError] = React.useState(""); React.useEffect(() => { const onKey = (e) => e.key === "Escape" && !processing && onClose(); window.addEventListener("keydown", onKey); document.body.style.overflow = "hidden"; return () => { window.removeEventListener("keydown", onKey); document.body.style.overflow = ""; }; }, [processing, onClose]); // Init Stripe Payment Element — runs once when modal opens for Stripe React.useEffect(() => { if (provider !== "stripe") return; let mounted = true; const meta = collectClientMeta(); (async () => { try { const customerName = [formData.firstName, formData.lastName].filter(Boolean).join(" ").trim(); const res = await fetch("/api/payments/stripe/create-intent", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: formData.email, name: customerName || undefined, meta }), }); if (!res.ok) { const body = await res.json().catch(() => ({})); if (mounted) { setStripeError(body.detail || "Ошибка инициализации платежа"); setStripeStatus("error"); } return; } const { clientSecret, publishableKey } = await res.json(); if (!mounted || !stripeContainerRef.current) return; const stripe = Stripe(publishableKey); const elements = stripe.elements({ clientSecret, locale: "ru" }); const paymentEl = elements.create("payment"); // Attach listener before mount so we never miss the event paymentEl.on("ready", () => { if (mounted) setStripeStatus("ready"); }); paymentEl.mount(stripeContainerRef.current); stripeRef.current = { inst: stripe, elems: elements }; } catch (e) { if (mounted) { setStripeError("Не удалось загрузить форму оплаты"); setStripeStatus("error"); } } })(); return () => { mounted = false; }; }, [provider]); const pay = async () => { const { inst, elems } = stripeRef.current; if (!inst || !elems) return; setProcessing(true); setStripeError(""); const { error, paymentIntent } = await inst.confirmPayment({ elements: elems, confirmParams: { return_url: window.location.href }, redirect: "if_required", }); if (error) { setStripeError(error.message); setProcessing(false); } else if (paymentIntent) { setStep("success"); setProcessing(false); setTimeout(onSuccess, 1100); } }; const isLava = provider === "lava"; const accent = isLava ? "#ff6b35" : "#635bff"; // lava orange / stripe purple const accentDark = isLava ? "#e85a2a" : "#5851ec"; return (
{ if (e.target === e.currentTarget && !processing) onClose(); }}>
e.stopPropagation()}> {/* Provider header */}
{!processing && step !== "success" && ( )}
{step === "form" && (
KiwiEducation · Консультация
{formData.email || ""}
{amount}
{stripeError && (
{stripeError}
)} {stripeStatus === "loading" && !stripeError && (
Загрузка формы…
)}
Защищено · Stripe · PCI DSS Level 1
)} {step === "success" && (

Платёж успешно проведён

Письмо с доступом к консультации отправлено на {formData.email}.

)}
); } function LavaWaitingOverlay({ status, email, lavaUrl, onClose }) { const isSuccess = status === "success"; const isBlocked = status === "blocked"; const canClose = isBlocked || status === "failed" || status === "timeout"; React.useEffect(() => { document.body.style.overflow = "hidden"; return () => { document.body.style.overflow = ""; }; }, []); const openManually = () => { if (lavaUrl) window.open(lavaUrl, "lava_checkout", `width=${Math.min(780,screen.width-40)},height=${Math.min(700,screen.height-40)}`); }; const titles = { opening: "Открываем форму оплаты…", paying: "Форма оплаты открыта", blocked: "Браузер заблокировал окно", failed: "Платёж не прошёл", timeout: "Время ожидания истекло", success: "Платёж успешно проведён", }; const bodies = { opening: "Создаём счёт и открываем окно оплаты…", paying: "Завершите оплату в открывшемся окне. Эта страница обновится автоматически.", blocked: "Браузер не разрешил открыть окно оплаты. Нажмите кнопку ниже, чтобы открыть вручную.", failed: "Закройте это сообщение и попробуйте снова.", timeout: "Не получили подтверждение за 5 минут. Если оплата прошла — проверьте почту.", success: null, }; return (
{isSuccess ? (
) : isBlocked || status === "failed" ? (
×
) : (
)}

{titles[status] || ""}

{isSuccess ? <>Письмо с доступом отправлено на {email}. : bodies[status] || ""}

{isBlocked && lavaUrl && ( )} {canClose && ( )} {!canClose && !isSuccess && ( )}
); } function ProviderLogo({ provider }) { if (provider === "lava") { return (
L lava.top
); } // Stripe return (
stripe
); } function Spinner() { return ( ); } /* ───── Email gate card ───── Email is the user's identity for device-bound magic links — must be CORRECT and VERIFIED before payment unlocks. Flow: empty → enter email → "Подтвердить" sends 6-digit code → enter code → verified ✓ → can pay. "Изменить" resets back to step 1. Demo code: any 6 digits works; "000000" is rejected to show the error state. */ const EmailGateCard = React.forwardRef(function EmailGateCard( { email, verified, onEmailChange, onVerifiedChange, error, isValidEmail, inputRef, devMode, pc = {} }, ref ) { // step: "enter" (typing email) | "code" (OTP) | "verified" const initialStep = verified ? "verified" : "enter"; const [step, setStep] = React.useState(initialStep); const [draft, setDraft] = React.useState(email || ""); const [code, setCode] = React.useState(["","","","","",""]); const [sending, setSending] = React.useState(false); const [verifying, setVerifying] = React.useState(false); const [localErr, setLocalErr] = React.useState(""); const [codeErr, setCodeErr] = React.useState(""); const [resendIn, setResendIn] = React.useState(0); const codeRefs = React.useRef([]); React.useEffect(() => { setDraft(email || ""); }, [email]); React.useEffect(() => { if (resendIn <= 0) return; const t = setInterval(() => setResendIn(s => s > 0 ? s - 1 : 0), 1000); return () => clearInterval(t); }, [resendIn]); const sendCode = () => { if (!isValidEmail(draft)) { setLocalErr("Введите корректный email"); return; } setLocalErr(""); setSending(true); if (devMode) { setTimeout(() => { onEmailChange(draft.trim()); setSending(false); setStep("code"); setCode(["","","","","",""]); setCodeErr(""); setResendIn(30); setTimeout(() => codeRefs.current[0] && codeRefs.current[0].focus(), 60); }, 700); } else { fetch("/api/auth/email/send", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: draft.trim() }), }) .then(r => r.ok ? r.json() : Promise.reject(r.status)) .then(() => { onEmailChange(draft.trim()); setSending(false); setStep("code"); setCode(["","","","","",""]); setCodeErr(""); setResendIn(30); setTimeout(() => codeRefs.current[0] && codeRefs.current[0].focus(), 60); }) .catch(status => { setSending(false); setLocalErr(status === 429 ? "Слишком много запросов. Попробуйте через 10 минут." : "Не удалось отправить код. Попробуйте снова."); }); } }; const verifyCode = (full) => { const c = (full || code.join("")).trim(); if (c.length !== 6) { setCodeErr("Введите 6 цифр"); return; } setVerifying(true); setCodeErr(""); if (devMode) { setTimeout(() => { setVerifying(false); if (c === "000000") { setCodeErr("Неверный код. Попробуйте ещё раз."); setCode(["","","","","",""]); setTimeout(() => codeRefs.current[0] && codeRefs.current[0].focus(), 60); return; } onVerifiedChange(true); setStep("verified"); }, 700); } else { fetch("/api/auth/email/verify", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: draft.trim(), code: c }), }) .then(r => r.ok ? r.json() : Promise.reject()) .then(data => { setVerifying(false); if (data.verified) { onVerifiedChange(true); setStep("verified"); } else { setCodeErr("Неверный код. Попробуйте ещё раз."); setCode(["","","","","",""]); setTimeout(() => codeRefs.current[0] && codeRefs.current[0].focus(), 60); } }) .catch(() => { setVerifying(false); setCodeErr("Ошибка проверки кода. Попробуйте снова."); }); } }; const changeEmail = () => { onVerifiedChange(false); setStep("enter"); setCode(["","","","","",""]); setCodeErr(""); setLocalErr(""); setTimeout(() => inputRef.current && inputRef.current.focus(), 60); }; const onCodeInput = (i, v) => { const digit = v.replace(/\D/g, "").slice(-1); const next = [...code]; next[i] = digit; setCode(next); if (codeErr) setCodeErr(""); if (digit && i < 5) codeRefs.current[i+1] && codeRefs.current[i+1].focus(); if (next.every(d => d !== "") && next.join("").length === 6) { setTimeout(() => verifyCode(next.join("")), 100); } }; const onCodeKey = (i, e) => { if (e.key === "Backspace" && !code[i] && i > 0) { codeRefs.current[i-1] && codeRefs.current[i-1].focus(); } }; const onCodePaste = (e) => { const txt = (e.clipboardData.getData("text") || "").replace(/\D/g,"").slice(0,6); if (txt.length === 6) { e.preventDefault(); const next = txt.split(""); setCode(next); codeRefs.current[5] && codeRefs.current[5].focus(); setTimeout(() => verifyCode(txt), 100); } }; const showErr = step === "enter" ? (localErr || error) : ""; return (
{/* Header */}
{pc.email_gate_label || "Email для входа"} · обязательно
{step === "verified" && ( подтверждён )} {step === "code" && ( Шаг 2 из 2 )}
{/* STEP: enter email */} {step === "enter" && (
{ setDraft(e.target.value); if (localErr) setLocalErr(""); }} onKeyDown={(e)=>{ if (e.key === "Enter") { e.preventDefault(); sendCode(); } }} style={{fontSize:16,height:50}} disabled={sending} /> {showErr &&
{showErr}
}
)} {/* STEP: enter code */} {step === "code" && (
Мы отправили 6-значный код на{" "} {email}.{" "}
{code.map((d, i) => ( codeRefs.current[i] = el} className={"otp-cell" + (codeErr ? " err" : "")} inputMode="numeric" maxLength={1} value={d} onChange={(e)=>onCodeInput(i, e.target.value)} onKeyDown={(e)=>onCodeKey(i, e)} disabled={verifying}/> ))}
{codeErr &&
{codeErr}
}
{devMode && (
· demo · любые 6 цифр подтверждают вход. Введите 000000, чтобы увидеть ошибку.
)}
)} {/* STEP: verified */} {step === "verified" && (
{email}
Код подтверждён · доступ привязан к этому адресу
)} {/* Note */}
{pc.email_gate_note || "На этот адрес придёт ссылка для входа. Это единственный способ войти с другого устройства — проверьте email до оплаты."}
); }); if (!document.getElementById("spin-style")) { const s = document.createElement("style"); s.id = "spin-style"; s.textContent = "@keyframes spin{to{transform:rotate(360deg)}}"; document.head.appendChild(s); } function collectClientMeta() { const params = new URLSearchParams(window.location.search); const meta = {}; const utmMap = [ ["utm_source", "utmSource"], ["utm_medium", "utmMedium"], ["utm_campaign", "utmCampaign"], ["utm_term", "utmTerm"], ["utm_content", "utmContent"], ]; for (const [param, key] of utmMap) { const val = params.get(param); if (val) meta[key] = val; } try { meta.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; meta.language = navigator.language; meta.screenWidth = String(screen.width); meta.screenHeight = String(screen.height); meta.pixelRatio = String(window.devicePixelRatio || 1); } catch {} return meta; } window.PaymentPage = PaymentPage;