новый лендинг

This commit is contained in:
2026-04-26 16:09:08 +04:00
parent 103e2bc4cf
commit 7701aa16c0
24 changed files with 6368 additions and 341 deletions
+325
View File
@@ -0,0 +1,325 @@
import { useEffect, useMemo, useState } from 'react';
import { useReveal, Icons, MoonMark, getBootstrap } from './atoms.jsx';
const FIELD_HINTS = {
username: 'Используй латиницу и цифры. Регистр символов при входе не имеет значения.',
invite_code: 'Регистрация доступна только по действующему инвайт-коду.',
};
function firstError(errors, field) {
if (!errors || !errors[field]) return null;
const value = errors[field];
return Array.isArray(value) ? value[0] : value;
}
export default function Join() {
const ref = useReveal();
const bootstrap = getBootstrap();
const flash = bootstrap.flash || {};
const old = flash.old || {};
const initialErrors = flash.errors || {};
const [form, setForm] = useState({
username: old.username || '',
email: old.email || '',
invite_code: old.invite_code || '',
password: '',
password_confirmation: '',
terms: false,
});
const [touched, setTouched] = useState(() => {
const t = {};
Object.keys(initialErrors).forEach((k) => { t[k] = true; });
return t;
});
const [serverErrors, setServerErrors] = useState(initialErrors);
const [serverMessage, setServerMessage] = useState(flash.error || null);
const [submitted, setSubmitted] = useState(Boolean(flash.success));
const [successUsername, setSuccessUsername] = useState(
flash.success_username || old.username || ''
);
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (!submitted) return;
const target = document.getElementById('join');
if (target && (flash.success || flash.error || Object.keys(initialErrors).length)) {
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}, [submitted]);
const set = (k) => (e) => {
const v = e.target.type === 'checkbox' ? e.target.checked : e.target.value;
setForm((f) => ({ ...f, [k]: v }));
if (serverErrors[k]) {
setServerErrors((errs) => {
const next = { ...errs };
delete next[k];
return next;
});
}
};
const blur = (k) => () => setTouched((t) => ({ ...t, [k]: true }));
const clientErrors = useMemo(() => {
const e = {};
if (form.username && !/^[a-zA-Z0-9]{3,32}$/.test(form.username)) {
e.username = 'Только латиница и цифры, 3–32 символа.';
}
if (form.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)) {
e.email = 'Похоже на некорректный email.';
}
if (form.invite_code && !/^[A-Za-z0-9-]{6,32}$/.test(form.invite_code)) {
e.invite_code = 'Код из букв, цифр и тире, 6–32 символа.';
}
if (form.password && form.password.length < 8) {
e.password = 'Минимум 8 символов.';
} else if (form.password && !/[A-Za-z]/.test(form.password)) {
e.password = 'Должна быть хотя бы одна буква.';
} else if (form.password && !/\d/.test(form.password)) {
e.password = 'Должна быть хотя бы одна цифра.';
}
if (form.password_confirmation && form.password_confirmation !== form.password) {
e.password_confirmation = 'Пароли не совпадают.';
}
return e;
}, [form]);
const errorFor = (field) =>
firstError(serverErrors, field) || (touched[field] && clientErrors[field]) || null;
const valid =
form.username &&
form.email &&
form.invite_code &&
form.password &&
form.password_confirmation &&
form.terms &&
Object.keys(clientErrors).length === 0;
const onSubmit = (e) => {
if (!valid) {
e.preventDefault();
setTouched({
username: true,
email: true,
invite_code: true,
password: true,
password_confirmation: true,
terms: true,
});
return;
}
setSubmitting(true);
};
if (submitted && flash.success) {
return (
<section id="join" data-screen-label="04 Join">
<div className="wrap">
<div className="join-card join-success reveal is-visible">
<div className="join-success-mark">
<MoonMark size={56} />
</div>
<span className="eyebrow" style={{ justifyContent: 'center' }}>Добро пожаловать</span>
<h3 className="h-display" style={{ fontSize: 36, marginTop: 16 }}>
Аккаунт создан
</h3>
<p className="lead" style={{ marginTop: 14 }}>
{successUsername ? (
<>
Логин <span style={{ color: 'var(--moon)', fontStyle: 'normal' }}>{successUsername}</span> готов к входу.
</>
) : (
'Аккаунт готов к входу. '
)}
{' '}Открывай лаунчер и жми «Играть».
</p>
<div className="hero-cta" style={{ justifyContent: 'center', marginTop: 28 }}>
<a
href={bootstrap.auth?.cabinet_url || bootstrap.auth?.login_url || '#'}
className="btn btn-primary"
>
Войти в личный кабинет {Icons.arrow}
</a>
</div>
</div>
</div>
</section>
);
}
const endpoint = bootstrap.register_endpoint || '/register';
const csrfToken = bootstrap.csrf_token || '';
return (
<section id="join" data-screen-label="04 Join">
<div className="wrap">
<div className="join-grid reveal" ref={ref}>
<div className="join-intro">
<span className="eyebrow">Регистрация</span>
<div style={{ height: 20 }} />
<h2 className="h-display" style={{ fontSize: 'clamp(36px, 5vw, 56px)' }}>
Создай игровой аккаунт
</h2>
<div className="divider-ornament" style={{ justifyContent: 'flex-start', margin: '22px 0' }}>
<span style={{ opacity: 0.6 }}></span>
</div>
<p className="lead" style={{ fontStyle: 'italic' }}>
Укажи инвайт-код, логин, почту и пароль аккаунт будет готов к входу в игру сразу после
регистрации.
</p>
<div className="join-note">
<span className="join-note-k">Закрытый бета-доступ</span>
<span className="join-note-v">
Пока идёт закрытый этап: регистрация только по инвайт-кодам. Код можно получить у
друзей-игроков или в нашем Discord.
</span>
</div>
</div>
<form
className="join-card"
method="POST"
action={endpoint}
onSubmit={onSubmit}
noValidate
>
<input type="hidden" name="_token" value={csrfToken} />
<div className="join-card-corner tl" aria-hidden="true"></div>
<div className="join-card-corner tr" aria-hidden="true"></div>
<div className="join-card-corner bl" aria-hidden="true"></div>
<div className="join-card-corner br" aria-hidden="true"></div>
{serverMessage && (
<div className="join-error" style={{ marginBottom: 14 }}>{serverMessage}</div>
)}
<Field
label="Логин"
hint={FIELD_HINTS.username}
error={errorFor('username')}
>
<input
type="text"
name="username"
autoComplete="username"
placeholder="Moonwalker"
value={form.username}
onChange={set('username')}
onBlur={blur('username')}
maxLength={32}
/>
</Field>
<Field label="Email" error={errorFor('email')}>
<input
type="email"
name="email"
autoComplete="email"
placeholder="player@example.com"
value={form.email}
onChange={set('email')}
onBlur={blur('email')}
/>
</Field>
<Field
label="Инвайт-код"
hint={FIELD_HINTS.invite_code}
error={errorFor('invite_code')}
icon={Icons.mask}
>
<input
type="text"
name="invite_code"
placeholder="ABCD-EFGH-IJKL"
value={form.invite_code}
onChange={(e) =>
setForm((f) => ({ ...f, invite_code: e.target.value.toUpperCase() }))
}
onBlur={blur('invite_code')}
maxLength={32}
style={{ letterSpacing: '0.14em', fontFamily: 'var(--f-mono)' }}
/>
</Field>
<div className="join-row">
<Field label="Пароль" error={errorFor('password')}>
<input
type="password"
name="password"
autoComplete="new-password"
placeholder="Минимум 8 символов"
value={form.password}
onChange={set('password')}
onBlur={blur('password')}
maxLength={32}
/>
</Field>
<Field
label="Повтор пароля"
error={errorFor('password_confirmation')}
>
<input
type="password"
name="password_confirmation"
autoComplete="new-password"
placeholder="Повтори пароль"
value={form.password_confirmation}
onChange={set('password_confirmation')}
onBlur={blur('password_confirmation')}
maxLength={32}
/>
</Field>
</div>
<label className="join-check">
<input
type="checkbox"
name="terms"
value="1"
checked={form.terms}
onChange={set('terms')}
/>
<span className="box" aria-hidden="true"></span>
<span>
Я принимаю <a href="#">правила сервера</a> и согласен на обработку данных для создания игрового
аккаунта.
</span>
</label>
{errorFor('terms') && (
<div className="join-error">{errorFor('terms')}</div>
)}
<button
type="submit"
className={`btn btn-primary join-submit ${(!valid || submitting) ? 'is-disabled' : ''}`}
disabled={!valid || submitting}
>
{submitting ? 'Создаём аккаунт…' : 'Зарегистрировать аккаунт'} {Icons.arrow}
</button>
</form>
</div>
</div>
</section>
);
}
function Field({ label, hint, error, icon, children }) {
return (
<div className={`join-field ${error ? 'has-error' : ''}`}>
<label className="join-label">{label}</label>
<div className="join-input">
{icon && <span className="join-input-icon">{icon}</span>}
{children}
</div>
{error ? (
<div className="join-error">{error}</div>
) : hint ? (
<div className="join-hint">{hint}</div>
) : null}
</div>
);
}