новый лендинг
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user