diff --git a/src/components/student/store-checkout-modal.tsx b/src/components/student/store-checkout-modal.tsx new file mode 100644 index 0000000..17a9606 --- /dev/null +++ b/src/components/student/store-checkout-modal.tsx @@ -0,0 +1,372 @@ +"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(null); + const [method, setMethod] = useState("robokassa"); + const [otherOpen, setOtherOpen] = useState(false); + const [submitting, setSubmitting] = useState(false); + const dialogRef = useRef(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( + '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 ( +
{ + if (e.target === e.currentTarget) onClose(); + }} + > +
+ {/* Header */} +
+

+ Покупка курса +

+ +
+ +
+ {course.coverImage ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( +
+ 📚 +
+ )} +

+ {course.title} +

+
+ + {course.description && ( +

+ {course.description} +

+ )} + +
setSubmitting(true)}> + + + + {!multiTariff && } + + {/* Tariff */} +
+ + Тариф + + {multiTariff ? ( +
+ {entry.tariffs.map((t) => ( + + ))} +
+ ) : ( +

{formatRub(entry.tariffs[0].priceRub)}

+ )} +
+ + {/* Payment method — rendered only when the list actually loaded */} + {methods && primary && rest.length > 0 && ( +
+ + Способ оплаты + +
+ + + {otherOpen && ( + + )} + {currentMethod?.kind === "manual" && ( +

+ Счёт выставим вручную — доступ откроется после подтверждения оплаты. +

+ )} +
+
+ )} + + {/* Promo code */} +
+ + (e.currentTarget.style.borderColor = "var(--foreground)")} + onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")} + /> +
+ +

+ Доступ придёт на {buyer.email} +

+ + +
+ + {/* Footer */} +
+ + Подробнее о курсе на сайте → + +

+ Сумма на странице оплаты отобразится в тенге (₸). Доступ откроется автоматически + после оплаты. +

+
+
+
+ ); +}