новый дизайн

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
File diff suppressed because it is too large Load Diff
+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>
@@ -1,178 +0,0 @@
@extends('layouts.portal')
@section('portal-content')
<section class="section">
<div class="section-heading">
<span class="eyebrow">Инвайты</span>
<h2>Управление инвайт-кодами</h2>
<p>{{ config('moonwell.registration.require_invite_code') ? 'Регистрация требует действующий инвайт-код.' : 'Регистрация сейчас открыта без инвайт-кодов.' }}</p>
</div>
<div class="portal-grid">
<article class="content-card">
<h3>Создать инвайт-коды</h3>
<dl class="stats-list stats-list--compact">
<div>
<dt>Всего</dt>
<dd>{{ $inviteStats['total'] }}</dd>
</div>
<div>
<dt>Доступно</dt>
<dd>{{ $inviteStats['available'] }}</dd>
</div>
<div>
<dt>Использовано</dt>
<dd>{{ $inviteStats['redeemed'] }}</dd>
</div>
</dl>
<form class="inline-form" method="POST" action="{{ route('admin.invite-codes.store') }}">
@csrf
<label class="form-field">
<span>Количество</span>
<input class="form-control @error('quantity') is-invalid @enderror" type="number" min="1" max="50" name="quantity" value="{{ old('quantity', 1) }}" required>
@error('quantity')<span class="form-error">{{ $message }}</span>@enderror
</label>
<button class="button button--gold" type="submit">Создать коды</button>
</form>
</article>
<article class="content-card">
<h3>Последние инвайт-коды</h3>
@if ($inviteCodes->isNotEmpty())
<div class="character-list">
@foreach ($inviteCodes as $invite)
<div class="character-row">
<div>
<strong>{{ $invite->code }}</strong>
<span>
Создал: {{ $invite->created_by_username ?: '—' }}
@if ($invite->created_at)
· {{ $invite->created_at->format('d.m.Y H:i') }}
@endif
</span>
</div>
<div>
<strong>{{ $invite->redeemed_at ? 'Использован' : 'Свободен' }}</strong>
<span>
{{ $invite->redeemed_by_username ?: 'ещё не использован' }}
</span>
</div>
</div>
@endforeach
</div>
@else
<div class="empty-inline">Инвайт-коды ещё не создавались.</div>
@endif
</article>
</div>
</section>
<section class="section">
<div class="section-heading">
<span class="eyebrow">Админка</span>
<h2>Аккаунты и персонажи</h2>
<p>Здесь видны все игровые аккаунты, их персонажи и текущий уровень доступа.</p>
</div>
<div class="tab-switcher">
@foreach ($accountTabs as $tabKey => $tab)
<a
class="button {{ $activeTab === $tabKey ? 'button--gold' : 'button--ghost' }}"
href="{{ route('admin.accounts.index', array_filter(['tab' => $tabKey, 'search' => $search !== '' ? $search : null])) }}"
>
{{ $tab['label'] }} · {{ $tab['count'] }}
</a>
@endforeach
</div>
<form class="search-form" method="GET" action="{{ route('admin.accounts.index') }}">
<input type="hidden" name="tab" value="{{ $activeTab }}">
<input class="form-control" type="search" name="search" value="{{ $search }}" placeholder="Поиск по логину или email">
<button class="button button--gold" type="submit">Найти</button>
@if ($search !== '')
<a class="button button--ghost" href="{{ route('admin.accounts.index', ['tab' => $activeTab]) }}">Сбросить</a>
@endif
</form>
</section>
<section class="section">
<div class="account-list">
@forelse ($accounts as $account)
<article class="content-card account-card">
<div class="account-card__header">
<div>
<h3>{{ $account['username'] }}</h3>
<p>ID #{{ $account['id'] }} · {{ $account['email'] !== '' ? $account['email'] : 'email не указан' }}</p>
</div>
<span class="tag">{{ $account['access_label'] }}</span>
</div>
<dl class="stats-list stats-list--compact">
<div>
<dt>Регистрация</dt>
<dd>{{ $account['joined_at'] }}</dd>
</div>
<div>
<dt>Последний вход</dt>
<dd>{{ $account['last_login_at'] }}</dd>
</div>
<div>
<dt>Статус</dt>
<dd>{{ $account['locked'] ? 'Заблокирован' : 'Активен' }}</dd>
</div>
<div>
<dt>Персонажей</dt>
<dd>{{ $account['characters']->count() }}</dd>
</div>
</dl>
<form class="inline-form" method="POST" action="{{ route('admin.accounts.access.update', $account['id']) }}">
@csrf
@method('PATCH')
<label class="form-field">
<span>Уровень доступа</span>
<select class="form-control @error('gmlevel') is-invalid @enderror" name="gmlevel">
@foreach ($accessLevels as $level => $label)
<option value="{{ $level }}" @selected($account['gm_level'] === $level)>{{ $label }}</option>
@endforeach
</select>
</label>
<button class="button button--gold" type="submit">Сохранить</button>
</form>
@if ($account['characters']->isNotEmpty())
<div class="character-list">
@foreach ($account['characters'] as $character)
<div class="character-row">
<div>
<strong>{{ $character['name'] }}</strong>
<span>{{ $character['race'] }} · {{ $character['class'] }}</span>
</div>
<div>
<strong>{{ $character['level'] }} ур.</strong>
<span>{{ $character['online'] ? 'Онлайн' : 'Оффлайн' }}</span>
</div>
</div>
@endforeach
</div>
@else
<div class="empty-inline">На аккаунте пока нет персонажей.</div>
@endif
</article>
@empty
<article class="content-card empty-state">
<h3>{{ $activeTab === 'bots' ? 'Боты не найдены' : 'Игроки не найдены' }}</h3>
<p>Измени поисковый запрос или переключись на другую вкладку.</p>
</article>
@endforelse
</div>
</section>
@endsection
@@ -1,102 +0,0 @@
@extends('layouts.portal')
@section('portal-content')
<section class="section">
<div class="section-heading">
<span class="eyebrow">Финансы</span>
<h2>Слёзы Элуны</h2>
<p>Ручные операции сохраняются в общем журнале с администратором и причиной изменения.</p>
</div>
<div class="portal-grid">
<article class="content-card">
<h3>Изменить количество</h3>
<form class="register-form" method="POST" action="{{ route('admin.balances.adjust') }}">
@csrf
<label class="form-field">
<span>ID или логин аккаунта</span>
<input class="form-control @error('account') is-invalid @enderror" name="account" value="{{ old('account') }}" required>
@error('account')<span class="form-error">{{ $message }}</span>@enderror
</label>
<label class="form-field">
<span>Операция</span>
<select class="form-control @error('direction') is-invalid @enderror" name="direction" required>
<option value="credit" @selected(old('direction') === 'credit')>Начислить</option>
<option value="debit" @selected(old('direction') === 'debit')>Снять</option>
</select>
</label>
<label class="form-field">
<span>Количество Слёз Элуны</span>
<input class="form-control @error('amount') is-invalid @enderror" type="number" name="amount" min="0.01" max="1000000" step="0.01" value="{{ old('amount') }}" required>
@error('amount')<span class="form-error">{{ $message }}</span>@enderror
</label>
<label class="form-field">
<span>Причина</span>
<input class="form-control @error('reason') is-invalid @enderror" name="reason" maxlength="255" value="{{ old('reason') }}" placeholder="Компенсация, возврат, нарушение…" required>
@error('reason')<span class="form-error">{{ $message }}</span>@enderror
</label>
<button class="button button--gold button--full" type="submit">Провести операцию</button>
</form>
</article>
<article class="content-card">
<h3>Правила операций</h3>
<p>Количество нельзя сделать отрицательным. Каждая корректировка содержит ID администратора, логин, причину и итоговое значение.</p>
<p>Операции необратимы в журнале: ошибочное начисление исправляется отдельным списанием.</p>
</article>
</div>
</section>
<section class="section">
<div class="section-heading">
<span class="eyebrow">Аудит</span>
<h2>Последние транзакции</h2>
</div>
<form class="search-form" method="GET" action="{{ route('admin.balances.index') }}">
<input class="form-control" type="search" name="search" value="{{ $search }}" placeholder="Логин или ID аккаунта">
<button class="button button--gold" type="submit">Найти</button>
@if ($search !== '')
<a class="button button--ghost" href="{{ route('admin.balances.index') }}">Сбросить</a>
@endif
</form>
<div class="account-list balance-ledger">
@forelse ($transactions as $transaction)
@php($meta = $transaction->metadata ?? [])
<article class="content-card balance-ledger__row">
<div>
<strong>{{ $transaction->username ?: 'Аккаунт удалён' }}</strong>
<span>ID #{{ $transaction->account_id }} · {{ $transaction->created_at->format('d.m.Y H:i:s') }}</span>
</div>
<div>
<strong>{{ match ($transaction->type) {
'deposit' => 'Пополнение',
'spend' => 'Покупка в игре',
'admin_credit' => 'Начисление администратором',
'admin_debit' => 'Списание администратором',
default => $transaction->type,
} }}</strong>
<span>{{ $meta['reason'] ?? $meta['description'] ?? $transaction->reference }}</span>
@if (isset($meta['admin_username']))
<span>Администратор: {{ $meta['admin_username'] }} (#{{ $meta['admin_account_id'] }})</span>
@endif
</div>
<div class="balance-ledger__amount">
<strong class="{{ (float) $transaction->amount >= 0 ? 'balance-operation--positive' : '' }}">
{{ (float) $transaction->amount >= 0 ? '+' : '' }}{{ number_format((float) $transaction->amount, 2, ',', ' ') }}
</strong>
<span>Слёз Элуны: {{ number_format((float) $transaction->balance_after, 2, ',', ' ') }}</span>
</div>
</article>
@empty
<article class="content-card empty-state"><p>Транзакции не найдены.</p></article>
@endforelse
</div>
</section>
@endsection
@@ -1,165 +0,0 @@
@extends('layouts.portal')
@section('portal-content')
<section class="section">
<div class="section-heading">
<span class="eyebrow">Админка</span>
<h2>Новости логин-экрана WoW</h2>
<p>Публикуй raw-текст с WoW-разметкой, а сайт сам отдаст готовый `SERVERALERT`-фид по открытой ссылке для клиента 3.3.5a.</p>
</div>
<div class="portal-grid">
<article class="content-card">
<h3>Публичная ссылка для клиента</h3>
<p>Эту ссылку можно вставить в логин-скрин клиента. Ответ возвращается как plain text и доступен без авторизации.</p>
<div class="action-stack">
<a class="button button--gold" href="{{ $feedUrl }}" target="_blank" rel="noreferrer">Открыть фид</a>
<code class="code-block">{{ $feedUrl }}</code>
</div>
<dl class="stats-list stats-list--compact">
<div>
<dt>Всего записей</dt>
<dd>{{ $newsStats['total'] }}</dd>
</div>
<div>
<dt>Опубликовано</dt>
<dd>{{ $newsStats['published'] }}</dd>
</div>
<div>
<dt>Черновиков</dt>
<dd>{{ $newsStats['draft'] }}</dd>
</div>
</dl>
</article>
<article class="content-card">
<h3>Итоговый ответ сервера</h3>
<p>Это превью полного ответа, который получит клиент по публичному URL.</p>
<pre class="feed-preview">{{ $feedPreview }}</pre>
</article>
</div>
</section>
<section class="section section--split">
<article class="content-card">
<span class="eyebrow">Новая запись</span>
<h2>Добавить новость</h2>
<p>Каждая запись становится отдельным блоком в общем фиде. Префикс `SERVERALERT:` добавляется автоматически.</p>
<form class="stack-form" method="POST" action="{{ route('admin.login-screen-news.store') }}">
@csrf
<label class="form-field">
<span>Название в админке</span>
<input class="form-control @error('title') is-invalid @enderror" type="text" name="title" value="{{ old('title') }}" maxlength="120" required>
@error('title')<span class="form-error">{{ $message }}</span>@enderror
</label>
<label class="form-field">
<span>Порядок вывода</span>
<input class="form-control @error('sort_order') is-invalid @enderror" type="number" name="sort_order" value="{{ old('sort_order', 0) }}" min="0" max="9999" required>
@error('sort_order')<span class="form-error">{{ $message }}</span>@enderror
</label>
<label class="checkbox-inline">
<input type="checkbox" name="is_published" value="1" @checked(old('is_published', true))>
<span>Сразу публиковать эту запись</span>
</label>
<label class="form-field">
<span>Текст для клиента</span>
<textarea class="form-control form-control--textarea @error('body') is-invalid @enderror" name="body" required>{{ old('body') }}</textarea>
<small>Поддерживаются WoW-коды цветов `|cFF...|r`, иконки `|T...|t`, переносы строк и обычный текст.</small>
@error('body')<span class="form-error">{{ $message }}</span>@enderror
</label>
<button class="button button--gold" type="submit">Добавить запись</button>
</form>
</article>
<article class="content-card">
<span class="eyebrow">Шаблон</span>
<h2>Пример WoW-блока</h2>
<p>Если нужно, можно вставлять уже готовые строки почти в том же виде, как у тебя в примере с GitHub raw.</p>
<pre class="feed-preview feed-preview--compact">|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</pre>
</article>
</section>
<section class="section">
<div class="section-heading">
<span class="eyebrow">Текущие записи</span>
<h2>Редактирование и публикация</h2>
<p>Опубликованные записи попадают в фид по порядку `sort_order`. Черновики остаются только в админке.</p>
</div>
<div class="account-list">
@forelse ($newsItems as $newsItem)
<article class="content-card news-card">
<div class="account-card__header">
<div>
<h3>{{ $newsItem->title }}</h3>
<p>
Порядок: {{ $newsItem->sort_order }}
@if ($newsItem->updated_by_username)
· Обновил: {{ $newsItem->updated_by_username }}
@endif
@if ($newsItem->updated_at)
· {{ $newsItem->updated_at->format('d.m.Y H:i') }}
@endif
</p>
</div>
<span class="tag">{{ $newsItem->is_published ? 'Опубликовано' : 'Черновик' }}</span>
</div>
<form class="stack-form" method="POST" action="{{ route('admin.login-screen-news.update', $newsItem) }}">
@csrf
@method('PATCH')
<label class="form-field">
<span>Название</span>
<input class="form-control" type="text" name="title" value="{{ $newsItem->title }}" maxlength="120" required>
</label>
<label class="form-field">
<span>Порядок вывода</span>
<input class="form-control" type="number" name="sort_order" value="{{ $newsItem->sort_order }}" min="0" max="9999" required>
</label>
<label class="checkbox-inline">
<input type="checkbox" name="is_published" value="1" @checked($newsItem->is_published)>
<span>Показывать в публичном фиде</span>
</label>
<label class="form-field">
<span>Текст блока</span>
<textarea class="form-control form-control--textarea" name="body" required>{{ $newsItem->body }}</textarea>
</label>
<div class="form-actions">
<button class="button button--gold" type="submit">Сохранить</button>
</div>
</form>
<form method="POST" action="{{ route('admin.login-screen-news.destroy', $newsItem) }}">
@csrf
@method('DELETE')
<button class="button button--ghost" type="submit" onclick="return confirm('Удалить эту запись из фида?')">Удалить запись</button>
</form>
</article>
@empty
<article class="content-card empty-state">
<h3>Записей пока нет</h3>
<p>Добавь первую новость, и публичный фид сразу начнёт отдавать её клиенту.</p>
</article>
@endforelse
</div>
</section>
@endsection
-154
View File
@@ -1,154 +0,0 @@
@extends('layouts.portal')
@section('portal-content')
<section class="section">
<div class="section-heading">
<span class="eyebrow">Админка</span>
<h2>Новости</h2>
<p>Новости отображаются в лаунчере. Картинка загружается в S3-хранилище.</p>
</div>
<div class="portal-grid">
<article class="content-card">
<h3>Статистика</h3>
<dl class="stats-list stats-list--compact">
<div>
<dt>Всего</dt>
<dd>{{ $newsStats['total'] }}</dd>
</div>
<div>
<dt>Опубликовано</dt>
<dd>{{ $newsStats['published'] }}</dd>
</div>
<div>
<dt>Черновиков</dt>
<dd>{{ $newsStats['draft'] }}</dd>
</div>
</dl>
</article>
</div>
</section>
<section class="section">
<article class="content-card">
<span class="eyebrow">Новая запись</span>
<h2>Добавить новость</h2>
<form class="stack-form" method="POST" action="{{ route('admin.news.store') }}" enctype="multipart/form-data">
@csrf
<label class="form-field">
<span>Заголовок</span>
<input class="form-control @error('title') is-invalid @enderror" type="text" name="title" value="{{ old('title') }}" maxlength="120" required>
@error('title')<span class="form-error">{{ $message }}</span>@enderror
</label>
<label class="form-field">
<span>Порядок вывода</span>
<input class="form-control @error('sort_order') is-invalid @enderror" type="number" name="sort_order" value="{{ old('sort_order', 0) }}" min="0" max="9999" required>
@error('sort_order')<span class="form-error">{{ $message }}</span>@enderror
</label>
<label class="checkbox-inline">
<input type="checkbox" name="is_published" value="1" @checked(old('is_published', true))>
<span>Сразу опубликовать</span>
</label>
<label class="form-field">
<span>Текст новости</span>
<textarea class="form-control form-control--textarea @error('body') is-invalid @enderror" name="body" required>{{ old('body') }}</textarea>
@error('body')<span class="form-error">{{ $message }}</span>@enderror
</label>
<label class="form-field">
<span>Картинка</span>
<input class="form-control @error('image') is-invalid @enderror" type="file" name="image" accept="image/*">
<small>Максимум 2 МБ. Необязательно.</small>
@error('image')<span class="form-error">{{ $message }}</span>@enderror
</label>
<button class="button button--gold" type="submit">Добавить новость</button>
</form>
</article>
</section>
<section class="section">
<div class="section-heading">
<span class="eyebrow">Текущие записи</span>
<h2>Редактирование</h2>
</div>
<div class="account-list">
@forelse ($newsItems as $newsItem)
<article class="content-card news-card">
<div class="account-card__header">
<div>
<h3>{{ $newsItem->title }}</h3>
<p>
Порядок: {{ $newsItem->sort_order }}
· {{ $newsItem->created_by_username ?? '—' }}
@if ($newsItem->updated_at)
· {{ $newsItem->updated_at->format('d.m.Y H:i') }}
@endif
</p>
</div>
<span class="tag">{{ $newsItem->is_published ? 'Опубликовано' : 'Черновик' }}</span>
</div>
@if ($newsItem->image_url)
<img src="{{ $newsItem->image_url }}" alt="{{ $newsItem->title }}" style="max-width: 320px; border-radius: 6px; margin-bottom: 1rem;">
@endif
<form class="stack-form" method="POST" action="{{ route('admin.news.update', $newsItem) }}" enctype="multipart/form-data">
@csrf
@method('PATCH')
<label class="form-field">
<span>Заголовок</span>
<input class="form-control" type="text" name="title" value="{{ $newsItem->title }}" maxlength="120" required>
</label>
<label class="form-field">
<span>Порядок вывода</span>
<input class="form-control" type="number" name="sort_order" value="{{ $newsItem->sort_order }}" min="0" max="9999" required>
</label>
<label class="checkbox-inline">
<input type="checkbox" name="is_published" value="1" @checked($newsItem->is_published)>
<span>Опубликовано</span>
</label>
<label class="form-field">
<span>Текст новости</span>
<textarea class="form-control form-control--textarea" name="body" required>{{ $newsItem->body }}</textarea>
</label>
<label class="form-field">
<span>Заменить картинку</span>
<input class="form-control" type="file" name="image" accept="image/*">
<small>Оставь пустым, чтобы сохранить текущую.</small>
</label>
<div class="form-actions">
<button class="button button--gold" type="submit">Сохранить</button>
</div>
</form>
<form method="POST" action="{{ route('admin.news.destroy', $newsItem) }}">
@csrf
@method('DELETE')
<button class="button button--ghost" type="submit" onclick="return confirm('Удалить эту новость?')">Удалить</button>
</form>
</article>
@empty
<article class="content-card empty-state">
<h3>Новостей пока нет</h3>
<p>Добавь первую новость — она сразу появится в лаунчере.</p>
</article>
@endforelse
</div>
</section>
@endsection
@@ -1,76 +0,0 @@
@extends('layouts.portal')
@section('portal-content')
<section class="section">
<div class="section-heading">
<span class="eyebrow">Игровой сервер</span>
<h2>Магазин</h2>
<p>Изменения записываются прямо в базу <code>store</code>. После сохранения перезагрузи данные магазина командой игрового модуля или перезапусти worldserver.</p>
</div>
<div class="portal-grid">
<article class="content-card">
<h3>Новая категория</h3>
<form class="stack-form" method="POST" action="{{ route('admin.shop.categories.store') }}">
@csrf
@include('admin.shop.partials.category-fields', ['category' => null])
<button class="button button--gold" type="submit">Создать категорию</button>
</form>
</article>
<article class="content-card">
<h3>Новый товар или услуга</h3>
@if ($categories->isEmpty())
<p>Сначала создай категорию.</p>
@else
<form class="stack-form" method="POST" action="{{ route('admin.shop.products.store') }}">
@csrf
@include('admin.shop.partials.product-fields', ['product' => null])
<button class="button button--gold" type="submit">Создать позицию</button>
</form>
@endif
</article>
</div>
</section>
<section class="section">
<div class="section-heading"><span class="eyebrow">Навигация магазина</span><h2>Категории</h2></div>
<div class="account-list">
@forelse ($categories as $category)
<article class="content-card">
<div class="account-card__header"><h3>#{{ $category->id }} · {{ $category->name }}</h3><span class="tag">{{ $category->products_count }} позиций</span></div>
<form class="stack-form" method="POST" action="{{ route('admin.shop.categories.update', $category) }}">
@csrf @method('PATCH')
@include('admin.shop.partials.category-fields', ['category' => $category])
<button class="button button--gold" type="submit">Сохранить</button>
</form>
<form method="POST" action="{{ route('admin.shop.categories.destroy', $category) }}">@csrf @method('DELETE')<button class="button button--ghost" onclick="return confirm('Удалить категорию и её связи с товарами?')">Удалить</button></form>
</article>
@empty
<article class="content-card empty-state"><p>Категорий пока нет.</p></article>
@endforelse
</div>
</section>
<section class="section">
<div class="section-heading"><span class="eyebrow">Контент магазина</span><h2>Товары и услуги</h2></div>
<div class="account-list">
@forelse ($products as $product)
<article class="content-card news-card">
<div class="account-card__header">
<div><h3>#{{ $product->id }} · {!! nl2br(e($product->name)) !!}</h3><p>{{ $product->categories->pluck('name')->join(', ') ?: 'Без категории' }} · тип {{ $product->type }}</p></div>
<span class="tag">{{ $product->price }} Слёз Элуны</span>
</div>
<form class="stack-form" method="POST" action="{{ route('admin.shop.products.update', $product) }}">
@csrf @method('PATCH')
@include('admin.shop.partials.product-fields', ['product' => $product])
<button class="button button--gold" type="submit">Сохранить</button>
</form>
<form method="POST" action="{{ route('admin.shop.products.destroy', $product) }}">@csrf @method('DELETE')<button class="button button--ghost" onclick="return confirm('Удалить позицию магазина?')">Удалить</button></form>
</article>
@empty
<article class="content-card empty-state"><p>Позиций пока нет.</p></article>
@endforelse
</div>
</section>
@endsection
@@ -1,6 +0,0 @@
@php($category = $category ?? null)
<label class="form-field"><span>Название</span><input class="form-control" name="name" value="{{ $category?->name }}" maxlength="120" required></label>
<label class="form-field"><span>Иконка клиента</span><input class="form-control" name="icon" value="{{ $category?->icon }}" placeholder="inv_helmet_96" maxlength="255"></label>
<label class="form-field"><span>Требуемый ранг</span><input class="form-control" type="number" name="requiredRank" value="{{ $category?->requiredRank ?? 0 }}" min="0" max="255" required></label>
<label class="form-field"><span>Флаги</span><input class="form-control" type="number" name="flags" value="{{ $category?->flags ?? 0 }}" min="0" required></label>
<label class="checkbox-inline"><input type="checkbox" name="enabled" value="1" @checked($category?->enabled ?? true)><span>Включена</span></label>
@@ -1,28 +0,0 @@
@php($product = $product ?? null)
@php($selectedCategories = $product?->categories?->pluck('id')->all() ?? [])
<label class="form-field"><span>Категории</span><select class="form-control" name="category_ids[]" multiple size="{{ min(max($categories->count(), 2), 8) }}" required>@foreach ($categories as $category)<option value="{{ $category->id }}" @selected(in_array($category->id, $selectedCategories, true))>{{ $category->name }}</option>@endforeach</select><small>Можно выбрать несколько с Ctrl/Cmd.</small></label>
<label class="form-field"><span>Тип</span><select class="form-control" name="type" required>@foreach ([1 => 'Предмет', 3 => 'Маунт', 4 => 'Питомец', 5 => 'Бафф', 7 => 'Услуга персонажа', 8 => 'Буст', 9 => 'Титул'] as $type => $label)<option value="{{ $type }}" @selected(($product?->type ?? 1) === $type)>{{ $label }} ({{ $type }})</option>@endforeach</select></label>
<label class="form-field"><span>Название</span><textarea class="form-control" name="name" required>{{ $product?->name }}</textarea></label>
<label class="form-field"><span>Название подсказки</span><input class="form-control" name="tooltipName" value="{{ $product?->tooltipName }}"></label>
<label class="form-field"><span>Тип подсказки</span><input class="form-control" name="tooltipType" value="{{ $product?->tooltipType }}" placeholder="item или spell"></label>
<label class="form-field"><span>Текст подсказки</span><textarea class="form-control form-control--textarea" name="tooltipText">{{ $product?->tooltipText }}</textarea></label>
<label class="form-field"><span>Иконка клиента</span><input class="form-control" name="icon" value="{{ $product?->icon }}" placeholder="inv_sword_22"></label>
<div class="form-row">
<label class="form-field"><span>Цена, Слёзы Элуны</span><input class="form-control" type="number" name="price" value="{{ $product?->price ?? 1 }}" min="1" required></label>
<label class="form-field"><span>Скидка, %</span><input class="form-control" type="number" name="discountAmount" value="{{ $product?->discountAmount ?? 0 }}" min="0" max="100"></label>
</div>
<div class="form-row">
<label class="form-field"><span>Hyperlink ID</span><input class="form-control" type="number" name="hyperlinkId" value="{{ $product?->hyperlinkId ?? 0 }}" min="0"></label>
<label class="form-field"><span>Creature Entry</span><input class="form-control" type="number" name="creatureEntry" value="{{ $product?->creatureEntry ?? 0 }}" min="0"></label>
<label class="form-field"><span>Флаги</span><input class="form-control" type="number" name="flags" value="{{ $product?->flags ?? 0 }}" min="0"></label>
</div>
<div class="shop-reward-grid">
@foreach (range(1, 8) as $slot)
<label class="form-field"><span>Награда {{ $slot }}</span><input class="form-control" type="number" name="reward_{{ $slot }}" value="{{ $product?->{'reward_'.$slot} ?? 0 }}" min="0"></label>
<label class="form-field"><span>Количество {{ $slot }}</span><input class="form-control" type="number" name="rewardcount_{{ $slot }}" value="{{ $product?->{'rewardcount_'.$slot} ?? 0 }}" min="0"></label>
@endforeach
</div>
<div class="form-actions">
<label class="checkbox-inline"><input type="checkbox" name="new" value="1" @checked($product?->new ?? false)><span>Пометка «Новинка»</span></label>
<label class="checkbox-inline"><input type="checkbox" name="enabled" value="1" @checked($product?->enabled ?? true)><span>Включён</span></label>
</div>
-61
View File
@@ -1,61 +0,0 @@
@extends('layouts.portal')
@section('portal-content')
<section class="section section--split">
<article class="content-card">
<span class="eyebrow">Личный кабинет</span>
<h2>Вход в игровой аккаунт</h2>
<p>
Используй логин и пароль от игрового аккаунта. После входа будут доступны персонажи,
смена пароля и ссылка на клиент игры.
</p>
<ul class="feature-points">
<li>Просмотр всех персонажей аккаунта</li>
<li>Смена пароля без ручных команд</li>
<li>Быстрый переход к клиенту игры</li>
</ul>
</article>
<aside class="hero-panel">
<div class="panel-header">
<span class="eyebrow">Авторизация</span>
<h2>Войти</h2>
<p>Вход выполняется по игровым учётным данным.</p>
</div>
<form class="register-form" method="POST" action="{{ route('login.store') }}">
@csrf
<label class="form-field">
<span>Логин</span>
<input
class="form-control @error('username') is-invalid @enderror"
type="text"
name="username"
value="{{ old('username') }}"
autocomplete="username"
maxlength="32"
required
>
@error('username')<span class="form-error">{{ $message }}</span>@enderror
</label>
<label class="form-field">
<span>Пароль</span>
<input
class="form-control @error('password') is-invalid @enderror"
type="password"
name="password"
autocomplete="current-password"
maxlength="32"
required
>
@error('password')<span class="form-error">{{ $message }}</span>@enderror
</label>
<button class="button button--gold button--full" type="submit">Войти в кабинет</button>
</form>
</aside>
</section>
@endsection
-190
View File
@@ -1,190 +0,0 @@
@extends('layouts.portal')
@section('portal-content')
<section class="section">
<div class="section-heading">
<span class="eyebrow">Личный кабинет</span>
<h2>{{ $user->username }}</h2>
<p>Здесь можно посмотреть персонажей, сменить пароль и скачать лаунчер.</p>
</div>
<div class="portal-grid">
<article class="content-card">
<h3>Данные аккаунта</h3>
<dl class="stats-list">
<div>
<dt>Логин</dt>
<dd>{{ $user->username }}</dd>
</div>
<div>
<dt>Email</dt>
<dd>{{ $user->email !== '' ? $user->email : '—' }}</dd>
</div>
<div>
<dt>Уровень доступа</dt>
<dd>{{ $user->accessLabel() }}</dd>
</div>
<div>
<dt>Регистрация</dt>
<dd>{{ $user->joinedAtLabel() }}</dd>
</div>
<div>
<dt>Последний вход</dt>
<dd>{{ $user->lastLoginLabel() }}</dd>
</div>
<div>
<dt>Статус</dt>
<dd>{{ $user->locked ? 'Заблокирован' : 'Активен' }}</dd>
</div>
</dl>
@if ($user->canAccessAdminPanel())
<a class="button button--ghost" href="{{ route('admin.accounts.index') }}">Перейти в админку</a>
@endif
</article>
<article class="content-card">
<h3>Лаунчер</h3>
<p>Скачай лаунчер для автоматической загрузки и обновления клиента игры.</p>
<div class="action-stack">
<a class="button button--gold" href="{{ route('cabinet.client') }}">Скачать лаунчер</a>
<span class="muted-note">Файл: {{ $clientFilename }}</span>
</div>
</article>
</div>
</section>
<section class="section section--split">
<article class="content-card">
<span class="eyebrow">Слёзы Элуны</span>
<h2>{{ number_format((float) $balance, 2, ',', ' ') }}</h2>
<p>Пополняй счёт и используй Слёзы Элуны для покупок в игре.</p>
<form class="register-form" method="POST" action="{{ route('cabinet.balance.deposit') }}">
@csrf
<label class="form-field">
<span>Сумма пополнения, ₽</span>
<input class="form-control @error('amount') is-invalid @enderror" type="number" name="amount" min="{{ $minimumDeposit }}" max="{{ $maximumDeposit }}" step="0.01" value="{{ old('amount', 500) }}" required>
@error('amount')<span class="form-error">{{ $message }}</span>@enderror
</label>
<button class="button button--gold button--full" type="submit">Пополнить</button>
<span class="muted-note">1 ₽ = {{ $tearsPerRuble }} Слёз Элуны</span>
<span class="muted-note">От {{ $minimumDeposit }} до {{ number_format((float) $maximumDeposit, 0, ',', ' ') }} ₽</span>
</form>
</article>
<article class="content-card">
<span class="eyebrow">История</span>
<h2>Последние операции</h2>
@forelse ($balanceTransactions as $transaction)
<div class="balance-operation">
<div>
<strong>{{ $transaction->type === 'deposit' ? 'Пополнение' : 'Покупка в игре' }}</strong>
<span>{{ $transaction->created_at->format('d.m.Y H:i') }}</span>
</div>
<strong class="{{ (float) $transaction->amount >= 0 ? 'balance-operation--positive' : '' }}">
{{ (float) $transaction->amount >= 0 ? '+' : '' }}{{ number_format((float) $transaction->amount, 2, ',', ' ') }}
</strong>
</div>
@empty
<div class="empty-inline">Операций пока нет.</div>
@endforelse
</article>
</section>
<section class="section section--split">
<article class="content-card">
<span class="eyebrow">Безопасность</span>
<h2>Сменить пароль</h2>
<p>После обновления новый пароль сразу начнёт действовать для входа в игру и кабинет.</p>
<form class="register-form" method="POST" action="{{ route('cabinet.password.update') }}">
@csrf
<label class="form-field">
<span>Текущий пароль</span>
<input class="form-control @error('current_password') is-invalid @enderror" type="password" name="current_password" maxlength="32" required>
@error('current_password')<span class="form-error">{{ $message }}</span>@enderror
</label>
<label class="form-field">
<span>Новый пароль</span>
<input class="form-control @error('password') is-invalid @enderror" type="password" name="password" maxlength="32" required>
</label>
<label class="form-field">
<span>Подтверждение нового пароля</span>
<input class="form-control @error('password') is-invalid @enderror" type="password" name="password_confirmation" maxlength="32" required>
@error('password')<span class="form-error">{{ $message }}</span>@enderror
</label>
<button class="button button--gold button--full" type="submit">Обновить пароль</button>
</form>
</article>
<article class="content-card">
<span class="eyebrow">Персонажи</span>
<h2>Краткая сводка</h2>
<p>Всего персонажей: {{ $characters->count() }}. Ниже показан полный список по этому аккаунту.</p>
@if ($characters->isNotEmpty())
<div class="tag-list">
@foreach ($characters as $character)
<span class="tag">{{ $character['name'] }} · {{ $character['class'] }} · {{ $character['level'] }}</span>
@endforeach
</div>
@else
<div class="empty-inline">Персонажей пока нет.</div>
@endif
</article>
</section>
<section class="section">
<div class="section-heading">
<span class="eyebrow">Мои персонажи</span>
<h2>Все персонажи аккаунта</h2>
<p>Здесь отображаются игровые персонажи, связанные с текущим аккаунтом.</p>
</div>
<div class="character-grid">
@forelse ($characters as $character)
<article class="character-card">
<div class="character-card__top">
<div>
<h3>{{ $character['name'] }}</h3>
<p>{{ $character['race'] }} · {{ $character['class'] }}</p>
</div>
<span class="tag">{{ $character['online'] ? 'Онлайн' : 'Оффлайн' }}</span>
</div>
<dl class="stats-list stats-list--compact">
<div>
<dt>Уровень</dt>
<dd>{{ $character['level'] }}</dd>
</div>
<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>
@empty
<article class="content-card empty-state">
<h3>Персонажей пока нет</h3>
<p>Создай персонажа в игре, и он появится здесь автоматически.</p>
</article>
@endforelse
</div>
</section>
@endsection
-17
View File
@@ -1,17 +0,0 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ config('moonwell.realm.server_name') }} — регистрация WoW аккаунта</title>
<meta name="description" content="Лендинг и форма регистрации игрового аккаунта для сервера Moonwell.">
<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=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{{ asset('site.css') }}">
</head>
<body>
@yield('content')
@include('partials.yandex-metrika')
</body>
</html>
-55
View File
@@ -1,55 +0,0 @@
@extends('layouts.app')
@section('content')
@php($portalUser = auth()->user())
<div class="page-shell">
<header class="topbar">
<a class="brand" href="{{ route('landing.index') }}">
<span class="brand-mark"></span>
<span>
<strong>{{ config('moonwell.realm.server_name') }}</strong>
<small>{{ config('moonwell.realm.tagline') }}</small>
</span>
</a>
<nav class="topnav">
<a href="{{ route('landing.index') }}">Главная</a>
@if ($portalUser)
<a href="{{ route('cabinet.index') }}">Личный кабинет</a>
@if ($portalUser->canAccessAdminPanel())
<a href="{{ route('admin.accounts.index') }}">Аккаунты</a>
<a href="{{ route('admin.balances.index') }}">Слёзы Элуны</a>
<a href="{{ route('admin.shop.index') }}">Магазин</a>
<a href="{{ route('admin.login-screen-news.index') }}">Новости WoW</a>
@endif
@endif
</nav>
<div class="topbar-actions">
@if ($portalUser)
<span class="topbar-user">{{ $portalUser->username }} · {{ $portalUser->accessLabel() }}</span>
<form method="POST" action="{{ route('logout') }}">
@csrf
<button class="button button--ghost" type="submit">Выйти</button>
</form>
@else
<a class="button button--ghost" href="{{ route('login') }}">Войти</a>
@endif
</div>
</header>
@if (session('status'))
<div class="alert alert--success">{{ session('status') }}</div>
@endif
@if (session('error'))
<div class="alert alert--error">{{ session('error') }}</div>
@endif
<main>
@yield('portal-content')
</main>
</div>
@endsection