перевод лендинга на vue, добавление поддержки inertia

This commit is contained in:
2026-06-27 22:15:17 +04:00
parent d72650bbdc
commit 8bf1b852f0
25 changed files with 1451 additions and 162 deletions
@@ -0,0 +1,172 @@
<script setup>
import { computed, nextTick, onMounted, reactive, ref } from 'vue';
import { arrowSvg, iconPaths, moonMark } from './symbols';
const props = defineProps({
registerEndpoint: { type: String, required: true },
csrfToken: { type: String, required: true },
auth: { type: Object, required: true },
flash: { type: Object, required: true },
});
const hints = {
username: 'Используй латиницу и цифры. Регистр символов при входе не имеет значения.',
invite_code: 'Регистрация доступна только по действующему инвайт-коду.',
};
const initialErrors = props.flash.errors || {};
const submitted = ref(Boolean(props.flash.success));
const submitting = ref(false);
const form = reactive({
username: props.flash.old?.username || '',
email: props.flash.old?.email || '',
invite_code: props.flash.old?.invite_code || '',
password: '',
password_confirmation: '',
terms: false,
});
const touched = reactive(Object.fromEntries(Object.keys(initialErrors).map((key) => [key, true])));
const serverErrors = reactive({ ...initialErrors });
const clientErrors = computed(() => {
const errors = {};
if (form.username && !/^[a-zA-Z0-9]{3,32}$/.test(form.username)) errors.username = 'Только латиница и цифры, 3-32 символа.';
if (form.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)) errors.email = 'Похоже на некорректный email.';
if (form.invite_code && !/^[A-Za-z0-9-]{6,32}$/.test(form.invite_code)) errors.invite_code = 'Код из букв, цифр и тире, 6-32 символа.';
if (form.password && form.password.length < 8) errors.password = 'Минимум 8 символов.';
else if (form.password && !/[A-Za-z]/.test(form.password)) errors.password = 'Должна быть хотя бы одна буква.';
else if (form.password && !/\d/.test(form.password)) errors.password = 'Должна быть хотя бы одна цифра.';
if (form.password_confirmation && form.password_confirmation !== form.password) errors.password_confirmation = 'Пароли не совпадают.';
return errors;
});
const valid = computed(() => Boolean(
form.username &&
form.email &&
form.invite_code &&
form.password &&
form.password_confirmation &&
form.terms &&
Object.keys(clientErrors.value).length === 0
));
onMounted(() => {
if (submitted.value || props.flash.error || Object.keys(initialErrors).length) {
nextTick(() => document.getElementById('join')?.scrollIntoView({ behavior: 'smooth', block: 'start' }));
}
});
function firstError(field) {
const value = serverErrors[field];
return Array.isArray(value) ? value[0] : value;
}
function errorFor(field) {
return firstError(field) || (touched[field] && clientErrors.value[field]) || null;
}
function updateField(field, value) {
form[field] = value;
delete serverErrors[field];
}
function submit(event) {
if (valid.value) {
submitting.value = true;
return;
}
event.preventDefault();
['username', 'email', 'invite_code', 'password', 'password_confirmation', 'terms'].forEach((field) => {
touched[field] = true;
});
}
</script>
<template>
<section id="join" data-screen-label="04 Join">
<div class="wrap">
<div v-if="submitted && flash.success" class="join-card join-success reveal is-visible">
<div class="join-success-mark" v-html="moonMark(56)"></div>
<span class="eyebrow" style="justify-content: center">Добро пожаловать</span>
<h3 class="h-display" style="font-size: 36px; margin-top: 16px">Аккаунт создан</h3>
<p class="lead" style="margin-top: 14px">
Логин <span style="color: var(--moon); font-style: normal">{{ flash.success_username || form.username }}</span> готов к входу. Открывай лаунчер и жми «Играть».
</p>
<div class="hero-cta" style="justify-content: center; margin-top: 28px">
<a :href="auth.cabinet_url || auth.login_url || '#'" class="btn btn-primary">Войти в личный кабинет <span v-html="arrowSvg"></span></a>
</div>
</div>
<div v-else class="join-grid reveal">
<div class="join-intro">
<span class="eyebrow">Регистрация</span>
<div style="height: 20px"></div>
<h2 class="h-display" style="font-size: clamp(36px, 5vw, 56px)">Создай игровой аккаунт</h2>
<div class="divider-ornament" style="justify-content: flex-start; margin: 22px 0"><span style="opacity: 0.6"></span></div>
<p class="lead" style="font-style: italic">Укажи инвайт-код, логин, почту и пароль - аккаунт будет готов к входу в игру сразу после регистрации.</p>
<div class="join-note">
<span class="join-note-k">Закрытый бета-доступ</span>
<span class="join-note-v">Пока идет закрытый этап: регистрация только по инвайт-кодам. Код можно получить у друзей-игроков или в нашем Discord.</span>
</div>
</div>
<form class="join-card" method="POST" :action="registerEndpoint" novalidate @submit="submit">
<input type="hidden" name="_token" :value="csrfToken" />
<div class="join-card-corner tl" aria-hidden="true"></div>
<div class="join-card-corner tr" aria-hidden="true"></div>
<div class="join-card-corner bl" aria-hidden="true"></div>
<div class="join-card-corner br" aria-hidden="true"></div>
<div v-if="flash.error" class="join-error" style="margin-bottom: 14px">{{ flash.error }}</div>
<div :class="['join-field', { 'has-error': errorFor('username') }]">
<label class="join-label">Логин</label>
<div class="join-input"><input type="text" name="username" autocomplete="username" placeholder="Moonwalker" :value="form.username" maxlength="32" @input="updateField('username', $event.target.value)" @blur="touched.username = true" /></div>
<div v-if="errorFor('username')" class="join-error">{{ errorFor('username') }}</div>
<div v-else class="join-hint">{{ hints.username }}</div>
</div>
<div :class="['join-field', { 'has-error': errorFor('email') }]">
<label class="join-label">Email</label>
<div class="join-input"><input type="email" name="email" autocomplete="email" placeholder="player@example.com" :value="form.email" @input="updateField('email', $event.target.value)" @blur="touched.email = true" /></div>
<div v-if="errorFor('email')" class="join-error">{{ errorFor('email') }}</div>
</div>
<div :class="['join-field', { 'has-error': errorFor('invite_code') }]">
<label class="join-label">Инвайт-код</label>
<div class="join-input">
<span class="join-input-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" v-html="iconPaths.mask"></svg></span>
<input type="text" name="invite_code" placeholder="ABCD-EFGH-IJKL" :value="form.invite_code" maxlength="32" style="letter-spacing: 0.14em; font-family: var(--f-mono)" @input="updateField('invite_code', $event.target.value.toUpperCase())" @blur="touched.invite_code = true" />
</div>
<div v-if="errorFor('invite_code')" class="join-error">{{ errorFor('invite_code') }}</div>
<div v-else class="join-hint">{{ hints.invite_code }}</div>
</div>
<div class="join-row">
<div :class="['join-field', { 'has-error': errorFor('password') }]">
<label class="join-label">Пароль</label>
<div class="join-input"><input type="password" name="password" autocomplete="new-password" placeholder="Минимум 8 символов" :value="form.password" maxlength="32" @input="updateField('password', $event.target.value)" @blur="touched.password = true" /></div>
<div v-if="errorFor('password')" class="join-error">{{ errorFor('password') }}</div>
</div>
<div :class="['join-field', { 'has-error': errorFor('password_confirmation') }]">
<label class="join-label">Повтор пароля</label>
<div class="join-input"><input type="password" name="password_confirmation" autocomplete="new-password" placeholder="Повтори пароль" :value="form.password_confirmation" maxlength="32" @input="updateField('password_confirmation', $event.target.value)" @blur="touched.password_confirmation = true" /></div>
<div v-if="errorFor('password_confirmation')" class="join-error">{{ errorFor('password_confirmation') }}</div>
</div>
</div>
<label class="join-check">
<input type="checkbox" name="terms" value="1" :checked="form.terms" @change="updateField('terms', $event.target.checked)" />
<span class="box" aria-hidden="true"></span>
<span>Я принимаю <a href="#">правила сервера</a> и согласен на обработку данных для создания игрового аккаунта.</span>
</label>
<div v-if="errorFor('terms')" class="join-error">{{ errorFor('terms') }}</div>
<button type="submit" :class="['btn btn-primary join-submit', { 'is-disabled': !valid || submitting }]" :disabled="!valid || submitting">
{{ submitting ? 'Создаем аккаунт...' : 'Зарегистрировать аккаунт' }} <span v-html="arrowSvg"></span>
</button>
</form>
</div>
</div>
</section>
</template>