донат шоп, управление магазином, яндекс метрика

This commit is contained in:
2026-07-19 19:17:29 +04:00
parent 8bf1b852f0
commit df7f98fe8b
48 changed files with 1859 additions and 18 deletions
@@ -5,6 +5,7 @@ import { arrowSvg, iconPaths, moonMark } from './symbols';
const props = defineProps({
registerEndpoint: { type: String, required: true },
csrfToken: { type: String, required: true },
inviteRequired: { type: Boolean, default: true },
auth: { type: Object, required: true },
flash: { type: Object, required: true },
});
@@ -32,7 +33,7 @@ const clientErrors = computed(() => {
const errors = {};
if (form.username && !/^[a-zA-Z0-9]{3,32}$/.test(form.username)) errors.username = 'Только латиница и цифры, 3-32 символа.';
if (form.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)) errors.email = 'Похоже на некорректный email.';
if (form.invite_code && !/^[A-Za-z0-9-]{6,32}$/.test(form.invite_code)) errors.invite_code = 'Код из букв, цифр и тире, 6-32 символа.';
if (props.inviteRequired && form.invite_code && !/^[A-Za-z0-9-]{6,32}$/.test(form.invite_code)) errors.invite_code = 'Код из букв, цифр и тире, 6-32 символа.';
if (form.password && form.password.length < 8) errors.password = 'Минимум 8 символов.';
else if (form.password && !/[A-Za-z]/.test(form.password)) errors.password = 'Должна быть хотя бы одна буква.';
else if (form.password && !/\d/.test(form.password)) errors.password = 'Должна быть хотя бы одна цифра.';
@@ -43,7 +44,7 @@ const clientErrors = computed(() => {
const valid = computed(() => Boolean(
form.username &&
form.email &&
form.invite_code &&
(!props.inviteRequired || form.invite_code) &&
form.password &&
form.password_confirmation &&
form.terms &&
@@ -77,7 +78,7 @@ function submit(event) {
}
event.preventDefault();
['username', 'email', 'invite_code', 'password', 'password_confirmation', 'terms'].forEach((field) => {
['username', 'email', ...(props.inviteRequired ? ['invite_code'] : []), 'password', 'password_confirmation', 'terms'].forEach((field) => {
touched[field] = true;
});
}
@@ -104,8 +105,8 @@ function submit(event) {
<div style="height: 20px"></div>
<h2 class="h-display" style="font-size: clamp(36px, 5vw, 56px)">Создай игровой аккаунт</h2>
<div class="divider-ornament" style="justify-content: flex-start; margin: 22px 0"><span style="opacity: 0.6"></span></div>
<p class="lead" style="font-style: italic">Укажи инвайт-код, логин, почту и пароль - аккаунт будет готов к входу в игру сразу после регистрации.</p>
<div class="join-note">
<p class="lead" style="font-style: italic">{{ inviteRequired ? 'Укажи инвайт-код, логин, почту и пароль' : 'Укажи логин, почту и пароль' }} аккаунт будет готов к входу в игру сразу после регистрации.</p>
<div v-if="inviteRequired" class="join-note">
<span class="join-note-k">Закрытый бета-доступ</span>
<span class="join-note-v">Пока идет закрытый этап: регистрация только по инвайт-кодам. Код можно получить у друзей-игроков или в нашем Discord.</span>
</div>
@@ -132,7 +133,7 @@ function submit(event) {
<div v-if="errorFor('email')" class="join-error">{{ errorFor('email') }}</div>
</div>
<div :class="['join-field', { 'has-error': errorFor('invite_code') }]">
<div v-if="inviteRequired" :class="['join-field', { 'has-error': errorFor('invite_code') }]">
<label class="join-label">Инвайт-код</label>
<div class="join-input">
<span class="join-input-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" v-html="iconPaths.mask"></svg></span>
+2 -1
View File
@@ -19,6 +19,7 @@ const props = defineProps({
tweaks: { type: Object, default: () => ({}) },
registerEndpoint: { type: String, required: true },
csrfToken: { type: String, required: true },
inviteRequired: { type: Boolean, default: true },
auth: { type: Object, default: () => ({}) },
flash: { type: Object, default: () => ({}) },
});
@@ -58,7 +59,7 @@ onBeforeUnmount(() => revealObserver?.disconnect());
<Realm :realm="realm" />
<About />
<Features />
<Join :register-endpoint="registerEndpoint" :csrf-token="csrfToken" :auth="auth" :flash="flash" />
<Join :register-endpoint="registerEndpoint" :csrf-token="csrfToken" :invite-required="inviteRequired" :auth="auth" :flash="flash" />
<News :posts="posts" />
<Footer :play-url="playUrl" />
<Tweaks :tweaks="tweaks" />
@@ -5,7 +5,7 @@
<div class="section-heading">
<span class="eyebrow">Инвайты</span>
<h2>Управление инвайт-кодами</h2>
<p>Без действующего инвайт-кода регистрация нового аккаунта недоступна.</p>
<p>{{ config('moonwell.registration.require_invite_code') ? 'Регистрация требует действующий инвайт-код.' : 'Регистрация сейчас открыта без инвайт-кодов.' }}</p>
</div>
<div class="portal-grid">
@@ -0,0 +1,102 @@
@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
@@ -0,0 +1,76 @@
@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
@@ -0,0 +1,6 @@
@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>
@@ -0,0 +1,28 @@
@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>
+2 -1
View File
@@ -7,5 +7,6 @@
</head>
<body>
<x-inertia::app />
@include('partials.yandex-metrika')
</body>
</html>
</html>
+38
View File
@@ -56,6 +56,44 @@
</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>
+2
View File
@@ -25,5 +25,7 @@
@csrf
</form>
@endauth
@include('partials.yandex-metrika')
</body>
</html>
+1
View File
@@ -12,5 +12,6 @@
</head>
<body>
@yield('content')
@include('partials.yandex-metrika')
</body>
</html>
+2
View File
@@ -19,6 +19,8 @@
<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
@@ -0,0 +1,13 @@
<!-- Yandex.Metrika counter -->
<script type="text/javascript">
(function(m,e,t,r,i,k,a){
m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};
m[i].l=1*new Date();
for (var j = 0; j < document.scripts.length; j++) {if (document.scripts[j].src === r) { return; }}
k=e.createElement(t),a=e.getElementsByTagName(t)[0],k.async=1,k.src=r,a.parentNode.insertBefore(k,a)
})(window, document,'script','https://mc.yandex.ru/metrika/tag.js?id=109338352', 'ym');
ym(109338352, 'init', {ssr:true, webvisor:true, clickmap:true, ecommerce:"dataLayer", referrer: document.referrer, url: location.href, accurateTrackBounce:true, trackLinks:true});
</script>
<noscript><div><img src="https://mc.yandex.ru/watch/109338352" style="position:absolute; left:-9999px;" alt="" /></div></noscript>
<!-- /Yandex.Metrika counter -->
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="ru">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Переход к оплате</title></head>
<body>
<p>Переходим к пополнению…</p>
<form id="robokassa-payment" method="POST" action="{{ $paymentUrl }}">
@foreach ($parameters as $name => $value)
<input type="hidden" name="{{ $name }}" value="{{ $value }}">
@endforeach
<button type="submit">Перейти к оплате</button>
</form>
<script>document.getElementById('robokassa-payment').submit();</script>
</body>
</html>