Files
lms-sb/src/components/student/store-checkout-modal.tsx
T
admins c2f84079ac Add DS-2 checkout modal for locked courses
Form POSTs directly to payment-router /pay/order with the student's
email prefilled, so the purchase auto-attaches to the existing account.
Payment methods are fetched from /pay/methods with a robokassa fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 16:35:30 +05:00

373 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useId, useRef, useState, type CSSProperties } from "react";
import {
PAY_METHODS_URL,
PAY_ORDER_URL,
type StoreCatalogEntry,
} from "@/lib/store-catalog";
export interface LockedCourse {
id: string;
slug: string;
title: string;
description: string | null;
coverImage: string | null;
}
interface PayMethod {
id: string;
label: string;
provider: string;
kind: "online" | "manual";
}
interface Props {
course: LockedCourse;
entry: StoreCatalogEntry;
buyer: { name: string; email: string };
onClose: () => void;
}
function formatRub(v: number) {
return `${v.toLocaleString("ru-RU")} ₽`;
}
const inputStyle: CSSProperties = {
width: "100%",
border: "2px solid var(--border)",
background: "var(--background)",
color: "var(--foreground)",
padding: "0.5rem 0.6rem",
fontSize: "14px",
fontFamily: "inherit",
outline: "none",
};
export function StoreCheckoutModal({ course, entry, buyer, onClose }: Props) {
const [selectedSku, setSelectedSku] = useState(entry.tariffs[0].sku);
const [methods, setMethods] = useState<PayMethod[] | null>(null);
const [method, setMethod] = useState("robokassa");
const [otherOpen, setOtherOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const dialogRef = useRef<HTMLDivElement>(null);
const titleId = useId();
const multiTariff = entry.tariffs.length > 1;
const currentMethod = methods?.find((m) => m.id === method);
// Payment methods dropdown, same source as the landings. On any failure the
// hidden method=robokassa keeps the checkout fully functional.
useEffect(() => {
fetch(PAY_METHODS_URL, { signal: AbortSignal.timeout(5000) })
.then((r) => (r.ok ? r.json() : Promise.reject(new Error("bad status"))))
.then((data: { methods?: PayMethod[] }) => {
const ms = data?.methods ?? [];
if (ms.length >= 2) {
setMethods(ms);
setMethod((cur) => (cur === "robokassa" ? ms[0].id : cur));
}
})
.catch(() => {});
}, []);
// Close on Escape
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [onClose]);
// Lock body scroll while the modal is open
useEffect(() => {
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = prev;
};
}, []);
// Focus the dialog on open, restore focus on close
useEffect(() => {
const prev = document.activeElement as HTMLElement | null;
dialogRef.current?.focus();
return () => prev?.focus();
}, []);
// Coming back from the payment page via bfcache must not leave the
// submit button stuck in the "submitting" state.
useEffect(() => {
const onPageShow = (e: PageTransitionEvent) => {
if (e.persisted) setSubmitting(false);
};
window.addEventListener("pageshow", onPageShow);
return () => window.removeEventListener("pageshow", onPageShow);
}, []);
// Minimal focus trap: keep Tab cycling inside the dialog
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key !== "Tab" || !dialogRef.current) return;
const focusables = dialogRef.current.querySelectorAll<HTMLElement>(
'button, [href], input, select, [tabindex]:not([tabindex="-1"])'
);
if (focusables.length === 0) return;
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
const labelClass = "block text-xs font-bold uppercase tracking-widest mb-1.5";
const primary = methods?.[0];
const rest = methods?.slice(1) ?? [];
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center px-4"
style={{ background: "rgba(50,50,50,0.45)" }}
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
tabIndex={-1}
onKeyDown={handleKeyDown}
className="w-full max-w-md max-h-[90dvh] overflow-y-auto p-6 space-y-4"
style={{
background: "var(--background)",
border: "2px solid var(--foreground)",
boxShadow: "6px 6px 0 0 var(--foreground)",
}}
>
{/* Header */}
<div className="flex items-start justify-between gap-2">
<p
className="text-xs font-bold uppercase tracking-widest"
style={{ color: "var(--muted-foreground)" }}
>
Покупка курса
</p>
<button
type="button"
onClick={onClose}
aria-label="Закрыть"
className="-mt-3 -mr-3 flex h-11 w-11 items-center justify-center"
style={{ color: "var(--muted-foreground)", fontSize: "22px", lineHeight: 1 }}
>
×
</button>
</div>
<div className="flex items-start gap-3">
{course.coverImage ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={course.coverImage}
alt=""
className="w-24 aspect-video object-cover flex-shrink-0"
style={{ border: "2px solid var(--border)" }}
/>
) : (
<div
className="w-24 aspect-video flex items-center justify-center text-xl flex-shrink-0"
style={{ backgroundColor: "var(--color-surface)", border: "2px solid var(--border)" }}
>
📚
</div>
)}
<h2 id={titleId} className="font-bold text-lg leading-tight">
{course.title}
</h2>
</div>
{course.description && (
<p className="text-sm line-clamp-4" style={{ color: "var(--muted-foreground)" }}>
{course.description}
</p>
)}
<form method="POST" action={PAY_ORDER_URL} onSubmit={() => setSubmitting(true)}>
<input type="hidden" name="name" value={buyer.name} />
<input type="hidden" name="email" value={buyer.email} />
<input type="hidden" name="method" value={method} />
{!multiTariff && <input type="hidden" name="sku" value={entry.tariffs[0].sku} />}
{/* Tariff */}
<div className="mb-4">
<span className={labelClass} style={{ color: "var(--muted-foreground)" }}>
Тариф
</span>
{multiTariff ? (
<div className="space-y-2">
{entry.tariffs.map((t) => (
<label
key={t.sku}
className="flex items-start gap-3 p-3 cursor-pointer min-h-[44px]"
style={{
border:
"2px solid " +
(selectedSku === t.sku ? "var(--foreground)" : "var(--border)"),
background: selectedSku === t.sku ? "var(--accent)" : "transparent",
}}
>
<input
type="radio"
name="sku"
value={t.sku}
checked={selectedSku === t.sku}
onChange={() => setSelectedSku(t.sku)}
className="mt-1"
style={{ accentColor: "var(--foreground)" }}
/>
<span className="flex-1">
<span className="flex items-baseline justify-between gap-2">
<span className="text-sm font-bold">{t.label}</span>
<span className="text-sm font-bold whitespace-nowrap">
{formatRub(t.priceRub)}
</span>
</span>
{t.includes && (
<span
className="block text-xs mt-0.5"
style={{ color: "var(--muted-foreground)" }}
>
{t.includes}
</span>
)}
</span>
</label>
))}
</div>
) : (
<p className="font-bold text-lg">{formatRub(entry.tariffs[0].priceRub)}</p>
)}
</div>
{/* Payment method — rendered only when the list actually loaded */}
{methods && primary && rest.length > 0 && (
<div className="mb-4">
<span className={labelClass} style={{ color: "var(--muted-foreground)" }}>
Способ оплаты
</span>
<div className="space-y-2">
<label className="flex items-center gap-3 p-2 cursor-pointer min-h-[44px]">
<input
type="radio"
name="method_ui"
checked={!otherOpen}
onChange={() => {
setOtherOpen(false);
setMethod(primary.id);
}}
style={{ accentColor: "var(--foreground)" }}
/>
<span className="text-sm">
<span className="font-bold">{primary.label}</span>{" "}
<span style={{ color: "var(--muted-foreground)" }}>{primary.provider}</span>
</span>
</label>
<label className="flex items-center gap-3 p-2 cursor-pointer min-h-[44px]">
<input
type="radio"
name="method_ui"
checked={otherOpen}
onChange={() => {
setOtherOpen(true);
setMethod(rest[0].id);
}}
style={{ accentColor: "var(--foreground)" }}
/>
<span className="text-sm">Другой способ</span>
</label>
{otherOpen && (
<select
value={method}
onChange={(e) => setMethod(e.target.value)}
aria-label="Другой способ оплаты"
style={inputStyle}
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
>
{rest.map((m) => (
<option key={m.id} value={m.id}>
{m.label} {m.provider}
</option>
))}
</select>
)}
{currentMethod?.kind === "manual" && (
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>
Счёт выставим вручную доступ откроется после подтверждения оплаты.
</p>
)}
</div>
</div>
)}
{/* Promo code */}
<div className="mb-4">
<label
className={labelClass}
style={{ color: "var(--muted-foreground)" }}
htmlFor={`${titleId}-promo`}
>
Промокод
</label>
<input
id={`${titleId}-promo`}
type="text"
name="promo_code"
maxLength={40}
autoComplete="off"
placeholder="Если есть"
style={inputStyle}
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
/>
</div>
<p className="text-xs mb-3" style={{ color: "var(--muted-foreground)" }}>
Доступ придёт на <span className="font-bold">{buyer.email}</span>
</p>
<button
type="submit"
disabled={submitting}
className="btn-aubade btn-aubade-accent w-full min-h-[44px] text-sm"
>
{submitting ? "Переходим к оплате…" : "Перейти к оплате →"}
</button>
</form>
{/* Footer */}
<div className="space-y-2 pt-1">
<a
href={entry.landingUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm underline inline-block"
style={{ color: "var(--foreground)" }}
>
Подробнее о курсе на сайте
</a>
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>
Сумма на странице оплаты отобразится в тенге (). Доступ откроется автоматически
после оплаты.
</p>
</div>
</div>
</div>
);
}