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