новый дизайн

This commit is contained in:
2026-07-25 18:36:45 +04:00
parent df7f98fe8b
commit c43a73e148
54 changed files with 4234 additions and 1110 deletions
+45
View File
@@ -0,0 +1,45 @@
<script setup>
defineProps({
eyebrow: { type: String, required: true },
title: { type: String, required: true },
description: { type: String, required: true },
effectiveDate: { type: String, required: true },
sections: { type: Array, required: true },
});
</script>
<template>
<section class="legal-page wrap">
<header class="legal-hero">
<span class="eyebrow">{{ eyebrow }}</span>
<h1 class="h-display">{{ title }}</h1>
<p class="lead">{{ description }}</p>
<div class="legal-effective">
<span>Дата вступления в силу</span>
<strong>{{ effectiveDate }}</strong>
</div>
</header>
<div class="legal-layout">
<aside class="legal-toc">
<span>Содержание</span>
<a v-for="(section, index) in sections" :key="section.id" :href="`#${section.id}`">
<small>{{ String(index + 1).padStart(2, '0') }}</small>
{{ section.title }}
</a>
</aside>
<article class="portal-card legal-document corners">
<section v-for="(section, index) in sections" :id="section.id" :key="section.id">
<span class="legal-section-number">{{ String(index + 1).padStart(2, '0') }}</span>
<h2 class="h-serif">{{ section.title }}</h2>
<p v-for="paragraph in section.paragraphs" :key="paragraph">{{ paragraph }}</p>
<ul v-if="section.items">
<li v-for="item in section.items" :key="item">{{ item }}</li>
</ul>
<p v-if="section.note" class="legal-note">{{ section.note }}</p>
</section>
</article>
</div>
</section>
</template>
+25
View File
@@ -0,0 +1,25 @@
<script setup>
import { Link } from '@inertiajs/vue3';
import { moonMark } from '../Pages/Landing/Components/symbols';
defineProps({
site: { type: Object, required: true },
urls: { type: Object, required: true },
});
</script>
<template>
<footer class="portal-footer">
<div class="wrap portal-footer__inner">
<Link class="portal-footer__brand" :href="urls.home">
<span v-html="moonMark(26)"></span>
<span>{{ site.name }}</span>
</Link>
<nav aria-label="Юридическая информация">
<Link v-if="urls.offer" :href="urls.offer">Публичная оферта</Link>
<Link v-if="urls.privacy" :href="urls.privacy">Политика конфиденциальности</Link>
</nav>
<span>© 2026 MoonWell · фанатский проект</span>
</div>
</footer>
</template>
+90
View File
@@ -0,0 +1,90 @@
<script setup>
import { Link, router, usePage } from '@inertiajs/vue3';
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import { moonMark } from '../Pages/Landing/Components/symbols';
const props = defineProps({
site: { type: Object, required: true },
auth: { type: Object, required: true },
urls: { type: Object, required: true },
});
const page = usePage();
const menuOpen = ref(false);
const currentPath = computed(() => page.url.split('?')[0]);
const isCabinet = computed(() => currentPath.value.startsWith('/cabinet'));
const isAdmin = computed(() => currentPath.value.startsWith('/admin'));
const cabinetUrl = computed(() => (
props.auth.authenticated
? props.urls.cabinet
: props.urls.login
));
function logout() {
menuOpen.value = false;
router.post(props.urls.logout);
}
watch(menuOpen, (open) => {
document.body.style.overflow = open ? 'hidden' : '';
});
onBeforeUnmount(() => {
document.body.style.overflow = '';
});
</script>
<template>
<header class="portal-header">
<div class="wrap portal-header__inner">
<Link class="portal-brand" :href="urls.home" aria-label="MoonWell — главная">
<span class="portal-brand__mark" v-html="moonMark(36)"></span>
<span>
<strong>{{ site.name }}</strong>
<small>{{ site.tagline }}</small>
</span>
</Link>
<nav class="portal-nav portal-nav--desktop" aria-label="Основная навигация">
<Link :href="urls.home">Главная</Link>
<Link v-if="cabinetUrl" :class="{ active: isCabinet }" :href="cabinetUrl">
{{ auth.authenticated ? 'Кабинет' : 'Войти' }}
</Link>
<Link v-if="auth.is_admin && urls.admin" :class="{ active: isAdmin }" :href="urls.admin">Админка</Link>
<Link v-if="urls.offer" :class="{ active: currentPath === '/public-offer' }" :href="urls.offer">Оферта</Link>
<Link v-if="urls.privacy" :class="{ active: currentPath === '/privacy-policy' }" :href="urls.privacy">Конфиденциальность</Link>
</nav>
<div v-if="auth.authenticated" class="portal-account portal-nav--desktop">
<span>
<strong>{{ auth.username }}</strong>
<small>{{ auth.access_label }}</small>
</span>
<button class="portal-logout" type="button" @click="logout">Выйти</button>
</div>
<button
class="portal-menu-toggle"
type="button"
:aria-expanded="menuOpen"
:aria-label="menuOpen ? 'Закрыть меню' : 'Открыть меню'"
@click="menuOpen = !menuOpen"
>
<span></span><span></span><span></span>
</button>
</div>
<div :class="['portal-mobile-menu', { open: menuOpen }]">
<nav class="wrap" aria-label="Мобильная навигация">
<Link :href="urls.home" @click="menuOpen = false">Главная</Link>
<Link v-if="cabinetUrl" :href="cabinetUrl" @click="menuOpen = false">
{{ auth.authenticated ? 'Личный кабинет' : 'Войти в кабинет' }}
</Link>
<Link v-if="auth.is_admin && urls.admin" :href="urls.admin" @click="menuOpen = false">Админка</Link>
<Link v-if="urls.offer" :href="urls.offer" @click="menuOpen = false">Публичная оферта</Link>
<Link v-if="urls.privacy" :href="urls.privacy" @click="menuOpen = false">Политика конфиденциальности</Link>
<button v-if="auth.authenticated" type="button" @click="logout">Выйти из аккаунта</button>
</nav>
</div>
</header>
</template>
+39
View File
@@ -0,0 +1,39 @@
<script setup>
import { Link } from '@inertiajs/vue3';
import PortalLayout from './PortalLayout.vue';
defineProps({
site: { type: Object, required: true },
auth: { type: Object, required: true },
urls: { type: Object, required: true },
flash: { type: Object, default: () => ({}) },
current: { type: String, required: true },
});
const items = [
{ key: 'accounts', label: 'Аккаунты', url: 'admin_accounts', number: '01' },
{ key: 'balances', label: 'Слёзы Элуны', url: 'admin_balances', number: '02' },
{ key: 'shop', label: 'Магазин', url: 'admin_shop', number: '03' },
{ key: 'news', label: 'Новости', url: 'admin_news', number: '04' },
{ key: 'login-news', label: 'Новости WoW', url: 'admin_login_news', number: '05' },
];
</script>
<template>
<PortalLayout :site="site" :auth="auth" :urls="urls" :flash="flash">
<div class="admin-bar">
<nav class="wrap admin-bar__inner" aria-label="Разделы админки">
<Link
v-for="item in items"
:key="item.key"
:class="{ active: current === item.key }"
:href="urls[item.url]"
>
<small>{{ item.number }}</small>
{{ item.label }}
</Link>
</nav>
</div>
<slot />
</PortalLayout>
</template>
+43
View File
@@ -0,0 +1,43 @@
<script setup>
import { Head } from '@inertiajs/vue3';
import { onMounted } from 'vue';
import SiteFooter from '../Components/SiteFooter.vue';
import SiteHeader from '../Components/SiteHeader.vue';
import Background from '../Pages/Landing/Components/Background.vue';
defineProps({
site: { type: Object, required: true },
auth: { type: Object, required: true },
urls: { type: Object, required: true },
flash: { type: Object, default: () => ({}) },
});
onMounted(() => {
document.documentElement.lang = 'ru';
document.body.dataset.variant = 'forest';
});
</script>
<template>
<Head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@400;500;600;700&family=Cormorant+Garamond:ital,wght@0,300;0,400;0,500;0,600;0,700;1,400&family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
</Head>
<Background />
<div class="portal-shell">
<SiteHeader :site="site" :auth="auth" :urls="urls" />
<div v-if="flash.status || flash.error" class="wrap portal-alerts" aria-live="polite">
<div v-if="flash.status" class="portal-alert portal-alert--success">{{ flash.status }}</div>
<div v-if="flash.error" class="portal-alert portal-alert--error">{{ flash.error }}</div>
</div>
<main class="portal-main">
<slot />
</main>
<SiteFooter :site="site" :urls="urls" />
</div>
</template>
+206
View File
@@ -0,0 +1,206 @@
<script setup>
import { Head, Link, router, useForm, usePage } from '@inertiajs/vue3';
import { computed, reactive, ref } from 'vue';
import AdminLayout from '../../Layouts/AdminLayout.vue';
import StatCards from './Components/StatCards.vue';
const props = defineProps({
site: { type: Object, required: true },
auth: { type: Object, required: true },
urls: { type: Object, required: true },
flash: { type: Object, default: () => ({}) },
accounts: { type: Array, default: () => [] },
accountStats: { type: Object, required: true },
accountTabs: { type: Object, required: true },
activeTab: { type: String, required: true },
inviteCodes: { type: Array, default: () => [] },
inviteStats: { type: Object, required: true },
inviteStoreUrl: { type: String, required: true },
registrationRequiresInvite: { type: Boolean, required: true },
search: { type: String, default: '' },
accessLevels: { type: Array, required: true },
old: { type: Object, default: () => ({}) },
});
const page = usePage();
const errors = computed(() => page.props.errors || {});
const searchValue = ref(props.search);
const savingAccount = ref(null);
const expandedAccounts = reactive({});
const accessValues = reactive(Object.fromEntries(props.accounts.map((account) => [account.id, account.gm_level])));
const inviteForm = useForm({ quantity: props.old.quantity ?? 1 });
const inviteStats = computed(() => [
{ label: 'Всего кодов', value: props.inviteStats.total },
{ label: 'Доступно', value: props.inviteStats.available },
{ label: 'Использовано', value: props.inviteStats.redeemed },
]);
function tabUrl(tab) {
const params = new URLSearchParams({ tab });
if (props.search) params.set('search', props.search);
return `${props.urls.admin_accounts}?${params.toString()}`;
}
function searchAccounts() {
router.get(props.urls.admin_accounts, {
tab: props.activeTab,
search: searchValue.value || undefined,
}, { preserveState: true, replace: true });
}
function updateAccess(account) {
savingAccount.value = account.id;
router.patch(account.update_access_url, {
gmlevel: accessValues[account.id],
}, {
preserveScroll: true,
onFinish: () => {
savingAccount.value = null;
},
});
}
function createInvites() {
inviteForm.post(props.inviteStoreUrl, {
preserveScroll: true,
onSuccess: () => inviteForm.reset('quantity'),
});
}
</script>
<template>
<Head><title>Аккаунты и инвайты — {{ site.name }}</title></Head>
<AdminLayout :site="site" :auth="auth" :urls="urls" :flash="flash" current="accounts">
<section class="admin-hero wrap">
<div>
<span class="eyebrow">Управление доступом</span>
<h1 class="h-display">Аккаунты и инвайты</h1>
<p class="lead">Игроки, боты, персонажи и уровни доступа — в одном рабочем пространстве.</p>
</div>
<div class="admin-hero__badge">
<small>Всего аккаунтов</small>
<strong>{{ (accountStats.players || 0) + (accountStats.bots || 0) }}</strong>
</div>
</section>
<div class="admin-page wrap">
<section class="admin-section">
<div class="admin-section__head">
<div><span class="eyebrow">Инвайты</span><h2 class="h-serif">Доступ к регистрации</h2></div>
<p>{{ registrationRequiresInvite ? 'Регистрация требует действующий инвайт-код.' : 'Регистрация сейчас открыта без инвайт-кодов.' }}</p>
</div>
<StatCards :items="inviteStats" />
<div class="admin-split">
<article class="portal-card admin-panel corners">
<span class="admin-kicker">Новая партия</span>
<h3 class="h-serif">Создать коды</h3>
<form class="admin-inline-form" @submit.prevent="createInvites">
<label class="admin-field">
<span>Количество</span>
<input v-model="inviteForm.quantity" type="number" min="1" max="50" required />
<small v-if="inviteForm.errors.quantity">{{ inviteForm.errors.quantity }}</small>
</label>
<button class="btn btn-primary" type="submit" :disabled="inviteForm.processing">
{{ inviteForm.processing ? 'Создаём…' : 'Создать коды' }}
</button>
</form>
</article>
<article class="portal-card admin-panel">
<span class="admin-kicker">Последние коды</span>
<div v-if="inviteCodes.length" class="admin-compact-list">
<div v-for="invite in inviteCodes" :key="invite.id">
<span>
<strong class="admin-code">{{ invite.code }}</strong>
<small>{{ invite.created_by }}<template v-if="invite.created_at"> · {{ invite.created_at }}</template></small>
</span>
<span>
<strong :class="{ 'admin-positive': !invite.redeemed }">{{ invite.redeemed ? 'Использован' : 'Свободен' }}</strong>
<small>{{ invite.redeemed_by }}</small>
</span>
</div>
</div>
<div v-else class="admin-empty-inline">Инвайт-коды ещё не создавались.</div>
</article>
</div>
</section>
<section class="admin-section">
<div class="admin-section__head">
<div><span class="eyebrow">Аккаунты</span><h2 class="h-serif">Игроки и персонажи</h2></div>
<p>Поиск по логину или email, управление GM-уровнем и просмотр состава аккаунта.</p>
</div>
<div class="admin-toolbar">
<div class="admin-tabs">
<Link
v-for="(tab, key) in accountTabs"
:key="key"
:class="{ active: activeTab === key }"
:href="tabUrl(key)"
>
{{ tab.label }} <span>{{ tab.count }}</span>
</Link>
</div>
<form class="admin-search" @submit.prevent="searchAccounts">
<input v-model="searchValue" type="search" placeholder="Логин или email" />
<button class="btn btn-primary" type="submit">Найти</button>
<Link v-if="search" class="btn btn-ghost" :href="tabUrl(activeTab).split('&search=')[0]">Сбросить</Link>
</form>
</div>
<div v-if="accounts.length" class="admin-account-list">
<article v-for="account in accounts" :key="account.id" class="portal-card admin-account">
<div class="admin-account__main">
<div class="admin-account__identity">
<span :class="['admin-account__status', { danger: account.locked }]"></span>
<span>
<strong>{{ account.username }}</strong>
<small>ID #{{ account.id }} · {{ account.email }}</small>
</span>
</div>
<dl class="admin-account__stats">
<div><dt>Доступ</dt><dd>{{ account.access_label }}</dd></div>
<div><dt>Регистрация</dt><dd>{{ account.joined_at }}</dd></div>
<div><dt>Последний вход</dt><dd>{{ account.last_login_at }}</dd></div>
<div><dt>Персонажи</dt><dd>{{ account.characters.length }}</dd></div>
</dl>
<form class="admin-access-form" @submit.prevent="updateAccess(account)">
<label class="admin-field">
<span>Уровень доступа</span>
<select v-model="accessValues[account.id]">
<option v-for="level in accessLevels" :key="level.value" :value="level.value">{{ level.label }}</option>
</select>
<small v-if="savingAccount === account.id && errors.gmlevel">{{ errors.gmlevel }}</small>
</label>
<button class="btn btn-primary" type="submit" :disabled="savingAccount === account.id">
{{ savingAccount === account.id ? 'Сохраняем…' : 'Сохранить' }}
</button>
</form>
<button class="admin-expand" type="button" @click="expandedAccounts[account.id] = !expandedAccounts[account.id]">
{{ expandedAccounts[account.id] ? 'Скрыть персонажей' : `Персонажи · ${account.characters.length}` }}
</button>
</div>
<div v-if="expandedAccounts[account.id]" class="admin-account__characters">
<div v-for="character in account.characters" :key="character.name">
<span><strong>{{ character.name }}</strong><small>{{ character.race }} · {{ character.class }}</small></span>
<span><strong>{{ character.level }} ур.</strong><small>{{ character.online ? 'Онлайн' : 'Оффлайн' }}</small></span>
</div>
<p v-if="!account.characters.length">На аккаунте пока нет персонажей.</p>
</div>
</article>
</div>
<article v-else class="portal-card admin-empty">
<span>☾</span>
<h3 class="h-serif">{{ activeTab === 'bots' ? 'Боты не найдены' : 'Игроки не найдены' }}</h3>
<p>Измени поисковый запрос или переключись на другую вкладку.</p>
</article>
</section>
</div>
</AdminLayout>
</template>
+133
View File
@@ -0,0 +1,133 @@
<script setup>
import { Head, Link, router, useForm } from '@inertiajs/vue3';
import { ref } from 'vue';
import AdminLayout from '../../Layouts/AdminLayout.vue';
const props = defineProps({
site: { type: Object, required: true },
auth: { type: Object, required: true },
urls: { type: Object, required: true },
flash: { type: Object, default: () => ({}) },
transactions: { type: Array, default: () => [] },
search: { type: String, default: '' },
adjustUrl: { type: String, required: true },
old: { type: Object, default: () => ({}) },
});
const searchValue = ref(props.search);
const form = useForm({
account: props.old.account || '',
direction: props.old.direction || 'credit',
amount: props.old.amount || '',
reason: props.old.reason || '',
});
function submit() {
form.post(props.adjustUrl, {
preserveScroll: true,
onSuccess: () => form.reset(),
});
}
function searchTransactions() {
router.get(props.urls.admin_balances, {
search: searchValue.value || undefined,
}, { preserveState: true, replace: true });
}
</script>
<template>
<Head><title>Слёзы Элуны — {{ site.name }}</title></Head>
<AdminLayout :site="site" :auth="auth" :urls="urls" :flash="flash" current="balances">
<section class="admin-hero wrap">
<div>
<span class="eyebrow">Финансы</span>
<h1 class="h-display">Слёзы Элуны</h1>
<p class="lead">Ручные корректировки и полный журнал движения виртуальной валюты.</p>
</div>
<div class="admin-hero__badge"><small>Операций в выборке</small><strong>{{ transactions.length }}</strong></div>
</section>
<div class="admin-page wrap">
<section class="admin-section">
<div class="admin-split admin-split--form">
<article class="portal-card admin-panel corners">
<span class="admin-kicker">Новая операция</span>
<h2 class="h-serif">Изменить баланс</h2>
<form class="admin-form" @submit.prevent="submit">
<label class="admin-field">
<span>ID или логин аккаунта</span>
<input v-model="form.account" required />
<small v-if="form.errors.account">{{ form.errors.account }}</small>
</label>
<div class="admin-form-grid">
<label class="admin-field">
<span>Операция</span>
<select v-model="form.direction" required>
<option value="credit">Начислить</option>
<option value="debit">Снять</option>
</select>
<small v-if="form.errors.direction">{{ form.errors.direction }}</small>
</label>
<label class="admin-field">
<span>Количество</span>
<input v-model="form.amount" type="number" min="0.01" max="1000000" step="0.01" required />
<small v-if="form.errors.amount">{{ form.errors.amount }}</small>
</label>
</div>
<label class="admin-field">
<span>Причина</span>
<input v-model="form.reason" maxlength="255" placeholder="Компенсация, возврат, нарушение…" required />
<small v-if="form.errors.reason">{{ form.errors.reason }}</small>
</label>
<button class="btn btn-primary admin-submit" type="submit" :disabled="form.processing">
{{ form.processing ? 'Проводим…' : 'Провести операцию' }}
</button>
</form>
</article>
<article class="portal-card admin-panel admin-rules">
<span class="admin-kicker">Контроль</span>
<h2 class="h-serif">Правила операций</h2>
<div><span>01</span><p>Баланс аккаунта не может стать отрицательным.</p></div>
<div><span>02</span><p>В журнале сохраняются администратор, причина и итоговое значение.</p></div>
<div><span>03</span><p>Ошибочная операция исправляется отдельной обратной корректировкой.</p></div>
</article>
</div>
</section>
<section class="admin-section">
<div class="admin-section__head">
<div><span class="eyebrow">Аудит</span><h2 class="h-serif">Последние транзакции</h2></div>
<form class="admin-search" @submit.prevent="searchTransactions">
<input v-model="searchValue" type="search" placeholder="Логин или ID аккаунта" />
<button class="btn btn-primary" type="submit">Найти</button>
<Link v-if="search" class="btn btn-ghost" :href="urls.admin_balances">Сбросить</Link>
</form>
</div>
<div v-if="transactions.length" class="admin-ledger">
<article v-for="transaction in transactions" :key="transaction.id" class="portal-card admin-ledger__row">
<div>
<strong>{{ transaction.username }}</strong>
<small>ID #{{ transaction.account_id }} · {{ transaction.created_at }}</small>
</div>
<div>
<strong>{{ transaction.type_label }}</strong>
<small>{{ transaction.description }}</small>
<small v-if="transaction.admin">Администратор: {{ transaction.admin.username }} (#{{ transaction.admin.account_id }})</small>
</div>
<div class="admin-ledger__amount">
<strong :class="{ positive: transaction.is_positive }">
{{ transaction.is_positive ? '+' : '' }}{{ transaction.formatted_amount }}
</strong>
<small>Баланс: {{ transaction.formatted_balance_after }}</small>
</div>
</article>
</div>
<article v-else class="portal-card admin-empty"><p>Транзакции не найдены.</p></article>
</section>
</div>
</AdminLayout>
</template>
@@ -0,0 +1,69 @@
<script setup>
import { useForm } from '@inertiajs/vue3';
const props = defineProps({
category: { type: Object, default: null },
action: { type: String, required: true },
submitLabel: { type: String, required: true },
});
const form = useForm({
name: props.category?.name || '',
icon: props.category?.icon || '',
requiredRank: props.category?.required_rank ?? 0,
flags: props.category?.flags ?? 0,
enabled: props.category?.enabled ?? true,
});
function submit() {
const options = {
preserveScroll: true,
onSuccess: () => {
if (!props.category) form.reset();
},
};
if (props.category) {
form.patch(props.action, options);
} else {
form.post(props.action, options);
}
}
</script>
<template>
<form class="admin-form" @submit.prevent="submit">
<div class="admin-form-grid">
<label class="admin-field admin-field--wide">
<span>Название</span>
<input v-model="form.name" maxlength="120" required />
<small v-if="form.errors.name">{{ form.errors.name }}</small>
</label>
<label class="admin-field admin-field--wide">
<span>Иконка клиента</span>
<input v-model="form.icon" maxlength="255" placeholder="inv_helmet_96" />
<small v-if="form.errors.icon">{{ form.errors.icon }}</small>
</label>
<label class="admin-field">
<span>Требуемый ранг</span>
<input v-model="form.requiredRank" type="number" min="0" max="255" required />
<small v-if="form.errors.requiredRank">{{ form.errors.requiredRank }}</small>
</label>
<label class="admin-field">
<span>Флаги</span>
<input v-model="form.flags" type="number" min="0" required />
<small v-if="form.errors.flags">{{ form.errors.flags }}</small>
</label>
</div>
<label class="admin-check">
<input v-model="form.enabled" type="checkbox" />
<span class="admin-check__box"></span>
<span>Категория включена</span>
</label>
<button class="btn btn-primary admin-submit" type="submit" :disabled="form.processing">
{{ form.processing ? 'Сохраняем…' : submitLabel }}
</button>
</form>
</template>
@@ -0,0 +1,90 @@
<script setup>
import { router, useForm } from '@inertiajs/vue3';
const props = defineProps({
item: { type: Object, default: null },
action: { type: String, required: true },
destroyUrl: { type: String, default: null },
allowImage: { type: Boolean, default: false },
labels: { type: Object, required: true },
initial: { type: Object, default: () => ({}) },
});
const form = useForm({
title: props.item?.title ?? props.initial.title ?? '',
sort_order: props.item?.sort_order ?? props.initial.sort_order ?? 0,
is_published: props.item?.is_published ?? props.initial.is_published ?? true,
body: props.item?.body ?? props.initial.body ?? '',
...(props.allowImage ? { image: null } : {}),
});
function submit() {
const options = {
preserveScroll: true,
forceFormData: props.allowImage,
onSuccess: () => {
if (!props.item) form.reset();
},
};
if (props.item && props.allowImage) {
form.transform((data) => ({ ...data, _method: 'patch' })).post(props.action, options);
} else if (props.item) {
form.patch(props.action, options);
} else {
form.post(props.action, options);
}
}
function destroyItem() {
if (props.destroyUrl && window.confirm(props.labels.deleteConfirm)) {
router.delete(props.destroyUrl, { preserveScroll: true });
}
}
</script>
<template>
<form class="admin-form" @submit.prevent="submit">
<div class="admin-form-grid">
<label class="admin-field admin-field--wide">
<span>{{ labels.title }}</span>
<input v-model="form.title" maxlength="120" required />
<small v-if="form.errors.title">{{ form.errors.title }}</small>
</label>
<label class="admin-field">
<span>Порядок вывода</span>
<input v-model="form.sort_order" type="number" min="0" max="9999" required />
<small v-if="form.errors.sort_order">{{ form.errors.sort_order }}</small>
</label>
</div>
<label class="admin-check">
<input v-model="form.is_published" type="checkbox" />
<span class="admin-check__box"></span>
<span>{{ labels.published }}</span>
</label>
<label class="admin-field">
<span>{{ labels.body }}</span>
<textarea v-model="form.body" rows="10" required></textarea>
<em v-if="labels.bodyHint">{{ labels.bodyHint }}</em>
<small v-if="form.errors.body">{{ form.errors.body }}</small>
</label>
<label v-if="allowImage" class="admin-field admin-upload">
<span>{{ item ? 'Заменить картинку' : 'Картинка' }}</span>
<input type="file" accept="image/*" @input="form.image = $event.target.files[0]" />
<em>{{ item ? 'Оставь пустым, чтобы сохранить текущую.' : 'Максимум 2 МБ. Необязательно.' }}</em>
<small v-if="form.errors.image">{{ form.errors.image }}</small>
</label>
<div class="admin-form-actions">
<button class="btn btn-primary admin-submit" type="submit" :disabled="form.processing">
{{ form.processing ? 'Сохраняем…' : labels.submit }}
</button>
<button v-if="destroyUrl" class="btn btn-ghost admin-danger-button" type="button" @click="destroyItem">
{{ labels.delete }}
</button>
</div>
</form>
</template>
@@ -0,0 +1,149 @@
<script setup>
import { useForm } from '@inertiajs/vue3';
const props = defineProps({
product: { type: Object, default: null },
categories: { type: Array, required: true },
productTypes: { type: Array, required: true },
action: { type: String, required: true },
submitLabel: { type: String, required: true },
});
const initialRewards = Object.fromEntries(
Array.from({ length: 8 }, (_, index) => {
const slot = index + 1;
const reward = props.product?.rewards?.find((item) => item.slot === slot);
return [
[`reward_${slot}`, reward?.id ?? 0],
[`rewardcount_${slot}`, reward?.count ?? 0],
];
}).flat(),
);
const form = useForm({
category_ids: props.product?.category_ids || [],
type: props.product?.type ?? 1,
name: props.product?.name || '',
tooltipName: props.product?.tooltip_name || '',
tooltipType: props.product?.tooltip_type || '',
tooltipText: props.product?.tooltip_text || '',
icon: props.product?.icon || '',
price: props.product?.price ?? 1,
hyperlinkId: props.product?.hyperlink_id ?? 0,
creatureEntry: props.product?.creature_entry ?? 0,
discountAmount: props.product?.discount_amount ?? 0,
flags: props.product?.flags ?? 0,
...initialRewards,
new: props.product?.is_new ?? false,
enabled: props.product?.enabled ?? true,
});
function submit() {
const options = { preserveScroll: true };
if (props.product) {
form.patch(props.action, options);
} else {
form.post(props.action, {
...options,
onSuccess: () => form.reset(),
});
}
}
</script>
<template>
<form class="admin-form" @submit.prevent="submit">
<div class="admin-form-grid">
<label class="admin-field admin-field--wide">
<span>Категории</span>
<select v-model="form.category_ids" multiple :size="Math.min(Math.max(categories.length, 2), 8)" required>
<option v-for="category in categories" :key="category.id" :value="category.id">{{ category.name }}</option>
</select>
<em>Можно выбрать несколько с Ctrl/Cmd.</em>
<small v-if="form.errors.category_ids">{{ form.errors.category_ids }}</small>
</label>
<label class="admin-field">
<span>Тип</span>
<select v-model="form.type" required>
<option v-for="type in productTypes" :key="type.value" :value="type.value">{{ type.label }} ({{ type.value }})</option>
</select>
<small v-if="form.errors.type">{{ form.errors.type }}</small>
</label>
<label class="admin-field admin-field--wide">
<span>Название</span>
<textarea v-model="form.name" rows="3" required></textarea>
<small v-if="form.errors.name">{{ form.errors.name }}</small>
</label>
<label class="admin-field">
<span>Название подсказки</span>
<input v-model="form.tooltipName" />
</label>
<label class="admin-field">
<span>Тип подсказки</span>
<input v-model="form.tooltipType" placeholder="item или spell" />
</label>
<label class="admin-field admin-field--wide">
<span>Текст подсказки</span>
<textarea v-model="form.tooltipText" rows="5"></textarea>
</label>
<label class="admin-field admin-field--wide">
<span>Иконка клиента</span>
<input v-model="form.icon" placeholder="inv_sword_22" />
</label>
<label class="admin-field">
<span>Цена, Слёзы Элуны</span>
<input v-model="form.price" type="number" min="1" required />
<small v-if="form.errors.price">{{ form.errors.price }}</small>
</label>
<label class="admin-field">
<span>Скидка, %</span>
<input v-model="form.discountAmount" type="number" min="0" max="100" />
</label>
<label class="admin-field">
<span>Hyperlink ID</span>
<input v-model="form.hyperlinkId" type="number" min="0" />
</label>
<label class="admin-field">
<span>Creature Entry</span>
<input v-model="form.creatureEntry" type="number" min="0" />
</label>
<label class="admin-field">
<span>Флаги</span>
<input v-model="form.flags" type="number" min="0" />
</label>
</div>
<details class="admin-subform">
<summary>Награды · 8 слотов</summary>
<div class="admin-rewards">
<div v-for="slot in 8" :key="slot">
<label class="admin-field">
<span>Награда {{ slot }}</span>
<input v-model="form[`reward_${slot}`]" type="number" min="0" />
</label>
<label class="admin-field">
<span>Количество {{ slot }}</span>
<input v-model="form[`rewardcount_${slot}`]" type="number" min="0" />
</label>
</div>
</div>
</details>
<div class="admin-check-row">
<label class="admin-check">
<input v-model="form.new" type="checkbox" />
<span class="admin-check__box"></span>
<span>Пометка «Новинка»</span>
</label>
<label class="admin-check">
<input v-model="form.enabled" type="checkbox" />
<span class="admin-check__box"></span>
<span>Позиция включена</span>
</label>
</div>
<button class="btn btn-primary admin-submit" type="submit" :disabled="form.processing">
{{ form.processing ? 'Сохраняем…' : submitLabel }}
</button>
</form>
</template>
@@ -0,0 +1,15 @@
<script setup>
defineProps({
items: { type: Array, required: true },
});
</script>
<template>
<div class="admin-stat-grid">
<article v-for="item in items" :key="item.label" class="portal-card admin-stat">
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
<small v-if="item.note">{{ item.note }}</small>
</article>
</div>
</template>
@@ -0,0 +1,120 @@
<script setup>
import { Head } from '@inertiajs/vue3';
import AdminLayout from '../../Layouts/AdminLayout.vue';
import NewsForm from './Components/NewsForm.vue';
import StatCards from './Components/StatCards.vue';
const props = defineProps({
site: { type: Object, required: true },
auth: { type: Object, required: true },
urls: { type: Object, required: true },
flash: { type: Object, default: () => ({}) },
newsItems: { type: Array, default: () => [] },
newsStats: { type: Object, required: true },
feedPreview: { type: String, required: true },
feedUrl: { type: String, required: true },
storeUrl: { type: String, required: true },
old: { type: Object, default: () => ({}) },
});
const stats = [
{ label: 'Всего', value: props.newsStats.total },
{ label: 'Опубликовано', value: props.newsStats.published },
{ label: 'Черновики', value: props.newsStats.draft },
];
const bodyHint = 'Поддерживаются WoW-коды цветов |cFF...|r, иконки |T...|t, переносы строк и обычный текст.';
const createLabels = {
title: 'Название в админке',
published: 'Сразу публиковать эту запись',
body: 'Текст для клиента',
bodyHint,
submit: 'Добавить запись',
delete: 'Удалить запись',
deleteConfirm: 'Удалить эту запись из фида?',
};
const editLabels = {
...createLabels,
title: 'Название',
published: 'Показывать в публичном фиде',
body: 'Текст блока',
submit: 'Сохранить запись',
};
const example = `|TInterface\\ICONS\\achievement_boss_lichking:25:25:0:-5|t |cFF00D1FFWrath of the Lich King|r
|TInterface\\ICONS\\achievement_zone_icecrown_01:18:18:0:-8|t |cFFA6D8FF- Артас пробуждается как Король-лич|r
|TInterface\\ICONS\\spell_deathknight_iceboundfortitude:18:18:0:-8|t |cFFA6D8FF- Рыцари смерти вторгаются в Азерот|r`;
</script>
<template>
<Head><title>Новости WoW — {{ site.name }}</title></Head>
<AdminLayout :site="site" :auth="auth" :urls="urls" :flash="flash" current="login-news">
<section class="admin-hero wrap">
<div>
<span class="eyebrow">Клиент 3.3.5a</span>
<h1 class="h-display">Новости логин-экрана WoW</h1>
<p class="lead">Raw-текст с игровой разметкой и готовый публичный SERVERALERT-фид.</p>
</div>
<div class="admin-hero__badge"><small>Опубликовано</small><strong>{{ newsStats.published }}</strong></div>
</section>
<div class="admin-page wrap">
<section class="admin-section">
<StatCards :items="stats" />
<div class="admin-split">
<article class="portal-card admin-panel corners">
<span class="admin-kicker">Публичная ссылка</span>
<h2 class="h-serif">Фид для клиента</h2>
<p>Ссылка доступна без авторизации и всегда возвращает plain text.</p>
<a class="btn btn-primary" :href="feedUrl" target="_blank" rel="noreferrer">Открыть фид</a>
<code class="admin-code-block">{{ feedUrl }}</code>
</article>
<article class="portal-card admin-panel">
<span class="admin-kicker">Итоговый ответ</span>
<h2 class="h-serif">Превью сервера</h2>
<pre class="admin-feed-preview">{{ feedPreview }}</pre>
</article>
</div>
</section>
<section class="admin-section">
<div class="admin-split">
<article class="portal-card admin-panel corners">
<span class="admin-kicker">Новая запись</span>
<h2 class="h-serif">Добавить новость</h2>
<NewsForm :action="storeUrl" :labels="createLabels" :initial="old" />
</article>
<article class="portal-card admin-panel">
<span class="admin-kicker">Шаблон</span>
<h2 class="h-serif">Пример WoW-блока</h2>
<p>Префикс SERVERALERT добавляется автоматически.</p>
<pre class="admin-feed-preview">{{ example }}</pre>
</article>
</div>
</section>
<section class="admin-section">
<div class="admin-section__head">
<div><span class="eyebrow">Текущие записи</span><h2 class="h-serif">Редактирование и публикация</h2></div>
<p>Опубликованные записи попадают в фид по порядку sort_order.</p>
</div>
<div v-if="newsItems.length" class="admin-editor-list">
<details v-for="item in newsItems" :key="item.id" class="portal-card admin-editor">
<summary>
<span>
<strong>{{ item.title }}</strong>
<small>Порядок: {{ item.sort_order }}<template v-if="item.updated_by"> · {{ item.updated_by }}</template><template v-if="item.updated_at"> · {{ item.updated_at }}</template></small>
</span>
<span :class="['admin-pill', { muted: !item.is_published }]">{{ item.is_published ? 'Опубликовано' : 'Черновик' }}</span>
</summary>
<div class="admin-editor__body">
<NewsForm :item="item" :action="item.update_url" :destroy-url="item.destroy_url" :labels="editLabels" />
</div>
</details>
</div>
<article v-else class="portal-card admin-empty"><p>Записей пока нет.</p></article>
</section>
</div>
</AdminLayout>
</template>
+96
View File
@@ -0,0 +1,96 @@
<script setup>
import { Head } from '@inertiajs/vue3';
import AdminLayout from '../../Layouts/AdminLayout.vue';
import NewsForm from './Components/NewsForm.vue';
import StatCards from './Components/StatCards.vue';
const props = defineProps({
site: { type: Object, required: true },
auth: { type: Object, required: true },
urls: { type: Object, required: true },
flash: { type: Object, default: () => ({}) },
newsItems: { type: Array, default: () => [] },
newsStats: { type: Object, required: true },
storeUrl: { type: String, required: true },
old: { type: Object, default: () => ({}) },
});
const stats = [
{ label: 'Всего', value: props.newsStats.total },
{ label: 'Опубликовано', value: props.newsStats.published },
{ label: 'Черновики', value: props.newsStats.draft },
];
const createLabels = {
title: 'Заголовок',
published: 'Сразу опубликовать',
body: 'Текст новости',
submit: 'Добавить новость',
delete: 'Удалить',
deleteConfirm: 'Удалить эту новость?',
};
const editLabels = {
...createLabels,
published: 'Опубликовано',
submit: 'Сохранить новость',
};
</script>
<template>
<Head><title>Новости — {{ site.name }}</title></Head>
<AdminLayout :site="site" :auth="auth" :urls="urls" :flash="flash" current="news">
<section class="admin-hero wrap">
<div>
<span class="eyebrow">Контент</span>
<h1 class="h-display">Новости</h1>
<p class="lead">Публикации для лендинга и лаунчера с изображениями из S3-хранилища.</p>
</div>
<div class="admin-hero__badge"><small>Опубликовано</small><strong>{{ newsStats.published }}</strong></div>
</section>
<div class="admin-page wrap">
<section class="admin-section">
<StatCards :items="stats" />
<article class="portal-card admin-panel corners admin-create-panel">
<span class="admin-kicker">Новая запись</span>
<h2 class="h-serif">Добавить новость</h2>
<NewsForm :action="storeUrl" :allow-image="true" :labels="createLabels" :initial="old" />
</article>
</section>
<section class="admin-section">
<div class="admin-section__head">
<div><span class="eyebrow">Текущие записи</span><h2 class="h-serif">Редактирование</h2></div>
<p>Записи отсортированы по полю порядка вывода.</p>
</div>
<div v-if="newsItems.length" class="admin-editor-list">
<details v-for="item in newsItems" :key="item.id" class="portal-card admin-editor">
<summary>
<span>
<strong>{{ item.title }}</strong>
<small>Порядок: {{ item.sort_order }} · {{ item.created_by }}<template v-if="item.updated_at"> · {{ item.updated_at }}</template></small>
</span>
<span :class="['admin-pill', { muted: !item.is_published }]">{{ item.is_published ? 'Опубликовано' : 'Черновик' }}</span>
</summary>
<div class="admin-editor__body">
<img v-if="item.image_url" class="admin-news-image" :src="item.image_url" :alt="item.title" />
<NewsForm
:item="item"
:action="item.update_url"
:destroy-url="item.destroy_url"
:allow-image="true"
:labels="editLabels"
/>
</div>
</details>
</div>
<article v-else class="portal-card admin-empty">
<span>☾</span><h3 class="h-serif">Новостей пока нет</h3><p>Добавь первую публикацию.</p>
</article>
</section>
</div>
</AdminLayout>
</template>
+122
View File
@@ -0,0 +1,122 @@
<script setup>
import { Head, router } from '@inertiajs/vue3';
import AdminLayout from '../../Layouts/AdminLayout.vue';
import CategoryForm from './Components/CategoryForm.vue';
import ProductForm from './Components/ProductForm.vue';
import StatCards from './Components/StatCards.vue';
const props = defineProps({
site: { type: Object, required: true },
auth: { type: Object, required: true },
urls: { type: Object, required: true },
flash: { type: Object, default: () => ({}) },
categories: { type: Array, default: () => [] },
products: { type: Array, default: () => [] },
categoryStoreUrl: { type: String, required: true },
productStoreUrl: { type: String, required: true },
productTypes: { type: Array, required: true },
});
const stats = [
{ label: 'Категории', value: props.categories.length },
{ label: 'Позиции', value: props.products.length },
{ label: 'Активные', value: props.products.filter((item) => item.enabled).length },
];
function destroy(url, message) {
if (window.confirm(message)) {
router.delete(url, { preserveScroll: true });
}
}
</script>
<template>
<Head><title>Магазин — {{ site.name }}</title></Head>
<AdminLayout :site="site" :auth="auth" :urls="urls" :flash="flash" current="shop">
<section class="admin-hero wrap">
<div>
<span class="eyebrow">Игровой сервер</span>
<h1 class="h-display">Магазин</h1>
<p class="lead">Категории, товары, награды и цены из базы игрового модуля.</p>
</div>
<div class="admin-hero__badge"><small>Позиций</small><strong>{{ products.length }}</strong></div>
</section>
<div class="admin-page wrap">
<section class="admin-section">
<StatCards :items="stats" />
<div class="admin-notice">
Изменения записываются прямо в базу <code>store</code>. После сохранения перезагрузи данные магазина командой модуля или перезапусти worldserver.
</div>
<div class="admin-split">
<article class="portal-card admin-panel corners">
<span class="admin-kicker">Навигация магазина</span>
<h2 class="h-serif">Новая категория</h2>
<CategoryForm :action="categoryStoreUrl" submit-label="Создать категорию" />
</article>
<article class="portal-card admin-panel corners">
<span class="admin-kicker">Контент магазина</span>
<h2 class="h-serif">Новая позиция</h2>
<ProductForm
v-if="categories.length"
:categories="categories"
:product-types="productTypes"
:action="productStoreUrl"
submit-label="Создать позицию"
/>
<div v-else class="admin-empty-inline">Сначала создай категорию.</div>
</article>
</div>
</section>
<section class="admin-section">
<div class="admin-section__head">
<div><span class="eyebrow">Навигация магазина</span><h2 class="h-serif">Категории</h2></div>
</div>
<div v-if="categories.length" class="admin-editor-list">
<details v-for="category in categories" :key="category.id" class="portal-card admin-editor">
<summary>
<span><strong>#{{ category.id }} · {{ category.name }}</strong><small>{{ category.enabled ? 'Включена' : 'Отключена' }}</small></span>
<span class="admin-pill">{{ category.products_count }} позиций</span>
</summary>
<div class="admin-editor__body">
<CategoryForm :category="category" :action="category.update_url" submit-label="Сохранить категорию" />
<button class="btn btn-ghost admin-danger-button" type="button" @click="destroy(category.destroy_url, 'Удалить категорию и её связи с товарами?')">Удалить категорию</button>
</div>
</details>
</div>
<article v-else class="portal-card admin-empty"><p>Категорий пока нет.</p></article>
</section>
<section class="admin-section">
<div class="admin-section__head">
<div><span class="eyebrow">Контент магазина</span><h2 class="h-serif">Товары и услуги</h2></div>
</div>
<div v-if="products.length" class="admin-editor-list">
<details v-for="product in products" :key="product.id" class="portal-card admin-editor">
<summary>
<span>
<strong class="admin-preline">#{{ product.id }} · {{ product.name }}</strong>
<small>{{ product.category_names.join(', ') || 'Без категории' }} · тип {{ product.type }}</small>
</span>
<span class="admin-pill">{{ product.price }} Слёз Элуны</span>
</summary>
<div class="admin-editor__body">
<ProductForm
:product="product"
:categories="categories"
:product-types="productTypes"
:action="product.update_url"
submit-label="Сохранить позицию"
/>
<button class="btn btn-ghost admin-danger-button" type="button" @click="destroy(product.destroy_url, 'Удалить позицию магазина?')">Удалить позицию</button>
</div>
</details>
</div>
<article v-else class="portal-card admin-empty"><p>Позиций пока нет.</p></article>
</section>
</div>
</AdminLayout>
</template>
+95
View File
@@ -0,0 +1,95 @@
<script setup>
import { Head, useForm, usePage } from '@inertiajs/vue3';
import { computed } from 'vue';
import PortalLayout from '../../Layouts/PortalLayout.vue';
import { arrowSvg, moonMark } from '../Landing/Components/symbols';
const props = defineProps({
site: { type: Object, required: true },
auth: { type: Object, required: true },
urls: { type: Object, required: true },
flash: { type: Object, default: () => ({}) },
old: { type: Object, default: () => ({}) },
});
const page = usePage();
const sharedErrors = computed(() => page.props.errors || {});
const form = useForm({
username: props.old.username || '',
password: '',
});
function submit() {
form.post(props.urls.login_store, {
preserveScroll: true,
onError: () => form.reset('password'),
});
}
</script>
<template>
<Head>
<title>Вход в личный кабинет — {{ site.name }}</title>
<meta name="description" content="Вход в личный кабинет игрового сервера MoonWell." />
</Head>
<PortalLayout :site="site" :auth="auth" :urls="urls" :flash="flash">
<section class="portal-auth wrap">
<div class="portal-auth__intro">
<span class="eyebrow">Личный кабинет</span>
<h1 class="h-display">Вернись к своему приключению</h1>
<p class="lead">
Используй логин и пароль игрового аккаунта. Здесь тебя ждут персонажи,
баланс, настройки безопасности и лаунчер.
</p>
<div class="portal-feature-list">
<div><span>01</span><p>Все персонажи аккаунта в одной сводке</p></div>
<div><span>02</span><p>Пополнение Слёз Элуны и история операций</p></div>
<div><span>03</span><p>Смена игрового пароля без ручных команд</p></div>
</div>
</div>
<form class="portal-card portal-form-card corners" @submit.prevent="submit">
<div class="portal-form-card__mark" v-html="moonMark(48)"></div>
<span class="eyebrow">Авторизация</span>
<h2 class="h-serif">Войти в аккаунт</h2>
<p>Вход выполняется по игровым учётным данным.</p>
<label :class="['portal-field', { 'has-error': sharedErrors.username || form.errors.username }]">
<span>Логин</span>
<input
v-model="form.username"
type="text"
autocomplete="username"
maxlength="32"
placeholder="Moonwalker"
autofocus
required
/>
<small v-if="form.errors.username || sharedErrors.username">{{ form.errors.username || sharedErrors.username }}</small>
</label>
<label :class="['portal-field', { 'has-error': sharedErrors.password || form.errors.password }]">
<span>Пароль</span>
<input
v-model="form.password"
type="password"
autocomplete="current-password"
maxlength="32"
placeholder="Твой пароль"
required
/>
<small v-if="form.errors.password || sharedErrors.password">{{ form.errors.password || sharedErrors.password }}</small>
</label>
<button class="btn btn-primary portal-submit" type="submit" :disabled="form.processing">
{{ form.processing ? 'Входим…' : 'Войти в кабинет' }}
<span v-html="arrowSvg"></span>
</button>
<a class="portal-back-link" :href="urls.home">← Вернуться на главную</a>
</form>
</section>
</PortalLayout>
</template>
+263
View File
@@ -0,0 +1,263 @@
<script setup>
import { Head, useForm, usePage } from '@inertiajs/vue3';
import { computed, ref } from 'vue';
import PortalLayout from '../../Layouts/PortalLayout.vue';
import { arrowSvg } from '../Landing/Components/symbols';
const props = defineProps({
site: { type: Object, required: true },
auth: { type: Object, required: true },
urls: { type: Object, required: true },
csrfToken: { type: String, required: true },
flash: { type: Object, default: () => ({}) },
account: { type: Object, required: true },
characters: { type: Array, default: () => [] },
client: { type: Object, required: true },
wallet: { type: Object, required: true },
});
const page = usePage();
const sharedErrors = computed(() => page.props.errors || {});
const activeSection = ref('overview');
const passwordForm = useForm({
current_password: '',
password: '',
password_confirmation: '',
});
const sections = [
{ id: 'overview', label: 'Обзор', number: '01' },
{ id: 'characters', label: 'Персонажи', number: '02' },
{ id: 'wallet', label: 'Слёзы Элуны', number: '03' },
{ id: 'security', label: 'Безопасность', number: '04' },
];
function goTo(id) {
activeSection.value = id;
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
function updatePassword() {
passwordForm.post(props.urls.password, {
preserveScroll: true,
onSuccess: () => passwordForm.reset(),
onError: () => {
passwordForm.reset('password', 'password_confirmation');
goTo('security');
},
});
}
</script>
<template>
<Head>
<title>Личный кабинет — {{ site.name }}</title>
<meta name="description" content="Персонажи, баланс и настройки игрового аккаунта MoonWell." />
</Head>
<PortalLayout :site="site" :auth="auth" :urls="urls" :flash="flash">
<section class="portal-welcome wrap">
<div>
<span class="eyebrow">Личный кабинет</span>
<h1 class="h-display">С возвращением, <em>{{ account.username }}</em></h1>
<p class="lead">Твой аккаунт, герои и всё необходимое для следующего путешествия.</p>
</div>
<div class="portal-welcome__status">
<span :class="['portal-status-dot', { danger: account.locked }]"></span>
<span>
<small>Статус аккаунта</small>
<strong>{{ account.status_label }}</strong>
</span>
</div>
</section>
<div class="portal-dashboard wrap">
<aside class="portal-sidebar">
<span class="portal-sidebar__label">Навигация</span>
<button
v-for="section in sections"
:key="section.id"
:class="{ active: activeSection === section.id }"
type="button"
@click="goTo(section.id)"
>
<span>{{ section.number }}</span>
{{ section.label }}
</button>
<div class="portal-sidebar__balance">
<small>Баланс</small>
<strong>{{ wallet.formatted_amount }}</strong>
<span>Слёз Элуны</span>
</div>
</aside>
<div class="portal-content">
<section id="overview" class="portal-section">
<div class="portal-section__head">
<div>
<span class="eyebrow">Обзор</span>
<h2 class="h-serif">Данные аккаунта</h2>
</div>
<a v-if="auth.is_admin" class="btn btn-ghost portal-small-button" :href="urls.admin">Открыть админку</a>
</div>
<div class="portal-grid portal-grid--account">
<article class="portal-card portal-account-card corners">
<dl class="portal-stats">
<div><dt>Логин</dt><dd>{{ account.username }}</dd></div>
<div><dt>Email</dt><dd>{{ account.email }}</dd></div>
<div><dt>Уровень доступа</dt><dd>{{ account.access_label }}</dd></div>
<div><dt>Регистрация</dt><dd>{{ account.joined_at }}</dd></div>
<div><dt>Последний вход</dt><dd>{{ account.last_login_at }}</dd></div>
<div><dt>Персонажей</dt><dd>{{ characters.length }}</dd></div>
</dl>
</article>
<article class="portal-card portal-launcher-card corners">
<span class="eyebrow">Лаунчер</span>
<h3 class="h-serif">Врата в Азерот</h3>
<p>Лаунчер автоматически скачает клиент, настроит realmlist и установит обновления.</p>
<a class="btn btn-primary" :href="urls.client">
Скачать лаунчер <span v-html="arrowSvg"></span>
</a>
<span class="portal-file-note">{{ client.filename }}</span>
</article>
</div>
</section>
<section id="characters" class="portal-section">
<div class="portal-section__head">
<div>
<span class="eyebrow">Мои персонажи</span>
<h2 class="h-serif">Герои аккаунта</h2>
</div>
<span class="portal-section__count">{{ characters.length }} {{ characters.length === 1 ? 'герой' : 'героев' }}</span>
</div>
<div v-if="characters.length" class="portal-character-grid">
<article v-for="character in characters" :key="character.name" class="portal-card portal-character-card">
<div class="portal-character-card__head">
<span class="portal-character-level"><span>{{ character.level }}</span></span>
<span :class="['portal-online', { online: character.online }]">
{{ character.online ? 'Онлайн' : 'Оффлайн' }}
</span>
</div>
<h3 class="h-serif">{{ character.name }}</h3>
<p>{{ character.race }} · {{ character.class }}</p>
<dl>
<div><dt>Пол</dt><dd>{{ character.gender }}</dd></div>
<div><dt>Золото</dt><dd>{{ character.money }}</dd></div>
<div><dt>Наработано</dt><dd>{{ character.played }}</dd></div>
</dl>
</article>
</div>
<article v-else class="portal-card portal-empty">
<span class="portal-empty__moon">☾</span>
<h3 class="h-serif">Персонажей пока нет</h3>
<p>Создай первого героя в игре — он появится здесь автоматически.</p>
<a class="btn btn-primary" :href="urls.client">Скачать лаунчер</a>
</article>
</section>
<section id="wallet" class="portal-section">
<div class="portal-section__head">
<div>
<span class="eyebrow">Слёзы Элуны</span>
<h2 class="h-serif">Баланс и операции</h2>
</div>
</div>
<div class="portal-grid portal-grid--wallet">
<article class="portal-card portal-wallet-card corners">
<span class="portal-card-label">Доступно сейчас</span>
<div class="portal-balance">{{ wallet.formatted_amount }}</div>
<p>Слёз Элуны</p>
<form class="portal-deposit" method="POST" :action="urls.deposit">
<input type="hidden" name="_token" :value="csrfToken" />
<label class="portal-field">
<span>Сумма пополнения, ₽</span>
<input
type="number"
name="amount"
:min="wallet.minimum_deposit"
:max="wallet.maximum_deposit"
step="0.01"
:value="wallet.deposit_amount"
required
/>
<small v-if="sharedErrors.amount" class="portal-field-error">{{ sharedErrors.amount }}</small>
</label>
<button class="btn btn-primary" type="submit">
Пополнить <span v-html="arrowSvg"></span>
</button>
</form>
<div class="portal-rate">
<span>1 ₽ = {{ wallet.tears_per_ruble }} Слёз Элуны</span>
<span>От {{ wallet.minimum_deposit }} до {{ wallet.formatted_maximum_deposit }} ₽</span>
</div>
</article>
<article class="portal-card portal-history-card">
<span class="portal-card-label">Последние операции</span>
<div v-if="wallet.transactions.length" class="portal-history">
<div v-for="transaction in wallet.transactions" :key="transaction.id" class="portal-history__row">
<span>
<strong>{{ transaction.label }}</strong>
<small>{{ transaction.created_at }}</small>
</span>
<strong :class="{ positive: transaction.is_positive }">
{{ transaction.is_positive ? '+' : '' }}{{ transaction.formatted_amount }}
</strong>
</div>
</div>
<div v-else class="portal-history-empty">Операций пока нет.</div>
</article>
</div>
</section>
<section id="security" class="portal-section">
<div class="portal-section__head">
<div>
<span class="eyebrow">Безопасность</span>
<h2 class="h-serif">Сменить пароль</h2>
</div>
</div>
<div class="portal-card portal-security-card corners">
<div class="portal-security-card__copy">
<h3 class="h-serif">Защити свой аккаунт</h3>
<p>После обновления новый пароль сразу начнёт действовать и в игре, и в личном кабинете.</p>
<ul>
<li>От 8 до 32 символов</li>
<li>Хотя бы одна буква и одна цифра</li>
<li>Не используй пароль от почты</li>
</ul>
</div>
<form class="portal-password-form" @submit.prevent="updatePassword">
<label :class="['portal-field', { 'has-error': passwordForm.errors.current_password }]">
<span>Текущий пароль</span>
<input v-model="passwordForm.current_password" type="password" autocomplete="current-password" maxlength="32" required />
<small v-if="passwordForm.errors.current_password">{{ passwordForm.errors.current_password }}</small>
</label>
<label :class="['portal-field', { 'has-error': passwordForm.errors.password }]">
<span>Новый пароль</span>
<input v-model="passwordForm.password" type="password" autocomplete="new-password" maxlength="32" required />
<small v-if="passwordForm.errors.password">{{ passwordForm.errors.password }}</small>
</label>
<label :class="['portal-field', { 'has-error': passwordForm.errors.password }]">
<span>Повтори новый пароль</span>
<input v-model="passwordForm.password_confirmation" type="password" autocomplete="new-password" maxlength="32" required />
</label>
<button class="btn btn-primary portal-submit" type="submit" :disabled="passwordForm.processing">
{{ passwordForm.processing ? 'Обновляем…' : 'Обновить пароль' }}
<span v-html="arrowSvg"></span>
</button>
</form>
</div>
</section>
</div>
</div>
</PortalLayout>
</template>
@@ -1,7 +1,10 @@
<script setup>
import { arrowSvg, moonMark } from './symbols';
defineProps({ playUrl: { type: String, required: true } });
defineProps({
playUrl: { type: String, required: true },
legalUrls: { type: Object, required: true },
});
</script>
<template>
@@ -32,14 +35,15 @@ defineProps({ playUrl: { type: String, required: true } });
</div>
<p>Приватный сервер World of Warcraft 3.3.5a с индивидуальным прогрессом, соло-данжами и режимом «Предатель». Бесплатно и без pay-to-win.</p>
</div>
<div class="footer-col"><h4>Сервер</h4><a href="#">Статус реалмов</a><a href="#">Скачать клиент</a><a href="#">Правила</a><a href="#">Дорожная карта</a></div>
<div class="footer-col"><h4>Сервер</h4><a href="#">Статус реалмов</a><a :href="playUrl">Скачать клиент</a><a v-if="legalUrls.published" :href="legalUrls.offer">Публичная оферта</a><a v-if="legalUrls.published" :href="legalUrls.privacy">Конфиденциальность</a></div>
<div class="footer-col"><h4>Сообщество</h4><a href="#">Discord</a><a href="#">Форум</a><a href="#">Поддержка</a><a href="#">Вакансии</a></div>
<div class="footer-col"><h4>База знаний</h4><a href="#">Гайды</a><a href="#">База данных</a><a href="#">Рейтинги</a><a href="#">API</a></div>
</div>
<div class="footer-bottom">
<span>© 2026 MoonWell · fan project · не связан с Blizzard Entertainment</span>
<span>Сделано с лунным светом</span>
<span v-if="legalUrls.published"><a :href="legalUrls.offer">Оферта</a> · <a :href="legalUrls.privacy">Политика конфиденциальности</a></span>
<span v-else>Сделано с лунным светом</span>
</div>
</div>
</footer>
@@ -8,6 +8,7 @@ const props = defineProps({
inviteRequired: { type: Boolean, default: true },
auth: { type: Object, required: true },
flash: { type: Object, required: true },
legalUrls: { type: Object, required: true },
});
const hints = {
@@ -159,7 +160,11 @@ function submit(event) {
<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>
<span v-if="legalUrls.published">
Я принимаю <a :href="legalUrls.offer" target="_blank" rel="noopener">публичную оферту</a>
и <a :href="legalUrls.privacy" target="_blank" rel="noopener">политику конфиденциальности</a>.
</span>
<span v-else>Я принимаю правила проекта и согласен на обработку данных для создания игрового аккаунта.</span>
</label>
<div v-if="errorFor('terms')" class="join-error">{{ errorFor('terms') }}</div>
+3 -2
View File
@@ -18,6 +18,7 @@ const props = defineProps({
posts: { type: Array, default: () => [] },
tweaks: { type: Object, default: () => ({}) },
registerEndpoint: { type: String, required: true },
legalUrls: { type: Object, required: true },
csrfToken: { type: String, required: true },
inviteRequired: { type: Boolean, default: true },
auth: { type: Object, default: () => ({}) },
@@ -59,8 +60,8 @@ onBeforeUnmount(() => revealObserver?.disconnect());
<Realm :realm="realm" />
<About />
<Features />
<Join :register-endpoint="registerEndpoint" :csrf-token="csrfToken" :invite-required="inviteRequired" :auth="auth" :flash="flash" />
<Join :register-endpoint="registerEndpoint" :csrf-token="csrfToken" :invite-required="inviteRequired" :auth="auth" :flash="flash" :legal-urls="legalUrls" />
<News :posts="posts" />
<Footer :play-url="playUrl" />
<Footer :play-url="playUrl" :legal-urls="legalUrls" />
<Tweaks :tweaks="tweaks" />
</template>
+109
View File
@@ -0,0 +1,109 @@
<script setup>
import { Head } from '@inertiajs/vue3';
import { computed } from 'vue';
import LegalDocument from '../../Components/LegalDocument.vue';
import PortalLayout from '../../Layouts/PortalLayout.vue';
const props = defineProps({
site: { type: Object, required: true },
auth: { type: Object, required: true },
urls: { type: Object, required: true },
legal: { type: Object, required: true },
});
const sections = computed(() => [
{
id: 'general',
title: 'Общие положения',
paragraphs: [
`Настоящий документ является публичным предложением ${props.legal.operator_name} заключить соглашение об использовании сервисов проекта ${props.site.name} на изложенных ниже условиях.`,
'Пользователем считается дееспособное лицо, которое зарегистрировало игровой аккаунт, использует сайт, лаунчер или игровые сервисы проекта. Регистрация аккаунта, использование сервисов либо совершение платежа означает полное и безоговорочное принятие условий оферты.',
'Проект является неофициальным фанатским проектом и не связан с Blizzard Entertainment, Inc. Все права на упомянутые товарные знаки и игровые материалы принадлежат их правообладателям.',
],
},
{
id: 'service',
title: 'Предмет соглашения',
paragraphs: [
'Администрация предоставляет Пользователю ограниченный, отзывный и непередаваемый доступ к сайту, личному кабинету, лаунчеру и игровому серверу в их текущем состоянии.',
'Доступ предоставляется исключительно для личного некоммерческого использования. Игровой аккаунт, персонажи, внутриигровые предметы и виртуальная валюта не являются собственностью Пользователя и не имеют гарантированной денежной стоимости.',
],
items: [
'создание и использование одного или нескольких игровых аккаунтов по правилам проекта;',
'доступ к персонажам, игровому прогрессу и функциям личного кабинета;',
'получение обновлений клиента через лаунчер;',
'приобретение виртуальной валюты «Слёзы Элуны» для использования внутри проекта.',
],
},
{
id: 'payments',
title: 'Платежи и виртуальная валюта',
paragraphs: [
'Пользователь вправе добровольно пополнять баланс личного кабинета. Размер начисления, курс, минимальная и максимальная сумма отображаются до совершения платежа.',
'Оплата проводится сторонним платёжным оператором. Администрация не получает и не хранит полные реквизиты банковской карты. Обязательство по начислению считается исполненным после подтверждения успешной оплаты платёжным оператором и отражения суммы в личном кабинете.',
'Слёзы Элуны используются только внутри проекта, не являются электронными деньгами, ценной бумагой или средством платежа и не подлежат обмену между пользователями на реальные деньги.',
],
},
{
id: 'refunds',
title: 'Возвраты и ошибки начисления',
paragraphs: [
'При технической ошибке, двойном списании или отсутствии начисления Пользователь должен обратиться в поддержку и сообщить логин аккаунта, дату, сумму и идентификатор платежа.',
'Решение о возврате принимается с учётом статуса платежа и факта использования начисленной виртуальной валюты. Если цифровое предоставление началось с согласия Пользователя и начисление уже использовано полностью или частично, возврат может быть ограничен в пределах, допускаемых применимым законодательством.',
'Возврат при наличии оснований производится тем же способом, которым была совершена оплата, если иной порядок не согласован сторонами.',
],
},
{
id: 'rules',
title: 'Права и обязанности',
paragraphs: [
'Пользователь обязан предоставлять достоверные данные, обеспечивать сохранность учётных данных, соблюдать правила проекта и не нарушать работу сайта, лаунчера и игрового сервера.',
'Запрещены автоматизация игрового процесса без разрешения, вмешательство в программный код или сетевой обмен, эксплуатация ошибок, мошенничество, продажа аккаунтов и виртуальных ценностей за реальные деньги, а также действия, нарушающие права других лиц.',
'Администрация вправе обновлять сервисы, проводить технические работы, исправлять баланс и игровые ошибки, а при нарушении правил — ограничивать или прекращать доступ к аккаунту.',
],
},
{
id: 'liability',
title: 'Ответственность',
paragraphs: [
'Сервисы предоставляются «как есть». Администрация стремится обеспечивать стабильную работу, но не гарантирует непрерывность доступа, отсутствие ошибок или сохранение конкретных игровых механик и значений.',
'Стороны освобождаются от ответственности за неисполнение обязательств вследствие обстоятельств непреодолимой силы, сбоев инфраструктуры третьих лиц, действий государственных органов и иных обстоятельств вне разумного контроля.',
'Ничто в оферте не ограничивает права Пользователя, которые не могут быть ограничены по закону.',
],
},
{
id: 'changes',
title: 'Изменение и прекращение',
paragraphs: [
'Администрация вправе изменять оферту. Новая редакция публикуется на этой странице и применяется с указанной даты вступления в силу. Продолжение использования сервисов после этой даты означает согласие с новой редакцией.',
'Пользователь может прекратить использование сервисов в любое время. Администрация может прекратить работу проекта или отдельных функций, уведомив пользователей доступным способом, когда это разумно возможно.',
],
},
{
id: 'contacts',
title: 'Контакты и споры',
paragraphs: [
`По вопросам платежей, доступа и условий оферты напишите на ${props.legal.contact_email}. Обращение должно содержать достаточно информации для идентификации аккаунта и ситуации.`,
'Стороны стремятся урегулировать разногласия путём переговоров. Если договориться не удаётся, спор разрешается в порядке, установленном применимым законодательством.',
],
note: `Исполнитель: ${props.legal.operator_name}. Контактный адрес: ${props.legal.contact_email}.`,
},
]);
</script>
<template>
<Head>
<title>Публичная оферта — {{ site.name }}</title>
<meta name="description" content="Публичная оферта игрового проекта MoonWell." />
</Head>
<PortalLayout :site="site" :auth="auth" :urls="urls">
<LegalDocument
eyebrow="Юридическая информация"
title="Публичная оферта"
description="Условия доступа к сервисам MoonWell, пополнения баланса и использования виртуальной валюты."
:effective-date="legal.effective_date"
:sections="sections"
/>
</PortalLayout>
</template>
+117
View File
@@ -0,0 +1,117 @@
<script setup>
import { Head } from '@inertiajs/vue3';
import { computed } from 'vue';
import LegalDocument from '../../Components/LegalDocument.vue';
import PortalLayout from '../../Layouts/PortalLayout.vue';
const props = defineProps({
site: { type: Object, required: true },
auth: { type: Object, required: true },
urls: { type: Object, required: true },
legal: { type: Object, required: true },
});
const sections = computed(() => [
{
id: 'scope',
title: 'Область действия',
paragraphs: [
`Настоящая политика описывает, как ${props.legal.operator_name} обрабатывает информацию пользователей сайта, личного кабинета, лаунчера и игрового сервера ${props.site.name}.`,
'Используя сервисы проекта или создавая аккаунт, Пользователь подтверждает, что ознакомился с этой политикой. Если Пользователь не согласен с условиями обработки, ему следует прекратить использование сервисов.',
],
},
{
id: 'data',
title: 'Какие данные мы обрабатываем',
paragraphs: [
'Состав данных зависит от используемых функций. Мы стремимся собирать только информацию, необходимую для работы проекта, безопасности и исполнения запросов Пользователя.',
],
items: [
'данные аккаунта: логин, email, пароль в виде криптографического проверочного значения, дата регистрации и последнего входа;',
'игровые данные: персонажи, прогресс, предметы, внутриигровые операции и дисциплинарный статус;',
'технические данные: IP-адрес, тип устройства и браузера, версия клиента, журналы авторизации и сведения об ошибках;',
'платёжные данные: сумма, валюта, статус и идентификатор операции; полные реквизиты банковских карт обрабатывает платёжный оператор;',
'данные обращений в поддержку и иная информация, которую Пользователь сообщает добровольно.',
],
},
{
id: 'purposes',
title: 'Цели и основания обработки',
paragraphs: [
'Данные используются для заключения и исполнения пользовательского соглашения, предоставления игровых функций, выполнения платежей, защиты проекта и пользователей, ответа на обращения и соблюдения требований закона.',
'В случаях, когда применимое законодательство требует согласия, обработка выполняется на основании согласия Пользователя. Отдельные данные могут обрабатываться для исполнения договора, выполнения юридической обязанности или защиты законных интересов проекта, если такие интересы не нарушают права Пользователя.',
],
},
{
id: 'cookies',
title: 'Cookies и аналитика',
paragraphs: [
'Сайт использует технические cookies и сведения сессии, необходимые для авторизации, защиты форм и сохранения настроек. Их отключение может сделать часть функций недоступной.',
'Также может использоваться веб-аналитика для оценки посещаемости и улучшения интерфейса. Аналитический сервис может получать обезличенные или псевдонимизированные технические сведения в соответствии со своими условиями.',
],
},
{
id: 'sharing',
title: 'Передача третьим лицам',
paragraphs: [
'Мы не продаём персональные данные. Информация передаётся только в объёме, необходимом для работы сервиса или исполнения требований закона.',
],
items: [
'платёжному оператору — для проведения платежа и обработки возврата;',
'поставщикам хостинга, хранения файлов, мониторинга и защиты инфраструктуры;',
'консультантам и подрядчикам, связанным обязанностью конфиденциальности;',
'государственным органам — при наличии законного и обязательного требования.',
],
},
{
id: 'storage',
title: 'Хранение и защита',
paragraphs: [
'Данные хранятся столько, сколько необходимо для указанных целей, разрешения споров, предотвращения злоупотреблений и выполнения юридических обязанностей. Конкретный срок зависит от категории данных и статуса аккаунта.',
'Мы применяем разумные организационные и технические меры: разграничение доступа, криптографическую защиту учётных данных, журналы безопасности и резервное копирование. При этом ни один способ передачи и хранения данных не может гарантировать абсолютную безопасность.',
],
},
{
id: 'rights',
title: 'Права пользователя',
paragraphs: [
'В пределах применимого законодательства Пользователь может запросить сведения об обработке, исправление неточных данных, удаление или ограничение обработки, отозвать согласие и возразить против отдельных способов обработки.',
`Для реализации прав направьте запрос на ${props.legal.contact_email}. Мы можем запросить подтверждение, необходимое для защиты аккаунта от несанкционированного доступа. Отзыв согласия не влияет на законность обработки, выполненной до его отзыва.`,
],
},
{
id: 'children',
title: 'Несовершеннолетние',
paragraphs: [
'Сервисы не предназначены для самостоятельного совершения платежей лицами, которые по применимому законодательству не вправе заключать такие соглашения без участия законного представителя.',
'Если законный представитель считает, что данные несовершеннолетнего были предоставлены без надлежащего согласия, он может обратиться по контактному адресу для проверки и удаления данных.',
],
},
{
id: 'updates',
title: 'Изменения и контакты',
paragraphs: [
'Политика может обновляться при изменении сервисов или требований законодательства. Актуальная редакция всегда размещается на этой странице с новой датой вступления в силу.',
`Оператор обработки: ${props.legal.operator_name}. Вопросы о конфиденциальности и запросы Пользователей принимаются по адресу ${props.legal.contact_email}.`,
],
note: 'Не отправляйте в обращении пароль, полные реквизиты банковской карты или иные секретные данные.',
},
]);
</script>
<template>
<Head>
<title>Политика конфиденциальности — {{ site.name }}</title>
<meta name="description" content="Политика обработки и защиты данных пользователей MoonWell." />
</Head>
<PortalLayout :site="site" :auth="auth" :urls="urls">
<LegalDocument
eyebrow="Защита данных"
title="Политика конфиденциальности"
description="Какие данные использует MoonWell, зачем они нужны и как пользователь может управлять ими."
:effective-date="legal.effective_date"
:sections="sections"
/>
</PortalLayout>
</template>