Files
lms-sb/src/app/(auth)/register/register-form.tsx
T
admins 0e67a53122 Add Яндекс.Метрика funnel goals: signup_free + gate_view (env-driven counter)
- window.ym reachGoal 'signup_free' on registration success
- GateViewTracker client component fires 'gate_view' on tripwire gate mount
- counter id via NEXT_PUBLIC_METRIKA_COUNTER_ID (build-time)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 12:13:32 +05:00

317 lines
11 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 { useState, useEffect, useRef } from "react";
import Link from "next/link";
declare global {
interface Window {
ym?: (counterId: number, action: string, goal: string) => void;
turnstile?: {
render: (
container: HTMLElement,
options: {
sitekey: string;
callback: (token: string) => void;
"expired-callback"?: () => void;
"error-callback"?: () => void;
}
) => string;
reset: (widgetId: string) => void;
};
}
}
interface Props {
showTermsCheckbox: boolean;
privacyPolicyUrl: string;
termsUrl: string;
offerUrl: string;
archetype?: string;
utmSource?: string;
utmMedium?: string;
utmCampaign?: string;
}
export function RegisterForm({ showTermsCheckbox, privacyPolicyUrl, termsUrl, offerUrl, archetype, utmSource, utmMedium, utmCampaign }: Props) {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [honeypot, setHoneypot] = useState("");
const [termsAccepted, setTermsAccepted] = useState(false);
const [turnstileToken, setTurnstileToken] = useState<string | null>(null);
const [error, setError] = useState("");
const [emailTaken, setEmailTaken] = useState(false);
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const turnstileContainerRef = useRef<HTMLDivElement>(null);
const turnstileWidgetId = useRef<string | null>(null);
const siteKey = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY;
useEffect(() => {
if (!siteKey || !turnstileContainerRef.current) return;
const script = document.createElement("script");
script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js";
script.async = true;
script.defer = true;
document.head.appendChild(script);
script.onload = () => {
if (!turnstileContainerRef.current || !window.turnstile) return;
turnstileWidgetId.current = window.turnstile.render(turnstileContainerRef.current, {
sitekey: siteKey,
callback: (token) => setTurnstileToken(token),
"expired-callback": () => setTurnstileToken(null),
"error-callback": () => setTurnstileToken(null),
});
};
return () => {
if (document.head.contains(script)) document.head.removeChild(script);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const inputStyle = {
border: "2px solid var(--border)",
background: "var(--background)",
outline: "none",
width: "100%",
padding: "0.5rem 0.75rem",
fontSize: "16px",
fontFamily: "inherit",
} as React.CSSProperties;
const legalLinks = [
{ url: privacyPolicyUrl, label: "Политику конфиденциальности" },
{ url: termsUrl, label: "Согласие на обработку данных" },
{ url: offerUrl, label: "Договор-оферту" },
].filter((l) => l.url);
function resetTurnstile() {
if (turnstileWidgetId.current) {
window.turnstile?.reset(turnstileWidgetId.current);
setTurnstileToken(null);
}
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (showTermsCheckbox && !termsAccepted) {
setError("Необходимо принять условия для продолжения");
return;
}
if (siteKey && !turnstileToken) {
setError("Пожалуйста, подтвердите, что вы не робот");
return;
}
setError("");
setEmailTaken(false);
setLoading(true);
// 1. Сетевой слой: настоящий обрыв связи — отдельно от ошибок сервера
let res: Response;
try {
res = await fetch("/api/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name,
email,
password,
website: honeypot,
cfTurnstileResponse: turnstileToken,
archetype,
utmSource,
utmMedium,
utmCampaign,
}),
});
} catch {
setError("Не удалось связаться с сервером. Проверьте интернет-соединение и попробуйте ещё раз.");
setLoading(false);
return;
}
// 2. Тело может быть не-JSON (502 при передеплое, HTML от прокси)
let data: { message?: string; code?: string } = {};
try {
data = await res.json() as { message?: string; code?: string };
} catch {
setError(
res.ok
? "Сервер вернул неожиданный ответ. Попробуйте ещё раз."
: "Сервис временно недоступен. Попробуйте через минуту."
);
resetTurnstile();
setLoading(false);
return;
}
// 3. Осмысленные ошибки сервера
if (!res.ok) {
if (res.status === 409 || data.code === "EMAIL_TAKEN") {
setEmailTaken(true);
} else {
setError(data.message ?? "Не удалось зарегистрироваться. Попробуйте ещё раз.");
}
resetTurnstile();
setLoading(false);
return;
}
// Яндекс.Метрика: цель «бесплатная регистрация»
const mc = Number(process.env.NEXT_PUBLIC_METRIKA_COUNTER_ID);
if (mc && typeof window !== "undefined" && typeof window.ym === "function") {
window.ym(mc, "reachGoal", "signup_free");
}
setSuccess(true);
setLoading(false);
}
if (success) {
return (
<div className="text-center space-y-4">
<div className="text-4xl">✉️</div>
<p className="font-bold">Проверьте почту</p>
<p className="text-sm" style={{ color: "var(--muted-foreground)" }}>
Мы отправили письмо на <strong>{email}</strong> для подтверждения аккаунта.
</p>
<Link href="/login" className="text-sm underline" style={{ color: "var(--foreground)" }}>
Вернуться к входу
</Link>
</div>
);
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
{/* Honeypot — hidden from humans, visible to bots */}
<div style={{ position: "absolute", left: "-9999px", top: "-9999px" }} aria-hidden="true">
<input
type="text"
name="website"
value={honeypot}
onChange={(e) => setHoneypot(e.target.value)}
tabIndex={-1}
autoComplete="off"
/>
</div>
<div className="space-y-1">
<label className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
Имя
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
required
style={inputStyle}
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
placeholder="Иван Иванов"
/>
</div>
<div className="space-y-1">
<label className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
Email
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
style={inputStyle}
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
placeholder="you@example.com"
/>
</div>
<div className="space-y-1">
<label className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
Пароль
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
style={inputStyle}
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
placeholder="Минимум 8 символов"
/>
</div>
{siteKey && <div ref={turnstileContainerRef} />}
{showTermsCheckbox && (
<label className="flex items-start gap-2 cursor-pointer">
<input
type="checkbox"
checked={termsAccepted}
onChange={(e) => setTermsAccepted(e.target.checked)}
className="mt-0.5 flex-shrink-0"
style={{ width: "16px", height: "16px", accentColor: "var(--foreground)" }}
/>
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
Я принимаю{" "}
{legalLinks.length > 0
? legalLinks.map((l, i) => (
<span key={l.url}>
<a
href={l.url}
target="_blank"
rel="noopener noreferrer"
className="underline"
style={{ color: "var(--foreground)" }}
>
{l.label}
</a>
{i < legalLinks.length - 1 ? ", " : ""}
</span>
))
: "условия использования платформы"}
</span>
</label>
)}
{emailTaken && (
<div className="text-xs py-3 px-3 space-y-2" style={{ border: "2px solid oklch(0.577 0.245 27.325)" }}>
<p style={{ color: "oklch(0.577 0.245 27.325)" }}>
Аккаунт с таким email уже зарегистрирован.
</p>
<p style={{ color: "var(--muted-foreground)" }}>
<Link href="/login" className="underline" style={{ color: "var(--foreground)" }}>Войти</Link>
{" · "}
<Link href="/forgot-password" className="underline" style={{ color: "var(--foreground)" }}>Забыли пароль?</Link>
</p>
</div>
)}
{error && !emailTaken && (
<p className="text-xs py-2 px-3" style={{ border: "2px solid oklch(0.577 0.245 27.325)", color: "oklch(0.577 0.245 27.325)" }}>
{error}
</p>
)}
<button
type="submit"
disabled={loading}
className="btn-aubade btn-aubade-accent w-full py-2 text-sm"
style={{ opacity: loading ? 0.6 : 1 }}
>
{loading ? "Регистрация..." : "Зарегистрироваться"}
</button>
<p className="text-center text-xs" style={{ color: "var(--muted-foreground)" }}>
Уже есть аккаунт?{" "}
<Link href="/login" className="underline" style={{ color: "var(--foreground)" }}>
Войти
</Link>
</p>
</form>
);
}