diff --git a/app/Http/Controllers/Admin/ShopController.php b/app/Http/Controllers/Admin/ShopController.php index 508e97b..31e784a 100644 --- a/app/Http/Controllers/Admin/ShopController.php +++ b/app/Http/Controllers/Admin/ShopController.php @@ -9,6 +9,7 @@ use App\Http\Requests\Admin\SaveShopProductRequest; use App\Models\ShopCategory; use App\Models\ShopProduct; use App\Services\ShopCatalogService; +use App\Services\StoreAvailabilityService; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Inertia\Inertia; @@ -18,7 +19,11 @@ class ShopController extends Controller { use BuildsPortalProps; - public function index(Request $request, ShopCatalogService $catalog): Response + public function index( + Request $request, + ShopCatalogService $catalog, + StoreAvailabilityService $availability, + ): Response { $categories = $catalog->categories(); @@ -68,6 +73,8 @@ class ShopController extends Controller ->all(), 'categoryStoreUrl' => route('admin.shop.categories.store'), 'productStoreUrl' => route('admin.shop.products.store'), + 'shopEnabled' => $availability->isEnabled(), + 'availabilityUpdateUrl' => route('admin.shop.availability.update'), 'productTypes' => collect([ 1 => 'Предмет', 3 => 'Маунт', @@ -80,6 +87,20 @@ class ShopController extends Controller ]); } + public function updateAvailability(Request $request, StoreAvailabilityService $availability): RedirectResponse + { + $validated = $request->validate([ + 'enabled' => ['required', 'boolean'], + ]); + + $enabled = (bool) $validated['enabled']; + $availability->setEnabled($enabled); + + return back()->with('status', $enabled + ? 'Магазин и пополнение включены.' + : 'Магазин и пополнение выключены.'); + } + public function storeCategory(SaveShopCategoryRequest $request, ShopCatalogService $catalog): RedirectResponse { $catalog->createCategory($request->validated()); diff --git a/app/Http/Controllers/CabinetController.php b/app/Http/Controllers/CabinetController.php index 8e8e52b..d3773a3 100644 --- a/app/Http/Controllers/CabinetController.php +++ b/app/Http/Controllers/CabinetController.php @@ -6,6 +6,7 @@ use App\Http\Requests\ChangePasswordRequest; use App\Services\AzerothCoreAccountService; use App\Services\BalanceService; use App\Services\GameClientDownloadService; +use App\Services\StoreAvailabilityService; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Inertia\Inertia; @@ -14,7 +15,12 @@ use Throwable; class CabinetController extends Controller { - public function index(Request $request, AzerothCoreAccountService $accounts, BalanceService $balances): Response + public function index( + Request $request, + AzerothCoreAccountService $accounts, + BalanceService $balances, + StoreAvailabilityService $storeAvailability, + ): Response { $user = $request->user(); $balance = $balances->balance($user->id); @@ -48,6 +54,7 @@ class CabinetController extends Controller 'filename' => config('moonwell.client.object_key'), ], 'wallet' => [ + 'deposit_enabled' => $storeAvailability->isEnabled(), 'amount' => $balance, 'formatted_amount' => number_format((float) $balance, 2, ',', ' '), 'minimum_deposit' => (float) config('services.robokassa.minimum_amount'), diff --git a/app/Http/Controllers/LandingController.php b/app/Http/Controllers/LandingController.php index 46b5ebb..28e254a 100644 --- a/app/Http/Controllers/LandingController.php +++ b/app/Http/Controllers/LandingController.php @@ -75,11 +75,6 @@ class LandingController extends Controller 'playUrl' => $playUrl, 'posts' => $posts, 'newsIndexUrl' => route('news.index'), - 'tweaks' => [ - 'variant' => 'forest', - 'particles' => true, - 'moon' => true, - ], 'registerEndpoint' => route('game-account.store'), 'legalUrls' => [ 'offer_published' => (bool) config('moonwell.legal.offer_published'), diff --git a/app/Http/Controllers/MainController.php b/app/Http/Controllers/MainController.php index 74ea1bd..b756d4d 100644 --- a/app/Http/Controllers/MainController.php +++ b/app/Http/Controllers/MainController.php @@ -62,11 +62,6 @@ class MainController extends Controller ], 'playUrl' => $user !== null ? route('cabinet.client') : '#join', 'posts' => $posts, - 'tweaks' => [ - 'variant' => 'forest', - 'particles' => true, - 'moon' => true, - ], 'registerEndpoint' => route('game-account.store'), 'csrfToken' => csrf_token(), 'auth' => [ diff --git a/app/Http/Controllers/PaymentController.php b/app/Http/Controllers/PaymentController.php index fe655e9..ea150d9 100644 --- a/app/Http/Controllers/PaymentController.php +++ b/app/Http/Controllers/PaymentController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers; use App\Services\RobokassaService; +use App\Services\StoreAvailabilityService; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Validation\Rule; @@ -10,8 +11,18 @@ use Illuminate\View\View; class PaymentController extends Controller { - public function create(Request $request, RobokassaService $robokassa): View + public function create( + Request $request, + RobokassaService $robokassa, + StoreAvailabilityService $storeAvailability, + ): View|RedirectResponse { + if (! $storeAvailability->isEnabled()) { + return redirect() + ->route('cabinet.index') + ->with('error', 'Пополнение временно недоступно.'); + } + $validated = $request->validate([ 'amount' => [ 'required', diff --git a/app/Services/StoreAvailabilityService.php b/app/Services/StoreAvailabilityService.php new file mode 100644 index 0000000..2a3db49 --- /dev/null +++ b/app/Services/StoreAvailabilityService.php @@ -0,0 +1,26 @@ +table('store_config') + ->where('id', self::CONFIG_ID) + ->value('enabled'); + } + + public function setEnabled(bool $enabled): void + { + DB::connection('store') + ->table('store_config') + ->where('id', self::CONFIG_ID) + ->update(['enabled' => (int) $enabled]); + } +} diff --git a/docs/mail-server.md b/docs/mail-server.md index f6bd816..ac717e8 100644 --- a/docs/mail-server.md +++ b/docs/mail-server.md @@ -149,3 +149,12 @@ Deploy-hook Certbot копирует обновлённые файлы в это только контейнер Stalwart, чтобы он перечитал сертификат. Hook устанавливается из `scripts/deploy-mail-certificate.sh`. + +Внешний HTTPS-доступ к панели и JMAP проксируется через Nginx. Готовая +конфигурация находится в `ops/nginx-mail.conf`; внутренний HTTP-порт Stalwart +остаётся доступен только на `127.0.0.1`. + +После установки сертификата его нужно добавить в Stalwart и выбрать как +сертификат по умолчанию. Для HTTP-настроек за Nginx также включается +`useXForwarded`, чтобы автоматические блокировки применялись к реальному +адресу клиента, а не к Docker gateway. diff --git a/resources/css/moonwell.css b/resources/css/moonwell.css index 61d0b52..4a45af3 100644 --- a/resources/css/moonwell.css +++ b/resources/css/moonwell.css @@ -1,6 +1,6 @@ /* ========================================================= MoonWell — Night Elf themed landing - Two moods controllable via Tweaks: "forest" (cozy/warm) & "temple" (arcane/modern) + Night forest visual system shared by the landing and portal ========================================================= */ :root { @@ -1619,85 +1619,6 @@ section { .footer-brand { grid-column: span 2; } } -/* ------------------------- tweaks panel ------------------------- */ -.tweaks { - position: fixed; - bottom: 20px; - right: 20px; - z-index: 100; - background: rgba(7,10,24,0.95); - border: 1px solid var(--panel-border-strong); - border-radius: var(--radius-lg); - padding: 16px 18px; - min-width: 240px; - backdrop-filter: blur(16px); - box-shadow: 0 20px 60px rgba(0,0,0,0.6); - font-family: var(--f-sans); - display: none; -} -.tweaks.open { display: block; } -.tweaks-title { - font-family: var(--f-mono); - font-size: 10px; - letter-spacing: 0.22em; - text-transform: uppercase; - color: var(--moon); - margin-bottom: 14px; - display: flex; - justify-content: space-between; - align-items: center; -} -.tweaks-title button { color: var(--text-mute); font-size: 14px; } -.tweak-row { - display: flex; - justify-content: space-between; - align-items: center; - padding: 8px 0; - border-top: 1px solid var(--panel-border); - font-size: 13px; - color: var(--text-soft); -} -.tweak-row:first-of-type { border-top: 0; } -.tweak-row label { letter-spacing: 0.04em; } -.tweak-seg { - display: inline-flex; - border: 1px solid var(--panel-border-strong); - border-radius: 2px; - overflow: hidden; -} -.tweak-seg button { - padding: 5px 10px; - font-size: 11px; - letter-spacing: 0.1em; - text-transform: uppercase; - font-family: var(--f-mono); - color: var(--text-mute); - transition: all var(--t-fast); -} -.tweak-seg button.active { - background: var(--moon); - color: var(--bg-0); -} -.tweak-switch { - width: 34px; height: 18px; - border-radius: 999px; - background: var(--panel-border-strong); - position: relative; - cursor: pointer; - transition: background var(--t-fast); -} -.tweak-switch::after { - content: ""; - position: absolute; - top: 2px; left: 2px; - width: 14px; height: 14px; - background: var(--text-soft); - border-radius: 50%; - transition: transform var(--t-fast); -} -.tweak-switch.on { background: var(--moon); } -.tweak-switch.on::after { transform: translateX(16px); background: var(--bg-0); } - /* ------------------------- particles canvas ------------------------- */ #particles { position: fixed; diff --git a/resources/js/Pages/Admin/Shop.vue b/resources/js/Pages/Admin/Shop.vue index 86d3ddb..49ba2e2 100644 --- a/resources/js/Pages/Admin/Shop.vue +++ b/resources/js/Pages/Admin/Shop.vue @@ -1,5 +1,5 @@ diff --git a/routes/web.php b/routes/web.php index 8502177..d5f30f8 100644 --- a/routes/web.php +++ b/routes/web.php @@ -71,6 +71,7 @@ Route::middleware(['auth', 'gamemaster']) Route::delete('/news/{newsItem}', [AdminNewsController::class, 'destroy'])->name('news.destroy'); Route::get('/shop', [ShopController::class, 'index'])->name('shop.index'); + Route::patch('/shop/availability', [ShopController::class, 'updateAvailability'])->name('shop.availability.update'); Route::post('/shop/categories', [ShopController::class, 'storeCategory'])->name('shop.categories.store'); Route::patch('/shop/categories/{category}', [ShopController::class, 'updateCategory'])->name('shop.categories.update'); Route::delete('/shop/categories/{category}', [ShopController::class, 'destroyCategory'])->name('shop.categories.destroy'); diff --git a/tests/Feature/AdminShopFeatureTest.php b/tests/Feature/AdminShopFeatureTest.php index 45f3990..4aa8535 100644 --- a/tests/Feature/AdminShopFeatureTest.php +++ b/tests/Feature/AdminShopFeatureTest.php @@ -17,6 +17,12 @@ class AdminShopFeatureTest extends TestCase config(['database.connections.store' => ['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '']]); DB::purge('store'); + Schema::connection('store')->create('store_config', function (Blueprint $table): void { + $table->unsignedInteger('id')->primary(); + $table->unsignedInteger('enabled')->default(1); + }); + DB::connection('store')->table('store_config')->insert(['id' => 1, 'enabled' => 1]); + Schema::connection('store')->create('store_categories', function (Blueprint $table): void { $table->increments('id'); $table->string('name')->nullable(); @@ -75,10 +81,28 @@ class AdminShopFeatureTest extends TestCase ->where('categories.0.name', 'Маунты') ->where('products.0.name', 'Спектральный тигр') ->where('products.0.category_ids.0', $categoryId) + ->where('shopEnabled', true) ->etc() ); } + public function test_admin_can_disable_and_enable_store(): void + { + $admin = $this->admin(); + + $this->actingAs($admin) + ->patch(route('admin.shop.availability.update'), ['enabled' => false]) + ->assertSessionHas('status', 'Магазин и пополнение выключены.'); + + $this->assertSame(0, (int) DB::connection('store')->table('store_config')->where('id', 1)->value('enabled')); + + $this->actingAs($admin) + ->patch(route('admin.shop.availability.update'), ['enabled' => true]) + ->assertSessionHas('status', 'Магазин и пополнение включены.'); + + $this->assertSame(1, (int) DB::connection('store')->table('store_config')->where('id', 1)->value('enabled')); + } + public function test_regular_player_cannot_manage_store(): void { $this->actingAs($this->admin(0))->get(route('admin.shop.index'))->assertForbidden(); diff --git a/tests/Feature/CabinetFeatureTest.php b/tests/Feature/CabinetFeatureTest.php index 3840f5a..606c7f5 100644 --- a/tests/Feature/CabinetFeatureTest.php +++ b/tests/Feature/CabinetFeatureTest.php @@ -6,6 +6,7 @@ use App\Models\GameAccountUser; use App\Services\AzerothCoreAccountService; use App\Services\BalanceService; use App\Services\GameClientDownloadService; +use App\Services\StoreAvailabilityService; use Illuminate\Support\Collection; use Inertia\Testing\AssertableInertia as Assert; use Mockery\MockInterface; @@ -13,6 +14,15 @@ use Tests\TestCase; class CabinetFeatureTest extends TestCase { + protected function setUp(): void + { + parent::setUp(); + + $this->mock(StoreAvailabilityService::class, function (MockInterface $mock): void { + $mock->shouldReceive('isEnabled')->andReturn(true); + }); + } + public function test_guest_is_redirected_to_login_for_cabinet(): void { $response = $this->get(route('cabinet.index')); @@ -53,6 +63,7 @@ class CabinetFeatureTest extends TestCase ->component('Cabinet/Index', false) ->where('account.username', 'PLAYERONE') ->where('wallet.formatted_amount', '1 250,00') + ->where('wallet.deposit_enabled', true) ->where('characters.0.name', 'Arthion') ->has('characters', 1) ->where('urls.client', route('cabinet.client')) @@ -60,6 +71,21 @@ class CabinetFeatureTest extends TestCase ); } + public function test_payment_creation_is_blocked_when_store_is_disabled(): void + { + $this->mock(StoreAvailabilityService::class, function (MockInterface $mock): void { + $mock->shouldReceive('isEnabled')->once()->andReturn(false); + }); + $this->mock(\App\Services\RobokassaService::class, function (MockInterface $mock): void { + $mock->shouldNotReceive('createInvoice'); + }); + + $this->actingAs($this->makeUser()) + ->post(route('cabinet.balance.deposit'), ['amount' => 500]) + ->assertRedirect(route('cabinet.index')) + ->assertSessionHas('error', 'Пополнение временно недоступно.'); + } + public function test_authenticated_user_can_change_password(): void { $user = $this->makeUser();