0e67a53122
- 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>
317 lines
11 KiB
TypeScript
317 lines
11 KiB
TypeScript
"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>
|
||
);
|
||
}
|