From df7f98fe8bd7e5dc843dfbc59611e03c187c7e04 Mon Sep 17 00:00:00 2001 From: sindoring Date: Sun, 19 Jul 2026 19:17:29 +0400 Subject: [PATCH] =?UTF-8?q?=D0=B4=D0=BE=D0=BD=D0=B0=D1=82=20=D1=88=D0=BE?= =?UTF-8?q?=D0=BF,=20=D1=83=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=BC=D0=B0=D0=B3=D0=B0=D0=B7=D0=B8=D0=BD=D0=BE?= =?UTF-8?q?=D0=BC,=20=D1=8F=D0=BD=D0=B4=D0=B5=D0=BA=D1=81=20=D0=BC=D0=B5?= =?UTF-8?q?=D1=82=D1=80=D0=B8=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 22 ++ .../Controllers/Admin/BalanceController.php | 59 +++++ app/Http/Controllers/Admin/ShopController.php | 65 ++++++ .../Controllers/Api/GameBalanceController.php | 44 ++++ .../Controllers/Api/RobokassaController.php | 30 +++ app/Http/Controllers/CabinetController.php | 8 +- app/Http/Controllers/LandingController.php | 17 +- app/Http/Controllers/PaymentController.php | 42 ++++ app/Http/Middleware/EnsureGameServer.php | 22 ++ .../Requests/Admin/AdjustBalanceRequest.php | 19 ++ .../Admin/SaveShopCategoryRequest.php | 24 ++ .../Requests/Admin/SaveShopProductRequest.php | 51 ++++ .../Requests/RegisterGameAccountRequest.php | 2 +- app/Models/BalanceTransaction.php | 29 +++ app/Models/PaymentInvoice.php | 27 +++ app/Models/ShopCategory.php | 33 +++ app/Models/ShopProduct.php | 33 +++ app/Services/BalanceService.php | 164 +++++++++++++ app/Services/RobokassaService.php | 218 ++++++++++++++++++ app/Services/ShopCatalogService.php | 106 +++++++++ bootstrap/app.php | 6 + config/database.php | 16 ++ config/moonwell.php | 1 + config/services.php | 28 +++ ..._000001_create_moonwell_balance_tables.php | 50 ++++ docs/game-balance-integration.md | 48 ++++ public/site.css | 66 ++++++ .../js/Pages/Landing/Components/Join.vue | 13 +- resources/js/Pages/Landing/Main.vue | 3 +- .../views/admin/accounts/index.blade.php | 2 +- .../views/admin/balances/index.blade.php | 102 ++++++++ resources/views/admin/shop/index.blade.php | 76 ++++++ .../shop/partials/category-fields.blade.php | 6 + .../shop/partials/product-fields.blade.php | 28 +++ resources/views/app.blade.php | 3 +- resources/views/cabinet/index.blade.php | 38 +++ resources/views/landing.blade.php | 2 + resources/views/layouts/app.blade.php | 1 + resources/views/layouts/portal.blade.php | 2 + .../views/partials/yandex-metrika.blade.php | 13 ++ resources/views/payments/redirect.blade.php | 14 ++ routes/api.php | 11 + routes/web.php | 21 ++ tests/Feature/AdminBalancesFeatureTest.php | 79 +++++++ tests/Feature/AdminShopFeatureTest.php | 81 +++++++ tests/Feature/BalancePaymentsFeatureTest.php | 121 ++++++++++ tests/Feature/CabinetFeatureTest.php | 8 +- tests/Feature/GameAccountRegistrationTest.php | 23 ++ 48 files changed, 1859 insertions(+), 18 deletions(-) create mode 100644 app/Http/Controllers/Admin/BalanceController.php create mode 100644 app/Http/Controllers/Admin/ShopController.php create mode 100644 app/Http/Controllers/Api/GameBalanceController.php create mode 100644 app/Http/Controllers/Api/RobokassaController.php create mode 100644 app/Http/Controllers/PaymentController.php create mode 100644 app/Http/Middleware/EnsureGameServer.php create mode 100644 app/Http/Requests/Admin/AdjustBalanceRequest.php create mode 100644 app/Http/Requests/Admin/SaveShopCategoryRequest.php create mode 100644 app/Http/Requests/Admin/SaveShopProductRequest.php create mode 100644 app/Models/BalanceTransaction.php create mode 100644 app/Models/PaymentInvoice.php create mode 100644 app/Models/ShopCategory.php create mode 100644 app/Models/ShopProduct.php create mode 100644 app/Services/BalanceService.php create mode 100644 app/Services/RobokassaService.php create mode 100644 app/Services/ShopCatalogService.php create mode 100644 database/migrations/2026_07_19_000001_create_moonwell_balance_tables.php create mode 100644 docs/game-balance-integration.md create mode 100644 resources/views/admin/balances/index.blade.php create mode 100644 resources/views/admin/shop/index.blade.php create mode 100644 resources/views/admin/shop/partials/category-fields.blade.php create mode 100644 resources/views/admin/shop/partials/product-fields.blade.php create mode 100644 resources/views/partials/yandex-metrika.blade.php create mode 100644 resources/views/payments/redirect.blade.php create mode 100644 tests/Feature/AdminBalancesFeatureTest.php create mode 100644 tests/Feature/AdminShopFeatureTest.php create mode 100644 tests/Feature/BalancePaymentsFeatureTest.php diff --git a/.env.example b/.env.example index 197efbd..b41ad05 100644 --- a/.env.example +++ b/.env.example @@ -89,11 +89,18 @@ AZEROTHCORE_CHARACTERS_DB_PASSWORD=password AZEROTHCORE_CHARACTERS_DB_SOCKET= AZEROTHCORE_CHARACTERS_DB_CHARSET=utf8mb4 AZEROTHCORE_CHARACTERS_DB_COLLATION=utf8mb4_unicode_ci +STORE_DB_HOST=host.docker.internal +STORE_DB_PORT=3306 +STORE_DB_DATABASE=store +STORE_DB_USERNAME=root +STORE_DB_PASSWORD=password +STORE_DB_SOCKET= AZEROTHCORE_ACCOUNT_GMLEVEL=0 AZEROTHCORE_ACCOUNT_REALM_ID=-1 AZEROTHCORE_ACCOUNT_EXPANSION=2 AZEROTHCORE_ACCOUNT_ACCESS_COMMENT="registered via moonwell-web" AZEROTHCORE_ENFORCE_UNIQUE_EMAIL=false +MOONWELL_REGISTRATION_REQUIRE_INVITE=true MOONWELL_ADMIN_MIN_GMLEVEL=3 MOONWELL_ADMIN_ACCESS_COMMENT="updated via moonwell-web admin" MOONWELL_BOT_ACCOUNT_PREFIX=RNDBOT @@ -112,3 +119,18 @@ MOONWELL_CLIENT_OBJECT_KEY="World of Warcraft.zip" MOONWELL_CLIENT_URL_TTL=30 VITE_APP_NAME="${APP_NAME}" + + +ROBOKASSA_MODE=test +ROBOKASSA_TEST_LOGIN= +ROBOKASSA_TEST_PASSWORD1= +ROBOKASSA_TEST_PASSWORD2= +ROBOKASSA_TEST_HASH_ALGORITHM=md5 +ROBOKASSA_PRODUCTION_LOGIN= +ROBOKASSA_PRODUCTION_PASSWORD1= +ROBOKASSA_PRODUCTION_PASSWORD2= +ROBOKASSA_PRODUCTION_HASH_ALGORITHM=md5 +ROBOKASSA_MINIMUM_AMOUNT=10 +ROBOKASSA_MAXIMUM_AMOUNT=100000 +MOONWELL_TEARS_PER_RUBLE=10 +GAME_SERVER_BALANCE_API_TOKEN= diff --git a/app/Http/Controllers/Admin/BalanceController.php b/app/Http/Controllers/Admin/BalanceController.php new file mode 100644 index 0000000..aef8e6d --- /dev/null +++ b/app/Http/Controllers/Admin/BalanceController.php @@ -0,0 +1,59 @@ +query('search', '')); + + return view('admin.balances.index', [ + 'transactions' => $balances->adminTransactions($search !== '' ? $search : null), + 'search' => $search, + ]); + } + + public function adjust( + AdjustBalanceRequest $request, + AzerothCoreAccountService $accounts, + BalanceService $balances, + ): RedirectResponse { + $accountInput = trim($request->string('account')->toString()); + $account = ctype_digit($accountInput) + ? $accounts->findUserById((int) $accountInput) + : $accounts->findUserByUsername($accountInput); + + if (! $account) { + return back()->withInput()->withErrors(['account' => 'Игровой аккаунт не найден.']); + } + + try { + $transaction = $balances->adjustByAdmin( + $account->id, + $request->string('amount')->toString(), + $request->string('direction')->toString(), + $request->user()->id, + $request->user()->username, + $request->string('reason')->toString(), + ); + } catch (InvalidArgumentException $exception) { + return back()->withInput()->withErrors(['amount' => $exception->getMessage()]); + } + + return back()->with('status', sprintf( + 'Счёт %s изменён. Теперь на нём %s Слёз Элуны.', + $account->username, + number_format((float) $transaction->balance_after, 2, ',', ' '), + )); + } +} diff --git a/app/Http/Controllers/Admin/ShopController.php b/app/Http/Controllers/Admin/ShopController.php new file mode 100644 index 0000000..6d01db7 --- /dev/null +++ b/app/Http/Controllers/Admin/ShopController.php @@ -0,0 +1,65 @@ + $catalog->categories(), + 'products' => $catalog->products(), + ]); + } + + public function storeCategory(SaveShopCategoryRequest $request, ShopCatalogService $catalog): RedirectResponse + { + $catalog->createCategory($request->validated()); + + return back()->with('status', 'Категория создана.'); + } + + public function updateCategory(ShopCategory $category, SaveShopCategoryRequest $request, ShopCatalogService $catalog): RedirectResponse + { + $catalog->updateCategory($category, $request->validated()); + + return back()->with('status', 'Категория обновлена.'); + } + + public function destroyCategory(ShopCategory $category, ShopCatalogService $catalog): RedirectResponse + { + $catalog->deleteCategory($category); + + return back()->with('status', 'Категория удалена.'); + } + + public function storeProduct(SaveShopProductRequest $request, ShopCatalogService $catalog): RedirectResponse + { + $catalog->createProduct($request->validated()); + + return back()->with('status', 'Товар создан.'); + } + + public function updateProduct(ShopProduct $product, SaveShopProductRequest $request, ShopCatalogService $catalog): RedirectResponse + { + $catalog->updateProduct($product, $request->validated()); + + return back()->with('status', 'Товар обновлён.'); + } + + public function destroyProduct(ShopProduct $product, ShopCatalogService $catalog): RedirectResponse + { + $catalog->deleteProduct($product); + + return back()->with('status', 'Товар удалён.'); + } +} diff --git a/app/Http/Controllers/Api/GameBalanceController.php b/app/Http/Controllers/Api/GameBalanceController.php new file mode 100644 index 0000000..c0a5f87 --- /dev/null +++ b/app/Http/Controllers/Api/GameBalanceController.php @@ -0,0 +1,44 @@ +json(['account_id' => $accountId, 'balance' => $balances->balance($accountId)]); + } + + public function spend(Request $request, int $accountId, BalanceService $balances): JsonResponse + { + $data = $request->validate([ + 'amount' => ['required', 'numeric', 'decimal:0,2', 'gt:0'], + 'idempotency_key' => ['required', 'string', 'max:160'], + 'description' => ['nullable', 'string', 'max:255'], + ]); + + try { + $transaction = $balances->spend( + $accountId, + (string) $data['amount'], + 'game:'.$data['idempotency_key'], + $data['description'] ?? null, + ); + } catch (InvalidArgumentException $exception) { + return response()->json(['message' => $exception->getMessage()], 422); + } + + return response()->json([ + 'transaction_id' => $transaction->id, + 'account_id' => $transaction->account_id, + 'amount' => $transaction->amount, + 'balance' => $transaction->balance_after, + ]); + } +} diff --git a/app/Http/Controllers/Api/RobokassaController.php b/app/Http/Controllers/Api/RobokassaController.php new file mode 100644 index 0000000..e4da800 --- /dev/null +++ b/app/Http/Controllers/Api/RobokassaController.php @@ -0,0 +1,30 @@ +processResult($request->all()); + } catch (InvalidArgumentException $exception) { + report($exception); + + return response('bad signature', 400)->header('Content-Type', 'text/plain'); + } catch (Throwable $exception) { + report($exception); + + return response('temporary error', 503)->header('Content-Type', 'text/plain'); + } + + return response('OK'.$invoice->id)->header('Content-Type', 'text/plain'); + } +} diff --git a/app/Http/Controllers/CabinetController.php b/app/Http/Controllers/CabinetController.php index e2cf657..b96dd5a 100644 --- a/app/Http/Controllers/CabinetController.php +++ b/app/Http/Controllers/CabinetController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers; use App\Http\Requests\ChangePasswordRequest; use App\Services\AzerothCoreAccountService; +use App\Services\BalanceService; use App\Services\GameClientDownloadService; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -12,7 +13,7 @@ use Throwable; class CabinetController extends Controller { - public function index(Request $request, AzerothCoreAccountService $accounts): View + public function index(Request $request, AzerothCoreAccountService $accounts, BalanceService $balances): View { $user = $request->user(); @@ -20,6 +21,11 @@ class CabinetController extends Controller 'user' => $user, 'characters' => $accounts->charactersForAccount($user->id), 'clientFilename' => config('moonwell.client.object_key'), + 'balance' => $balances->balance($user->id), + 'balanceTransactions' => $balances->recentTransactions($user->id), + 'minimumDeposit' => config('services.robokassa.minimum_amount'), + 'maximumDeposit' => config('services.robokassa.maximum_amount'), + 'tearsPerRuble' => config('services.robokassa.tears_per_ruble'), ]); } diff --git a/app/Http/Controllers/LandingController.php b/app/Http/Controllers/LandingController.php index 5c53133..eb741f4 100644 --- a/app/Http/Controllers/LandingController.php +++ b/app/Http/Controllers/LandingController.php @@ -78,6 +78,7 @@ class LandingController extends Controller 'moon' => true, ], 'registerEndpoint' => route('game-account.store'), + 'inviteRequired' => (bool) config('moonwell.registration.require_invite_code'), 'csrfToken' => csrf_token(), 'auth' => [ 'authenticated' => $user !== null, @@ -110,15 +111,19 @@ class LandingController extends Controller ): RedirectResponse { try { - $result = $inviteCodes->redeemForRegistration( - $request->string('invite_code')->toString(), - $request->string('username')->toString(), - fn (): array => $registrar->register( + $register = fn (): array => $registrar->register( $request->string('username')->toString(), $request->string('email')->toString(), $request->string('password')->toString(), - ), - ); + ); + + $result = config('moonwell.registration.require_invite_code') + ? $inviteCodes->redeemForRegistration( + $request->string('invite_code')->toString(), + $request->string('username')->toString(), + $register, + ) + : $register(); } catch (DuplicateGameAccountException $exception) { return back() ->withErrors([$exception->field => $exception->getMessage()]) diff --git a/app/Http/Controllers/PaymentController.php b/app/Http/Controllers/PaymentController.php new file mode 100644 index 0000000..fe655e9 --- /dev/null +++ b/app/Http/Controllers/PaymentController.php @@ -0,0 +1,42 @@ +validate([ + 'amount' => [ + 'required', + 'numeric', + 'decimal:0,2', + Rule::numeric()->min((float) config('services.robokassa.minimum_amount'))->max((float) config('services.robokassa.maximum_amount')), + ], + ]); + + $user = $request->user(); + $invoice = $robokassa->createInvoice($user->id, (string) $validated['amount']); + + return view('payments.redirect', [ + 'paymentUrl' => config('services.robokassa.payment_url'), + 'parameters' => $robokassa->paymentParameters($invoice, $user->email), + ]); + } + + public function success(): RedirectResponse + { + return redirect()->route('cabinet.index')->with('status', 'Пополнение принято. Слёзы Элуны скоро появятся на счёте.'); + } + + public function fail(): RedirectResponse + { + return redirect()->route('cabinet.index')->with('error', 'Оплата отменена или не была завершена.'); + } +} diff --git a/app/Http/Middleware/EnsureGameServer.php b/app/Http/Middleware/EnsureGameServer.php new file mode 100644 index 0000000..f22b79f --- /dev/null +++ b/app/Http/Middleware/EnsureGameServer.php @@ -0,0 +1,22 @@ +bearerToken(); + + if ($expected === '' || $provided === '' || ! hash_equals($expected, $provided)) { + abort(401, 'Неверный токен игрового сервера.'); + } + + return $next($request); + } +} diff --git a/app/Http/Requests/Admin/AdjustBalanceRequest.php b/app/Http/Requests/Admin/AdjustBalanceRequest.php new file mode 100644 index 0000000..8a3f050 --- /dev/null +++ b/app/Http/Requests/Admin/AdjustBalanceRequest.php @@ -0,0 +1,19 @@ + ['required', 'string', 'max:255'], + 'direction' => ['required', Rule::in(['credit', 'debit'])], + 'amount' => ['required', 'numeric', 'decimal:0,2', 'gt:0', 'max:1000000'], + 'reason' => ['required', 'string', 'min:3', 'max:255'], + ]; + } +} diff --git a/app/Http/Requests/Admin/SaveShopCategoryRequest.php b/app/Http/Requests/Admin/SaveShopCategoryRequest.php new file mode 100644 index 0000000..62bc427 --- /dev/null +++ b/app/Http/Requests/Admin/SaveShopCategoryRequest.php @@ -0,0 +1,24 @@ +merge(['enabled' => $this->boolean('enabled')]); + } + + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:120'], + 'icon' => ['nullable', 'string', 'max:255'], + 'requiredRank' => ['required', 'integer', 'min:0', 'max:255'], + 'flags' => ['required', 'integer', 'min:0'], + 'enabled' => ['boolean'], + ]; + } +} diff --git a/app/Http/Requests/Admin/SaveShopProductRequest.php b/app/Http/Requests/Admin/SaveShopProductRequest.php new file mode 100644 index 0000000..9047ef5 --- /dev/null +++ b/app/Http/Requests/Admin/SaveShopProductRequest.php @@ -0,0 +1,51 @@ +merge(['new' => $this->boolean('new'), 'enabled' => $this->boolean('enabled')]); + } + + public function rules(): array + { + return [ + 'category_ids' => ['required', 'array', 'min:1'], + 'category_ids.*' => ['integer', Rule::exists('store.store_categories', 'id')], + 'type' => ['required', 'integer', Rule::in([1, 3, 4, 5, 7, 8, 9])], + 'name' => ['required', 'string', 'max:765'], + 'tooltipName' => ['nullable', 'string', 'max:765'], + 'tooltipType' => ['nullable', 'string', 'max:765'], + 'tooltipText' => ['nullable', 'string', 'max:10000'], + 'icon' => ['nullable', 'string', 'max:765'], + 'price' => ['required', 'integer', 'min:1', 'max:100000000'], + 'hyperlinkId' => ['nullable', 'integer', 'min:0'], + 'creatureEntry' => ['nullable', 'integer', 'min:0'], + 'discountAmount' => ['nullable', 'integer', 'min:0', 'max:100'], + 'flags' => ['nullable', 'integer', 'min:0'], + 'new' => ['boolean'], + 'enabled' => ['boolean'], + 'reward_1' => ['nullable', 'integer', 'min:0'], + 'reward_2' => ['nullable', 'integer', 'min:0'], + 'reward_3' => ['nullable', 'integer', 'min:0'], + 'reward_4' => ['nullable', 'integer', 'min:0'], + 'reward_5' => ['nullable', 'integer', 'min:0'], + 'reward_6' => ['nullable', 'integer', 'min:0'], + 'reward_7' => ['nullable', 'integer', 'min:0'], + 'reward_8' => ['nullable', 'integer', 'min:0'], + 'rewardcount_1' => ['nullable', 'integer', 'min:0'], + 'rewardcount_2' => ['nullable', 'integer', 'min:0'], + 'rewardcount_3' => ['nullable', 'integer', 'min:0'], + 'rewardcount_4' => ['nullable', 'integer', 'min:0'], + 'rewardcount_5' => ['nullable', 'integer', 'min:0'], + 'rewardcount_6' => ['nullable', 'integer', 'min:0'], + 'rewardcount_7' => ['nullable', 'integer', 'min:0'], + 'rewardcount_8' => ['nullable', 'integer', 'min:0'], + ]; + } +} diff --git a/app/Http/Requests/RegisterGameAccountRequest.php b/app/Http/Requests/RegisterGameAccountRequest.php index 8765d1a..50a8b3f 100644 --- a/app/Http/Requests/RegisterGameAccountRequest.php +++ b/app/Http/Requests/RegisterGameAccountRequest.php @@ -19,7 +19,7 @@ class RegisterGameAccountRequest extends FormRequest return [ 'username' => ['required', 'string', 'min:3', 'max:32', 'regex:/^[A-Za-z0-9]+$/'], 'email' => ['required', 'string', 'email:rfc', 'max:255'], - 'invite_code' => ['required', 'string', 'min:6', 'max:32'], + 'invite_code' => [config('moonwell.registration.require_invite_code') ? 'required' : 'nullable', 'string', 'min:6', 'max:32'], 'password' => ['required', 'string', 'min:8', 'max:32', 'confirmed', 'regex:/^(?=.*[A-Za-z])(?=.*\d).+$/'], 'terms' => ['accepted'], ]; diff --git a/app/Models/BalanceTransaction.php b/app/Models/BalanceTransaction.php new file mode 100644 index 0000000..f049f18 --- /dev/null +++ b/app/Models/BalanceTransaction.php @@ -0,0 +1,29 @@ + 'decimal:2', 'balance_after' => 'decimal:2', 'metadata' => 'array']; + } +} diff --git a/app/Models/PaymentInvoice.php b/app/Models/PaymentInvoice.php new file mode 100644 index 0000000..5aa9310 --- /dev/null +++ b/app/Models/PaymentInvoice.php @@ -0,0 +1,27 @@ + 'decimal:2', 'paid_at' => 'datetime']; + } +} diff --git a/app/Models/ShopCategory.php b/app/Models/ShopCategory.php new file mode 100644 index 0000000..9d7fbc3 --- /dev/null +++ b/app/Models/ShopCategory.php @@ -0,0 +1,33 @@ + 'integer', 'flags' => 'integer', 'enabled' => 'bool']; + } + + public function products(): BelongsToMany + { + return $this->belongsToMany(ShopProduct::class, 'store_category_service_link', 'category', 'service'); + } + + public function scopeOrdered(Builder $query): Builder + { + return $query->orderBy('id'); + } +} diff --git a/app/Models/ShopProduct.php b/app/Models/ShopProduct.php new file mode 100644 index 0000000..83ab24e --- /dev/null +++ b/app/Models/ShopProduct.php @@ -0,0 +1,33 @@ + 'integer', 'discountAmount' => 'integer', 'new' => 'bool', 'enabled' => 'bool']; + } + + public function categories(): BelongsToMany + { + return $this->belongsToMany(ShopCategory::class, 'store_category_service_link', 'service', 'category'); + } + + public function scopeOrdered(Builder $query): Builder + { + return $query->orderBy('id'); + } +} diff --git a/app/Services/BalanceService.php b/app/Services/BalanceService.php new file mode 100644 index 0000000..1eb6fdf --- /dev/null +++ b/app/Services/BalanceService.php @@ -0,0 +1,164 @@ +table('account_balances') + ->where('account_id', $accountId) + ->value('balance'); + + return $this->fromCents($this->toCents((string) ($value ?? '0'))); + } + + /** @return Collection */ + public function recentTransactions(int $accountId, int $limit = 10): Collection + { + return BalanceTransaction::query() + ->where('account_id', $accountId) + ->latest('id') + ->limit($limit) + ->get(); + } + + public function spend(int $accountId, string $amount, string $reference, ?string $description = null): BalanceTransaction + { + $cents = $this->toCents($amount); + if ($cents <= 0) { + throw new InvalidArgumentException('Сумма списания должна быть больше нуля.'); + } + + return DB::connection('azerothcore_auth')->transaction(function () use ($accountId, $cents, $reference, $description): BalanceTransaction { + $existing = BalanceTransaction::query()->where('reference', $reference)->first(); + if ($existing) { + if ($existing->account_id !== $accountId || $this->toCents((string) $existing->amount) !== -$cents) { + throw new InvalidArgumentException('Ключ операции уже использован с другими параметрами.'); + } + + return $existing; + } + + $balance = DB::connection('azerothcore_auth')->table('account_balances') + ->where('account_id', $accountId) + ->lockForUpdate() + ->first(); + $current = $this->toCents((string) ($balance->balance ?? '0')); + + if ($current < $cents) { + throw new InvalidArgumentException('Недостаточно средств.'); + } + + $after = $this->fromCents($current - $cents); + DB::connection('azerothcore_auth')->table('account_balances') + ->where('account_id', $accountId) + ->update(['balance' => $after, 'updated_at' => now()]); + + return BalanceTransaction::query()->create([ + 'account_id' => $accountId, + 'type' => BalanceTransaction::TYPE_SPEND, + 'amount' => $this->fromCents(-$cents), + 'balance_after' => $after, + 'reference' => $reference, + 'metadata' => array_filter(['description' => $description]), + ]); + }, 3); + } + + public function adjustByAdmin( + int $accountId, + string $amount, + string $direction, + int $adminAccountId, + string $adminUsername, + string $reason, + ): BalanceTransaction { + $cents = $this->toCents($amount); + if ($cents <= 0 || ! in_array($direction, ['credit', 'debit'], true)) { + throw new InvalidArgumentException('Некорректная корректировка баланса.'); + } + + return DB::connection('azerothcore_auth')->transaction(function () use ($accountId, $cents, $direction, $adminAccountId, $adminUsername, $reason): BalanceTransaction { + DB::connection('azerothcore_auth')->table('account_balances')->insertOrIgnore([ + 'account_id' => $accountId, + 'balance' => '0.00', + 'created_at' => now(), + 'updated_at' => now(), + ]); + $balance = DB::connection('azerothcore_auth')->table('account_balances') + ->where('account_id', $accountId) + ->lockForUpdate() + ->firstOrFail(); + $current = $this->toCents((string) $balance->balance); + $delta = $direction === 'credit' ? $cents : -$cents; + + if ($current + $delta < 0) { + throw new InvalidArgumentException('Недостаточно средств для ручного списания.'); + } + + $after = $this->fromCents($current + $delta); + DB::connection('azerothcore_auth')->table('account_balances') + ->where('account_id', $accountId) + ->update(['balance' => $after, 'updated_at' => now()]); + + return BalanceTransaction::query()->create([ + 'account_id' => $accountId, + 'type' => $direction === 'credit' ? BalanceTransaction::TYPE_ADMIN_CREDIT : BalanceTransaction::TYPE_ADMIN_DEBIT, + 'amount' => $this->fromCents($delta), + 'balance_after' => $after, + 'reference' => 'admin:'.Str::uuid(), + 'metadata' => [ + 'admin_account_id' => $adminAccountId, + 'admin_username' => $adminUsername, + 'reason' => $reason, + ], + ]); + }, 3); + } + + /** @return Collection */ + public function adminTransactions(?string $search = null, int $limit = 100): Collection + { + return BalanceTransaction::query() + ->leftJoin('account', 'account.id', '=', 'balance_transactions.account_id') + ->select('balance_transactions.*', 'account.username') + ->when($search, function ($query, string $search): void { + $query->where(function ($query) use ($search): void { + $query->where('account.username', 'like', '%'.$search.'%'); + if (ctype_digit($search)) { + $query->orWhere('balance_transactions.account_id', (int) $search); + } + }); + }) + ->latest('balance_transactions.id') + ->limit($limit) + ->get(); + } + + private function toCents(string $amount): int + { + $normalized = str_replace(',', '.', trim($amount)); + if (! preg_match('/^-?\d+(?:\.\d{1,6})?$/', $normalized)) { + throw new InvalidArgumentException('Некорректная сумма.'); + } + + $negative = str_starts_with($normalized, '-'); + $normalized = ltrim($normalized, '-'); + [$whole, $fraction] = array_pad(explode('.', $normalized, 2), 2, ''); + $cents = ((int) $whole * 100) + (int) str_pad(substr($fraction, 0, 2), 2, '0'); + + return $negative ? -$cents : $cents; + } + + private function fromCents(int $cents): string + { + return number_format($cents / 100, 2, '.', ''); + } +} diff --git a/app/Services/RobokassaService.php b/app/Services/RobokassaService.php new file mode 100644 index 0000000..0adacdc --- /dev/null +++ b/app/Services/RobokassaService.php @@ -0,0 +1,218 @@ +currentMode(); + $this->assertConfigured($mode); + + return PaymentInvoice::query()->create([ + 'account_id' => $accountId, + 'amount' => $this->normalizeAmount($amount), + 'mode' => $mode, + 'status' => PaymentInvoice::STATUS_PENDING, + ]); + } + + /** @return array */ + public function paymentParameters(PaymentInvoice $invoice, string $email = ''): array + { + $mode = (string) $invoice->mode; + $this->assertConfigured($mode); + + $custom = ['Shp_account' => (string) $invoice->account_id]; + $amount = $this->normalizeAmount((string) $invoice->amount); + + return array_filter([ + 'MerchantLogin' => $this->credential($mode, 'login'), + 'OutSum' => $amount, + 'InvId' => (string) $invoice->id, + 'Description' => 'Пополнение Слёз Элуны', + 'SignatureValue' => $this->hash($this->signatureBase( + [$this->credential($mode, 'login'), $amount, (string) $invoice->id, $this->credential($mode, 'password1')], + $custom, + ), $mode), + 'Email' => $email, + 'Culture' => 'ru', + 'Encoding' => 'utf-8', + 'IsTest' => $mode === PaymentInvoice::MODE_TEST ? '1' : '0', + ...$custom, + ], static fn (string $value): bool => $value !== ''); + } + + /** @param array $payload */ + public function processResult(array $payload): PaymentInvoice + { + $invoiceId = $this->value($payload, 'InvId'); + $amount = $this->value($payload, 'OutSum'); + $signature = $this->value($payload, 'SignatureValue'); + $accountId = $this->value($payload, 'Shp_account'); + + if (! ctype_digit($invoiceId) || ! ctype_digit($accountId) || $signature === '') { + throw new InvalidArgumentException('Некорректные параметры уведомления Robokassa.'); + } + + $invoiceForSignature = PaymentInvoice::query()->findOrFail((int) $invoiceId); + $mode = (string) $invoiceForSignature->mode; + $this->assertConfigured($mode); + + $expected = $this->hash($this->signatureBase( + [$amount, $invoiceId, $this->credential($mode, 'password2')], + ['Shp_account' => $accountId], + ), $mode); + + if (! hash_equals(strtolower($expected), strtolower($signature))) { + throw new InvalidArgumentException('Некорректная подпись Robokassa.'); + } + + return DB::connection('azerothcore_auth')->transaction(function () use ($invoiceId, $accountId, $amount): PaymentInvoice { + $invoice = PaymentInvoice::query()->lockForUpdate()->findOrFail((int) $invoiceId); + + if ($invoice->account_id !== (int) $accountId || $this->normalizeAmount((string) $invoice->amount) !== $this->normalizeAmount($amount)) { + throw new InvalidArgumentException('Сумма или получатель платежа не совпадают со счётом.'); + } + + if ($invoice->status === PaymentInvoice::STATUS_PAID) { + return $invoice; + } + + DB::connection('azerothcore_auth')->table('account_balances')->insertOrIgnore([ + 'account_id' => $invoice->account_id, + 'balance' => '0.00', + 'created_at' => now(), + 'updated_at' => now(), + ]); + $balance = DB::connection('azerothcore_auth')->table('account_balances') + ->where('account_id', $invoice->account_id) + ->lockForUpdate() + ->firstOrFail(); + $current = (string) $balance->balance; + + $tearsAmount = $this->multiplyAmount( + (string) $invoice->amount, + (int) config('services.robokassa.tears_per_ruble'), + ); + $after = $this->addAmounts($current, $tearsAmount); + DB::connection('azerothcore_auth')->table('account_balances') + ->where('account_id', $invoice->account_id) + ->update(['balance' => $after, 'updated_at' => now()]); + + BalanceTransaction::query()->create([ + 'account_id' => $invoice->account_id, + 'type' => BalanceTransaction::TYPE_DEPOSIT, + 'amount' => $tearsAmount, + 'balance_after' => $after, + 'reference' => 'robokassa:'.$invoice->id, + 'metadata' => [ + 'invoice_id' => $invoice->id, + 'paid_rubles' => $invoice->amount, + 'tears_per_ruble' => (int) config('services.robokassa.tears_per_ruble'), + ], + ]); + + $invoice->update(['status' => PaymentInvoice::STATUS_PAID, 'paid_at' => now()]); + + return $invoice->refresh(); + }, 3); + } + + private function normalizeAmount(string $amount): string + { + if (! is_numeric($amount) || (float) $amount < 0) { + throw new InvalidArgumentException('Некорректная сумма.'); + } + + return number_format((float) $amount, 2, '.', ''); + } + + private function addAmounts(string $left, string $right): string + { + $toCents = static function (string $value): int { + [$whole, $fraction] = explode('.', number_format((float) $value, 2, '.', '')); + + return ((int) $whole * 100) + (int) $fraction; + }; + + return number_format(($toCents($left) + $toCents($right)) / 100, 2, '.', ''); + } + + private function multiplyAmount(string $amount, int $multiplier): string + { + if ($multiplier <= 0) { + throw new RuntimeException('Некорректный курс Слёз Элуны.'); + } + + return number_format((float) $amount * $multiplier, 2, '.', ''); + } + + /** @param list $parts @param array $custom */ + private function signatureBase(array $parts, array $custom): string + { + ksort($custom, SORT_STRING); + + return implode(':', [...$parts, ...array_map( + static fn (string $name, string $value): string => $name.'='.$value, + array_keys($custom), + array_values($custom), + )]); + } + + private function hash(string $value, ?string $mode = null): string + { + $algorithm = strtolower($this->credential($mode ?? $this->currentMode(), 'hash_algorithm')); + if (! in_array($algorithm, hash_algos(), true)) { + throw new RuntimeException('Неизвестный алгоритм подписи Robokassa.'); + } + + return hash($algorithm, $value); + } + + /** @param array $payload */ + private function value(array $payload, string $key): string + { + foreach ($payload as $payloadKey => $value) { + if (strcasecmp((string) $payloadKey, $key) === 0 && is_scalar($value)) { + return (string) $value; + } + } + + return ''; + } + + private function currentMode(): string + { + $mode = (string) config('services.robokassa.mode'); + if (! in_array($mode, [PaymentInvoice::MODE_TEST, PaymentInvoice::MODE_PRODUCTION], true)) { + throw new RuntimeException('ROBOKASSA_MODE должен быть test или production.'); + } + + return $mode; + } + + private function credential(string $mode, string $key): string + { + return (string) config("services.robokassa.environments.{$mode}.{$key}"); + } + + private function assertConfigured(string $mode): void + { + if (! in_array($mode, [PaymentInvoice::MODE_TEST, PaymentInvoice::MODE_PRODUCTION], true)) { + throw new RuntimeException('Некорректный режим Robokassa.'); + } + + foreach (['login', 'password1', 'password2'] as $key) { + if ($this->credential($mode, $key) === '') { + throw new RuntimeException("Robokassa не настроена для режима {$mode}."); + } + } + } +} diff --git a/app/Services/ShopCatalogService.php b/app/Services/ShopCatalogService.php new file mode 100644 index 0000000..fa393a0 --- /dev/null +++ b/app/Services/ShopCatalogService.php @@ -0,0 +1,106 @@ + */ + public function categories(): Collection + { + return ShopCategory::query()->ordered()->withCount('products')->get(); + } + + /** @return Collection */ + public function products(): Collection + { + return ShopProduct::query()->with('categories')->ordered()->get(); + } + + public function createCategory(array $data): ShopCategory + { + return ShopCategory::query()->create($this->categoryData($data)); + } + + public function updateCategory(ShopCategory $category, array $data): ShopCategory + { + $category->update($this->categoryData($data)); + + return $category->refresh(); + } + + public function deleteCategory(ShopCategory $category): void + { + DB::connection('store')->transaction(function () use ($category): void { + $category->products()->detach(); + $category->delete(); + }); + } + + public function createProduct(array $data): ShopProduct + { + return DB::connection('store')->transaction(function () use ($data): ShopProduct { + $product = ShopProduct::query()->create($this->productData($data)); + $product->categories()->sync($data['category_ids']); + + return $product->load('categories'); + }); + } + + public function updateProduct(ShopProduct $product, array $data): ShopProduct + { + return DB::connection('store')->transaction(function () use ($product, $data): ShopProduct { + $product->update($this->productData($data)); + $product->categories()->sync($data['category_ids']); + + return $product->refresh()->load('categories'); + }); + } + + public function deleteProduct(ShopProduct $product): void + { + DB::connection('store')->transaction(function () use ($product): void { + $product->categories()->detach(); + $product->delete(); + }); + } + + private function categoryData(array $data): array + { + return [ + 'name' => trim($data['name']), + 'icon' => trim((string) ($data['icon'] ?? '')), + 'requiredRank' => (int) $data['requiredRank'], + 'flags' => (int) $data['flags'], + 'enabled' => (int) ($data['enabled'] ?? false), + ]; + } + + private function productData(array $data): array + { + return [ + 'type' => (int) $data['type'], + 'name' => trim($data['name']), + 'tooltipName' => trim((string) ($data['tooltipName'] ?? '')), + 'tooltipType' => trim((string) ($data['tooltipType'] ?? '')), + 'tooltipText' => trim((string) ($data['tooltipText'] ?? '')), + 'icon' => trim((string) ($data['icon'] ?? '')), + 'price' => (int) $data['price'], + 'currency' => 1, + 'hyperlinkId' => (int) ($data['hyperlinkId'] ?? 0), + 'creatureEntry' => (int) ($data['creatureEntry'] ?? 0), + 'discountAmount' => (int) ($data['discountAmount'] ?? 0), + 'flags' => (int) ($data['flags'] ?? 0), + ...collect(range(1, 8))->flatMap(fn (int $slot): array => [ + 'reward_'.$slot => (int) ($data['reward_'.$slot] ?? 0), + 'rewardcount_'.$slot => (int) ($data['rewardcount_'.$slot] ?? 0), + ])->all(), + 'new' => (int) ($data['new'] ?? false), + 'enabled' => (int) ($data['enabled'] ?? false), + ]; + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index ba49231..ffc819b 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -20,6 +20,11 @@ return Application::configure(basePath: dirname(__DIR__)) ->withMiddleware(function (Middleware $middleware): void { $middleware->trustProxies(at: env('TRUSTED_PROXIES', '*')); + $middleware->validateCsrfTokens(except: [ + 'payments/robokassa/success', + 'payments/robokassa/fail', + ]); + $middleware->api(prepend: [ \Illuminate\Http\Middleware\SetCacheHeaders::class . ':no_store', \App\Http\Middleware\ForceJsonResponse::class, @@ -28,6 +33,7 @@ return Application::configure(basePath: dirname(__DIR__)) $middleware->alias([ 'gamemaster' => \App\Http\Middleware\EnsureGameMaster::class, 'launcher' => \App\Http\Middleware\EnsureLauncherRequest::class, + 'game-server' => \App\Http\Middleware\EnsureGameServer::class, ]); }) ->withExceptions(function (Exceptions $exceptions): void { diff --git a/config/database.php b/config/database.php index 5629b84..f530446 100644 --- a/config/database.php +++ b/config/database.php @@ -104,6 +104,22 @@ return [ ]) : [], ], + 'store' => [ + 'driver' => 'mysql', + 'host' => env('STORE_DB_HOST', env('AZEROTHCORE_AUTH_DB_HOST', '127.0.0.1')), + 'port' => env('STORE_DB_PORT', env('AZEROTHCORE_AUTH_DB_PORT', '3306')), + 'database' => env('STORE_DB_DATABASE', 'store'), + 'username' => env('STORE_DB_USERNAME', env('AZEROTHCORE_AUTH_DB_USERNAME', 'root')), + 'password' => env('STORE_DB_PASSWORD', env('AZEROTHCORE_AUTH_DB_PASSWORD', 'password')), + 'unix_socket' => env('STORE_DB_SOCKET', ''), + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_general_ci', + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => true, + 'engine' => null, + ], + 'mariadb' => [ 'driver' => 'mariadb', 'url' => env('DB_URL'), diff --git a/config/moonwell.php b/config/moonwell.php index e573080..5d23330 100644 --- a/config/moonwell.php +++ b/config/moonwell.php @@ -5,6 +5,7 @@ return [ 'characters_connection' => env('AZEROTHCORE_CHARACTERS_CONNECTION', 'azerothcore_characters'), 'registration' => [ + 'require_invite_code' => (bool) env('MOONWELL_REGISTRATION_REQUIRE_INVITE', true), 'gmlevel' => (int) env('AZEROTHCORE_ACCOUNT_GMLEVEL', 0), 'realm_id' => (int) env('AZEROTHCORE_ACCOUNT_REALM_ID', -1), 'expansion' => (int) env('AZEROTHCORE_ACCOUNT_EXPANSION', 2), diff --git a/config/services.php b/config/services.php index 6a90eb8..7142931 100644 --- a/config/services.php +++ b/config/services.php @@ -1,5 +1,7 @@ [ + 'mode' => $robokassaMode, + 'environments' => [ + 'test' => [ + 'login' => env('ROBOKASSA_TEST_LOGIN', env('ROBOKASSA_LOGIN')), + 'password1' => env('ROBOKASSA_TEST_PASSWORD1', env('ROBOKASSA_PASSWORD1')), + 'password2' => env('ROBOKASSA_TEST_PASSWORD2', env('ROBOKASSA_PASSWORD2')), + 'hash_algorithm' => env('ROBOKASSA_TEST_HASH_ALGORITHM', env('ROBOKASSA_HASH_ALGORITHM', 'md5')), + ], + 'production' => [ + 'login' => env('ROBOKASSA_PRODUCTION_LOGIN'), + 'password1' => env('ROBOKASSA_PRODUCTION_PASSWORD1'), + 'password2' => env('ROBOKASSA_PRODUCTION_PASSWORD2'), + 'hash_algorithm' => env('ROBOKASSA_PRODUCTION_HASH_ALGORITHM', 'md5'), + ], + ], + 'payment_url' => env('ROBOKASSA_PAYMENT_URL', 'https://auth.robokassa.ru/Merchant/Index.aspx'), + 'minimum_amount' => env('ROBOKASSA_MINIMUM_AMOUNT', 10), + 'maximum_amount' => env('ROBOKASSA_MAXIMUM_AMOUNT', 100000), + 'tears_per_ruble' => env('MOONWELL_TEARS_PER_RUBLE', 10), + ], + + 'game_server' => [ + 'balance_api_token' => env('GAME_SERVER_BALANCE_API_TOKEN'), + ], + ]; diff --git a/database/migrations/2026_07_19_000001_create_moonwell_balance_tables.php b/database/migrations/2026_07_19_000001_create_moonwell_balance_tables.php new file mode 100644 index 0000000..acd80f8 --- /dev/null +++ b/database/migrations/2026_07_19_000001_create_moonwell_balance_tables.php @@ -0,0 +1,50 @@ +environment('testing') ? config('database.default') : 'azerothcore_auth'; + } + + public function up(): void + { + Schema::connection($this->getConnection())->create('account_balances', function (Blueprint $table): void { + $table->unsignedInteger('account_id')->primary(); + $table->decimal('balance', 14, 2)->default(0); + $table->timestamps(); + }); + + Schema::connection($this->getConnection())->create('payment_invoices', function (Blueprint $table): void { + $table->id(); + $table->unsignedInteger('account_id')->index(); + $table->decimal('amount', 14, 2); + $table->string('mode', 20)->default('test'); + $table->string('status', 20)->default('pending')->index(); + $table->timestamp('paid_at')->nullable(); + $table->timestamps(); + }); + + Schema::connection($this->getConnection())->create('balance_transactions', function (Blueprint $table): void { + $table->id(); + $table->unsignedInteger('account_id')->index(); + $table->string('type', 20); + $table->decimal('amount', 14, 2); + $table->decimal('balance_after', 14, 2); + $table->string('reference', 191)->unique(); + $table->json('metadata')->nullable(); + $table->timestamp('created_at')->useCurrent(); + }); + } + + public function down(): void + { + Schema::connection($this->getConnection())->dropIfExists('balance_transactions'); + Schema::connection($this->getConnection())->dropIfExists('payment_invoices'); + Schema::connection($this->getConnection())->dropIfExists('account_balances'); + } +}; diff --git a/docs/game-balance-integration.md b/docs/game-balance-integration.md new file mode 100644 index 0000000..4742cc7 --- /dev/null +++ b/docs/game-balance-integration.md @@ -0,0 +1,48 @@ +# Интеграция Слёз Элуны + +Слёзы Элуны хранятся в `acore_auth.account_balances`, поэтому сайт и игровой сервер используют один источник истины. Все движения валюты записываются в `acore_auth.balance_transactions`. + +Курс пополнения задаётся через `MOONWELL_TEARS_PER_RUBLE` и по умолчанию равен `10`: **1 рубль = 10 Слёз Элуны**. Суммы API игрового сервера и ручные операции администратора всегда указываются в Слёзах Элуны, а не в рублях. + +## Robokassa + +В технических настройках магазина укажите: + +- ResultURL: `https://ВАШ-ДОМЕН/api/payments/robokassa/result`, метод POST; +- SuccessURL: `https://ВАШ-ДОМЕН/payments/robokassa/success`; +- FailURL: `https://ВАШ-ДОМЕН/payments/robokassa/fail`; +- алгоритм подписи, совпадающий с `ROBOKASSA_HASH_ALGORITHM`. + +Доступны два независимых режима: + +- `ROBOKASSA_MODE=test` использует `ROBOKASSA_TEST_LOGIN`, `ROBOKASSA_TEST_PASSWORD1`, `ROBOKASSA_TEST_PASSWORD2` и передаёт `IsTest=1`; +- `ROBOKASSA_MODE=production` использует отдельные `ROBOKASSA_PRODUCTION_*` значения и передаёт `IsTest=0`. + +Текущие старые переменные `ROBOKASSA_LOGIN`, `ROBOKASSA_PASSWORD1`, `ROBOKASSA_PASSWORD2` поддерживаются как тестовые для обратной совместимости. Перед боевым запуском заполните production-переменные, переключите `ROBOKASSA_MODE=production` и выполните `php artisan config:clear`. + +Режим сохраняется в каждом выставленном счёте. Поэтому отложенный тестовый callback продолжит проверяться тестовым Паролем №2 даже после переключения приложения в production. + +## API игрового сервера + +Задайте длинный случайный `GAME_SERVER_BALANCE_API_TOKEN` и передавайте его как `Authorization: Bearer TOKEN`. + +Получение количества Слёз Элуны: + +```http +GET /api/game/balance/7 +Authorization: Bearer TOKEN +``` + +Списание: + +```http +POST /api/game/balance/7/spend +Authorization: Bearer TOKEN +Content-Type: application/json + +{"amount":"25.50","idempotency_key":"shop-purchase-42","description":"Название предмета"} +``` + +`idempotency_key` должен быть уникальным для каждой игровой покупки. `amount` — количество списываемых Слёз Элуны. Повтор запроса с тем же ключом возвращает ту же операцию и не списывает валюту второй раз. Ответ `422` означает недостаточное количество или конфликт ключа; `401` — неверный серверный токен. + +После развёртывания выполните `php artisan migrate --force`. Миграция создаёт таблицы непосредственно в подключении `azerothcore_auth`. diff --git a/public/site.css b/public/site.css index 4bc666c..188b26b 100644 --- a/public/site.css +++ b/public/site.css @@ -570,6 +570,60 @@ pre { font-size: 0.92rem; } +.balance-operation { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 14px 0; + border-bottom: 1px solid var(--line); +} + +.balance-operation div { + display: grid; + gap: 4px; +} + +.balance-operation span { + color: var(--muted); + font-size: 0.85rem; +} + +.balance-operation--positive { + color: var(--gold); +} + +.balance-ledger { + margin-top: 22px; +} + +.balance-ledger__row { + display: grid; + grid-template-columns: minmax(180px, 1fr) minmax(240px, 2fr) minmax(160px, auto); + align-items: center; + gap: 20px; +} + +.balance-ledger__row > div { + display: grid; + gap: 5px; +} + +.balance-ledger__row span { + color: var(--muted); + font-size: 0.88rem; +} + +.balance-ledger__amount { + text-align: right; +} + +.shop-reward-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 14px; +} + .code-block, .feed-preview { margin: 0; @@ -758,6 +812,18 @@ pre { align-items: stretch; } + .balance-ledger__row { + grid-template-columns: 1fr; + } + + .shop-reward-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .balance-ledger__amount { + text-align: left; + } + .hero { padding-top: 18px; } diff --git a/resources/js/Pages/Landing/Components/Join.vue b/resources/js/Pages/Landing/Components/Join.vue index 6ed70db..1c10fbc 100644 --- a/resources/js/Pages/Landing/Components/Join.vue +++ b/resources/js/Pages/Landing/Components/Join.vue @@ -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) {

Создай игровой аккаунт

-

Укажи инвайт-код, логин, почту и пароль - аккаунт будет готов к входу в игру сразу после регистрации.

-
+

{{ inviteRequired ? 'Укажи инвайт-код, логин, почту и пароль' : 'Укажи логин, почту и пароль' }} — аккаунт будет готов к входу в игру сразу после регистрации.

+
Закрытый бета-доступ Пока идет закрытый этап: регистрация только по инвайт-кодам. Код можно получить у друзей-игроков или в нашем Discord.
@@ -132,7 +133,7 @@ function submit(event) {
{{ errorFor('email') }}
-
+
diff --git a/resources/js/Pages/Landing/Main.vue b/resources/js/Pages/Landing/Main.vue index b242df2..0443e4c 100644 --- a/resources/js/Pages/Landing/Main.vue +++ b/resources/js/Pages/Landing/Main.vue @@ -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()); - +