From c947e638ebb15ab8caf52e8a65b1e72a333bcdfb Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Mon, 22 Dec 2025 13:11:16 +0100 Subject: [PATCH] verschieben des sofortigen verzichts auf das Widerrrufsrecht zum Anlegen des Events --- .../Api/Tenant/EventController.php | 64 +++++- .../Api/TenantBillingController.php | 48 +++- app/Http/Controllers/CheckoutController.php | 12 +- .../Controllers/PaddleCheckoutController.php | 12 +- .../CheckoutFreeActivationRequest.php | 1 - .../Requests/Paddle/PaddleCheckoutRequest.php | 1 - .../Requests/Tenant/EventStoreRequest.php | 1 + .../Packages/PackageLimitEvaluator.php | 9 + .../Paddle/PaddleCustomerPortalService.php | 27 +++ resources/js/admin/api.ts | 22 ++ .../js/admin/i18n/locales/de/management.json | 86 +++----- .../js/admin/i18n/locales/en/management.json | 80 +++---- resources/js/admin/mobile/BillingPage.tsx | 205 ++++++++++++------ resources/js/admin/mobile/EventFormPage.tsx | 138 ++++++++++-- .../mobile/__tests__/billingUsage.test.ts | 64 ++++++ resources/js/admin/mobile/billingUsage.ts | 83 +++++++ .../mobile/components/LegalConsentSheet.tsx | 83 +++++-- .../js/admin/mobile/components/Primitives.tsx | 15 +- .../__tests__/PaymentStep.render.test.tsx | 37 ++++ .../marketing/checkout/steps/PaymentStep.tsx | 63 +----- routes/api.php | 3 + .../Feature/Api/Tenant/BillingPortalTest.php | 39 ++++ .../Checkout/CheckoutFreeActivationTest.php | 10 +- tests/Feature/EventControllerTest.php | 131 ++++++----- .../Feature/PaddleCheckoutControllerTest.php | 1 - tests/Feature/Tenant/EventManagementTest.php | 1 + tests/ui/purchase/checkout-payment.test.ts | 5 - tests/ui/purchase/paddle-sandbox-full.test.ts | 5 - .../standard-package-checkout.test.ts | 5 - 29 files changed, 877 insertions(+), 374 deletions(-) create mode 100644 app/Services/Paddle/PaddleCustomerPortalService.php create mode 100644 resources/js/admin/mobile/__tests__/billingUsage.test.ts create mode 100644 resources/js/admin/mobile/billingUsage.ts create mode 100644 resources/js/pages/marketing/checkout/__tests__/PaymentStep.render.test.tsx create mode 100644 tests/Feature/Api/Tenant/BillingPortalTest.php diff --git a/app/Http/Controllers/Api/Tenant/EventController.php b/app/Http/Controllers/Api/Tenant/EventController.php index c8bac06..413a9a5 100644 --- a/app/Http/Controllers/Api/Tenant/EventController.php +++ b/app/Http/Controllers/Api/Tenant/EventController.php @@ -8,10 +8,12 @@ use App\Http\Requests\Tenant\EventStoreRequest; use App\Http\Resources\Tenant\EventJoinTokenResource; use App\Http\Resources\Tenant\EventResource; use App\Http\Resources\Tenant\PhotoResource; +use App\Models\CheckoutSession; use App\Models\Event; use App\Models\EventPackage; use App\Models\GuestNotification; use App\Models\Package; +use App\Models\PackagePurchase; use App\Models\Photo; use App\Models\Tenant; use App\Services\EventJoinTokenService; @@ -116,6 +118,17 @@ class EventController extends Controller ]); } + $requiresWaiver = $package->isEndcustomer(); + $latestPurchase = $requiresWaiver ? $this->resolveLatestPackagePurchase($tenant, $package) : null; + $existingWaiver = $latestPurchase ? data_get($latestPurchase->metadata, 'consents.digital_content_waiver_at') : null; + $needsWaiver = $requiresWaiver && ! $existingWaiver; + + if ($needsWaiver && ! $request->boolean('accepted_waiver')) { + throw ValidationException::withMessages([ + 'accepted_waiver' => 'Ein sofortiger Beginn der digitalen Dienstleistung erfordert Ihre ausdrückliche Zustimmung.', + ]); + } + $eventData = array_merge($validated, [ 'tenant_id' => $tenantId, 'status' => $validated['status'] ?? 'draft', @@ -180,15 +193,21 @@ class EventController extends Controller 'gallery_expires_at' => $package->gallery_days ? now()->addDays($package->gallery_days) : null, ]); - $note = sprintf('Event #%d created (%s)', $event->id, $event->name); + if ($package->isReseller()) { + $note = sprintf('Event #%d created (%s)', $event->id, $event->name); - if (! $tenant->consumeEventAllowance(1, 'event.create', $note)) { - throw new HttpException(402, 'Insufficient package allowance.'); + if (! $tenant->consumeEventAllowance(1, 'event.create', $note)) { + throw new HttpException(402, 'Insufficient package allowance.'); + } } return $event; }); + if ($needsWaiver) { + $this->recordEventStartWaiver($tenant, $package, $latestPurchase); + } + $tenant->refresh(); $event->load(['eventType', 'tenant', 'eventPackages.package', 'eventPackages.addons']); @@ -200,6 +219,45 @@ class EventController extends Controller ], 201); } + private function resolveLatestPackagePurchase(Tenant $tenant, Package $package): ?PackagePurchase + { + return PackagePurchase::query() + ->where('tenant_id', $tenant->id) + ->where('package_id', $package->id) + ->orderByDesc('purchased_at') + ->orderByDesc('id') + ->first(); + } + + private function recordEventStartWaiver(Tenant $tenant, Package $package, ?PackagePurchase $purchase): void + { + $timestamp = now(); + $legalVersion = config('app.legal_version', $timestamp->toDateString()); + + if ($purchase) { + $metadata = $purchase->metadata ?? []; + $consents = is_array($metadata['consents'] ?? null) ? $metadata['consents'] : []; + $consents['digital_content_waiver_at'] = $timestamp->toIso8601String(); + $consents['legal_version'] = $consents['legal_version'] ?? $legalVersion; + $metadata['consents'] = $consents; + $purchase->metadata = $metadata; + $purchase->save(); + } + + $session = CheckoutSession::query() + ->where('tenant_id', $tenant->id) + ->where('package_id', $package->id) + ->where('status', CheckoutSession::STATUS_COMPLETED) + ->orderByDesc('completed_at') + ->first(); + + if ($session && ! $session->digital_content_waiver_at) { + $session->digital_content_waiver_at = $timestamp; + $session->legal_version = $session->legal_version ?? $legalVersion; + $session->save(); + } + } + public function show(Request $request, Event $event): JsonResponse { $tenantId = $request->attributes->get('tenant_id'); diff --git a/app/Http/Controllers/Api/TenantBillingController.php b/app/Http/Controllers/Api/TenantBillingController.php index 92e04ad..fa10d9d 100644 --- a/app/Http/Controllers/Api/TenantBillingController.php +++ b/app/Http/Controllers/Api/TenantBillingController.php @@ -4,6 +4,8 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; use App\Models\EventPackageAddon; +use App\Services\Paddle\PaddleCustomerPortalService; +use App\Services\Paddle\PaddleCustomerService; use App\Services\Paddle\PaddleTransactionService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -12,7 +14,11 @@ use Illuminate\Support\Facades\Log; class TenantBillingController extends Controller { - public function __construct(private readonly PaddleTransactionService $paddleTransactions) {} + public function __construct( + private readonly PaddleTransactionService $paddleTransactions, + private readonly PaddleCustomerService $paddleCustomers, + private readonly PaddleCustomerPortalService $portalSessions, + ) {} public function transactions(Request $request): JsonResponse { @@ -116,4 +122,44 @@ class TenantBillingController extends Controller ], ]); } + + public function portal(Request $request): JsonResponse + { + $tenant = $request->attributes->get('tenant'); + + if (! $tenant) { + return response()->json([ + 'message' => 'Tenant not found.', + ], 404); + } + + try { + $customerId = $this->paddleCustomers->ensureCustomerId($tenant); + $session = $this->portalSessions->createSession($customerId); + } catch (\Throwable $exception) { + Log::warning('Failed to create Paddle customer portal session', [ + 'tenant_id' => $tenant->id, + 'error' => $exception->getMessage(), + ]); + + return response()->json([ + 'message' => 'Failed to create Paddle customer portal session.', + ], 502); + } + + $url = Arr::get($session, 'data.urls.general.overview') + ?? Arr::get($session, 'data.urls.general') + ?? Arr::get($session, 'urls.general.overview') + ?? Arr::get($session, 'urls.general'); + + if (! $url) { + return response()->json([ + 'message' => 'Paddle customer portal session missing URL.', + ], 502); + } + + return response()->json([ + 'url' => $url, + ]); + } } diff --git a/app/Http/Controllers/CheckoutController.php b/app/Http/Controllers/CheckoutController.php index e2f8d42..cb31643 100644 --- a/app/Http/Controllers/CheckoutController.php +++ b/app/Http/Controllers/CheckoutController.php @@ -219,16 +219,6 @@ class CheckoutController extends Controller ], 422); } - $requiresWaiver = (bool) ($package->activates_immediately ?? true); - - if ($requiresWaiver && ! $request->boolean('accepted_waiver')) { - return response()->json([ - 'errors' => [ - 'accepted_waiver' => ['Ein sofortiger Beginn der digitalen Dienstleistung erfordert Ihre ausdrückliche Zustimmung.'], - ], - ], 422); - } - $session = $sessions->createOrResume($user, $package, [ 'tenant' => $user->tenant, 'locale' => $validated['locale'] ?? null, @@ -241,7 +231,7 @@ class CheckoutController extends Controller 'accepted_terms_at' => $now, 'accepted_privacy_at' => $now, 'accepted_withdrawal_notice_at' => $now, - 'digital_content_waiver_at' => $requiresWaiver && $request->boolean('accepted_waiver') ? $now : null, + 'digital_content_waiver_at' => null, 'legal_version' => config('app.legal_version', $now->toDateString()), ])->save(); diff --git a/app/Http/Controllers/PaddleCheckoutController.php b/app/Http/Controllers/PaddleCheckoutController.php index 3b55169..612692e 100644 --- a/app/Http/Controllers/PaddleCheckoutController.php +++ b/app/Http/Controllers/PaddleCheckoutController.php @@ -38,14 +38,6 @@ class PaddleCheckoutController extends Controller throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Paddle price.']); } - $requiresWaiver = (bool) ($package->activates_immediately ?? true); - - if ($requiresWaiver && ! $request->boolean('accepted_waiver')) { - throw ValidationException::withMessages([ - 'accepted_waiver' => 'Ein sofortiger Beginn der digitalen Dienstleistung erfordert Ihre ausdrückliche Zustimmung.', - ]); - } - $session = $this->sessions->createOrResume($user, $package, [ 'tenant' => $tenant, ]); @@ -58,7 +50,7 @@ class PaddleCheckoutController extends Controller 'accepted_terms_at' => $now, 'accepted_privacy_at' => $now, 'accepted_withdrawal_notice_at' => $now, - 'digital_content_waiver_at' => $requiresWaiver ? $now : null, + 'digital_content_waiver_at' => null, 'legal_version' => $this->resolveLegalVersion(), ])->save(); @@ -95,7 +87,6 @@ class PaddleCheckoutController extends Controller 'checkout_session_id' => (string) $session->id, 'legal_version' => $session->legal_version, 'accepted_terms' => '1', - 'accepted_waiver' => $requiresWaiver && $request->boolean('accepted_waiver') ? '1' : '0', ], 'customer' => array_filter([ 'email' => $user->email, @@ -112,7 +103,6 @@ class PaddleCheckoutController extends Controller 'coupon_code' => $couponCode ?: null, 'legal_version' => $session->legal_version, 'accepted_terms' => true, - 'accepted_waiver' => $requiresWaiver && $request->boolean('accepted_waiver'), ], 'discount_id' => $discountId, ]); diff --git a/app/Http/Requests/Checkout/CheckoutFreeActivationRequest.php b/app/Http/Requests/Checkout/CheckoutFreeActivationRequest.php index b976f94..8d78fc0 100644 --- a/app/Http/Requests/Checkout/CheckoutFreeActivationRequest.php +++ b/app/Http/Requests/Checkout/CheckoutFreeActivationRequest.php @@ -24,7 +24,6 @@ class CheckoutFreeActivationRequest extends FormRequest return [ 'package_id' => ['required', 'exists:packages,id'], 'accepted_terms' => ['required', 'boolean', 'accepted'], - 'accepted_waiver' => ['nullable', 'boolean'], 'locale' => ['nullable', 'string', 'max:10'], ]; } diff --git a/app/Http/Requests/Paddle/PaddleCheckoutRequest.php b/app/Http/Requests/Paddle/PaddleCheckoutRequest.php index 769321f..be8ed88 100644 --- a/app/Http/Requests/Paddle/PaddleCheckoutRequest.php +++ b/app/Http/Requests/Paddle/PaddleCheckoutRequest.php @@ -28,7 +28,6 @@ class PaddleCheckoutRequest extends FormRequest 'inline' => ['sometimes', 'boolean'], 'coupon_code' => ['nullable', 'string', 'max:64'], 'accepted_terms' => ['required', 'boolean', 'accepted'], - 'accepted_waiver' => ['sometimes', 'boolean'], ]; } diff --git a/app/Http/Requests/Tenant/EventStoreRequest.php b/app/Http/Requests/Tenant/EventStoreRequest.php index d7645ff..f236bf7 100644 --- a/app/Http/Requests/Tenant/EventStoreRequest.php +++ b/app/Http/Requests/Tenant/EventStoreRequest.php @@ -67,6 +67,7 @@ class EventStoreRequest extends FormRequest 'settings.watermark.offset_x' => ['nullable', 'integer', 'min:-500', 'max:500'], 'settings.watermark.offset_y' => ['nullable', 'integer', 'min:-500', 'max:500'], 'settings.watermark_serve_originals' => ['nullable', 'boolean'], + 'accepted_waiver' => ['nullable', 'boolean'], ]; } diff --git a/app/Services/Packages/PackageLimitEvaluator.php b/app/Services/Packages/PackageLimitEvaluator.php index 8e728a1..b77a78a 100644 --- a/app/Services/Packages/PackageLimitEvaluator.php +++ b/app/Services/Packages/PackageLimitEvaluator.php @@ -10,6 +10,15 @@ class PackageLimitEvaluator { public function assessEventCreation(Tenant $tenant): ?array { + $hasEndcustomerPackage = $tenant->tenantPackages() + ->where('active', true) + ->whereHas('package', fn ($query) => $query->withTrashed()->where('type', 'endcustomer')) + ->exists(); + + if ($hasEndcustomerPackage) { + return null; + } + if ($tenant->hasEventAllowance()) { return null; } diff --git a/app/Services/Paddle/PaddleCustomerPortalService.php b/app/Services/Paddle/PaddleCustomerPortalService.php new file mode 100644 index 0000000..75df782 --- /dev/null +++ b/app/Services/Paddle/PaddleCustomerPortalService.php @@ -0,0 +1,27 @@ +} $options + * @return array + */ + public function createSession(string $customerId, array $options = []): array + { + $payload = [ + 'customer_id' => $customerId, + ]; + + if (! empty($options['subscription_ids'])) { + $payload['subscription_ids'] = array_values( + array_filter($options['subscription_ids'], 'is_string') + ); + } + + return $this->client->post('/customer-portal-sessions', $payload); + } +} diff --git a/resources/js/admin/api.ts b/resources/js/admin/api.ts index 4aa8b91..2d927aa 100644 --- a/resources/js/admin/api.ts +++ b/resources/js/admin/api.ts @@ -682,6 +682,7 @@ type EventSavePayload = { status?: 'draft' | 'published' | 'archived'; is_active?: boolean; package_id?: number; + accepted_waiver?: boolean; settings?: Record & { watermark?: WatermarkSettings; watermark_serve_originals?: boolean | null; @@ -2158,6 +2159,27 @@ export async function getTenantPaddleTransactions(cursor?: string): Promise<{ }; } +export async function createTenantBillingPortalSession(): Promise<{ url: string }> { + const response = await authorizedFetch('/api/v1/tenant/billing/portal', { + method: 'POST', + }); + + if (!response.ok) { + const payload = await safeJson(response); + console.error('[API] Failed to create Paddle portal session', response.status, payload); + throw new Error('Failed to create Paddle portal session'); + } + + const payload = await safeJson(response); + const url = payload?.url; + + if (typeof url !== 'string' || url.length === 0) { + throw new Error('Paddle portal session missing URL'); + } + + return { url }; +} + export async function getTenantAddonHistory(page = 1, perPage = 25): Promise<{ data: TenantAddonHistoryEntry[]; meta: PaginationMeta; diff --git a/resources/js/admin/i18n/locales/de/management.json b/resources/js/admin/i18n/locales/de/management.json index 1b91e2a..5234323 100644 --- a/resources/js/admin/i18n/locales/de/management.json +++ b/resources/js/admin/i18n/locales/de/management.json @@ -4,7 +4,11 @@ "subtitle": "Verwalte deine gebuchten Pakete und behalte Laufzeiten im Blick.", "actions": { "refresh": "Aktualisieren", - "exportCsv": "Export als CSV" + "exportCsv": "Export als CSV", + "portal": "Im Paddle-Portal verwalten", + "portalBusy": "Portal wird geöffnet...", + "openPackages": "Pakete öffnen", + "contactSupport": "Support kontaktieren" }, "stats": { "package": { @@ -17,7 +21,7 @@ "helper": "Verfügbar: {{count}}" }, "addons": { - "label": "Add-ons", + "label": "Zusatzpakete", "helper": "Historie insgesamt" }, "transactions": { @@ -27,16 +31,19 @@ }, "errors": { "load": "Paketdaten konnten nicht geladen werden.", - "more": "Weitere Einträge konnten nicht geladen werden." + "more": "Weitere Einträge konnten nicht geladen werden.", + "portal": "Paddle-Portal konnte nicht geöffnet werden." }, "sections": { "invoices": { "title": "Rechnungen & Zahlungen", + "hint": "Zahlungen prüfen und Belege herunterladen.", "empty": "Keine Zahlungen gefunden." }, "addOns": { - "title": "Add-ons", - "empty": "Keine Add-ons gebucht." + "title": "Zusatzpakete", + "hint": "Zusatzkontingente je Event im Blick behalten.", + "empty": "Keine Zusatzpakete gebucht." }, "overview": { "title": "Paketübersicht", @@ -68,7 +75,8 @@ } }, "packages": { - "title": "Paket-Historie", + "title": "Pakete", + "hint": "Aktives Paket, Limits und Historie auf einen Blick.", "description": "Übersicht über aktive und vergangene Pakete.", "empty": "Noch keine Pakete gebucht.", "card": { @@ -335,6 +343,14 @@ "confirm": "Weiter zum Checkout", "cancel": "Abbrechen" }, + "eventStartConsent": { + "title": "Vor dem ersten Event", + "description": "Bitte bestätige den sofortigen Beginn der digitalen Leistung, bevor du dein erstes Event erstellst.", + "checkboxWaiver": "Ich verlange ausdrücklich, dass mit der Bereitstellung der digitalen Leistung jetzt begonnen wird. Mir ist bekannt, dass ich mein Widerrufsrecht verliere, sobald der Vertrag vollständig erfüllt ist.", + "errorWaiver": "Bitte bestätige den sofortigen Leistungsbeginn und das vorzeitige Erlöschen des Widerrufsrechts.", + "confirm": "Event erstellen", + "cancel": "Abbrechen" + }, "placeholders": { "untitled": "Unbenanntes Event" }, @@ -1369,55 +1385,6 @@ "confirm": { "disable": "Photobooth-Zugang deaktivieren?" } - }, - "billing": { - "title": "Pakete & Abrechnung", - "subtitle": "Verwalte deine gebuchten Pakete und behalte Laufzeiten im Blick.", - "actions": { - "refresh": "Aktualisieren", - "exportCsv": "Export als CSV" - }, - "errors": { - "load": "Paketdaten konnten nicht geladen werden.", - "more": "Weitere Einträge konnten nicht geladen werden." - }, - "sections": { - "overview": { - "title": "Paketübersicht", - "description": "Dein aktives Paket und die wichtigsten Kennzahlen.", - "empty": "Noch kein Paket aktiv.", - "emptyBadge": "Kein aktives Paket", - "cards": { - "package": { - "label": "Aktives Paket", - "helper": "Aktuell zugewiesen" - }, - "used": { - "label": "Genutzte Events", - "helper": "Verfügbar: {{count}}" - }, - "price": { - "label": "Preis (netto)" - }, - "expires": { - "label": "Läuft ab", - "helper": "Automatische Verlängerung, falls aktiv" - } - } - } - }, - "packages": { - "title": "Paket-Historie", - "description": "Übersicht über aktuelle und vergangene Pakete.", - "empty": "Noch keine Pakete gebucht.", - "card": { - "statusActive": "Aktiv", - "statusInactive": "Inaktiv", - "used": "Genutzte Events", - "available": "Verfügbar", - "expires": "Läuft ab" - } - } } }, "settings": { @@ -2185,6 +2152,15 @@ "mobileBilling": { "packageFallback": "Paket", "remainingEvents": "{{count}} Events", + "openEvent": "Event öffnen", + "usage": { + "events": "Events", + "guests": "Gäste", + "photos": "Fotos", + "value": "{{used}} / {{limit}}", + "limit": "Limit {{limit}}", + "remaining": "Verbleibend {{count}}" + }, "status": { "completed": "Abgeschlossen", "pending": "Ausstehend", diff --git a/resources/js/admin/i18n/locales/en/management.json b/resources/js/admin/i18n/locales/en/management.json index aefc6c1..b25c670 100644 --- a/resources/js/admin/i18n/locales/en/management.json +++ b/resources/js/admin/i18n/locales/en/management.json @@ -4,7 +4,11 @@ "subtitle": "Manage your purchased packages and track their durations.", "actions": { "refresh": "Refresh", - "exportCsv": "Export CSV" + "exportCsv": "Export CSV", + "portal": "Manage in Paddle", + "portalBusy": "Opening portal...", + "openPackages": "Open packages", + "contactSupport": "Contact support" }, "stats": { "package": { @@ -27,15 +31,18 @@ }, "errors": { "load": "Unable to load package data.", - "more": "Unable to load more entries." + "more": "Unable to load more entries.", + "portal": "Unable to open the Paddle portal." }, "sections": { "invoices": { "title": "Invoices & payments", + "hint": "Review transactions and download receipts.", "empty": "No payments found." }, "addOns": { "title": "Add-ons", + "hint": "Track extra photo, guest, or time bundles per event.", "empty": "No add-ons booked." }, "overview": { @@ -68,7 +75,8 @@ } }, "packages": { - "title": "Package history", + "title": "Packages", + "hint": "Active package, limits, and history at a glance.", "description": "Overview of active and past packages.", "empty": "No packages purchased yet.", "card": { @@ -938,6 +946,14 @@ "confirm": "Continue to checkout", "cancel": "Cancel" }, + "eventStartConsent": { + "title": "Before your first event", + "description": "Please confirm the immediate start of the digital service before creating your first event.", + "checkboxWaiver": "I expressly request that the digital service begins now and understand my right of withdrawal expires once the contract has been fully performed.", + "errorWaiver": "Please confirm the immediate start of the digital service and the early expiry of the right of withdrawal.", + "confirm": "Create event", + "cancel": "Cancel" + }, "placeholders": { "untitled": "Untitled event" }, @@ -1382,55 +1398,6 @@ "confirm": { "disable": "Disable photobooth access?" } - }, - "billing": { - "title": "Packages & billing", - "subtitle": "Manage your purchased packages and track their durations.", - "actions": { - "refresh": "Refresh", - "exportCsv": "Export CSV" - }, - "errors": { - "load": "Unable to load package data.", - "more": "Unable to load more entries." - }, - "sections": { - "overview": { - "title": "Package overview", - "description": "Your active package and the most important metrics.", - "empty": "No active package yet.", - "emptyBadge": "No active package", - "cards": { - "package": { - "label": "Active package", - "helper": "Currently assigned" - }, - "used": { - "label": "Events used", - "helper": "Remaining: {{count}}" - }, - "price": { - "label": "Price (net)" - }, - "expires": { - "label": "Expires", - "helper": "Auto-renews if enabled" - } - } - } - }, - "packages": { - "title": "Package history", - "description": "Overview of current and past packages.", - "empty": "No packages purchased yet.", - "card": { - "statusActive": "Active", - "statusInactive": "Inactive", - "used": "Used events", - "available": "Available", - "expires": "Expires" - } - } } } , @@ -2205,6 +2172,15 @@ "mobileBilling": { "packageFallback": "Package", "remainingEvents": "{{count}} events", + "openEvent": "Open event", + "usage": { + "events": "Events", + "guests": "Guests", + "photos": "Photos", + "value": "{{used}} / {{limit}}", + "limit": "Limit {{limit}}", + "remaining": "Remaining {{count}}" + }, "status": { "completed": "Completed", "pending": "Pending", diff --git a/resources/js/admin/mobile/BillingPage.tsx b/resources/js/admin/mobile/BillingPage.tsx index 1fe72d4..779a30a 100644 --- a/resources/js/admin/mobile/BillingPage.tsx +++ b/resources/js/admin/mobile/BillingPage.tsx @@ -1,13 +1,15 @@ import React from 'react'; import { useNavigate, useLocation } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { CreditCard, Package, Receipt, RefreshCcw, Sparkles } from 'lucide-react'; +import { Package, Receipt, RefreshCcw, Sparkles } from 'lucide-react'; import { YStack, XStack } from '@tamagui/stacks'; import { SizableText as Text } from '@tamagui/text'; import { Pressable } from '@tamagui/react-native-web-lite'; +import toast from 'react-hot-toast'; import { MobileShell } from './components/MobileShell'; import { MobileCard, CTAButton, PillBadge } from './components/Primitives'; import { + createTenantBillingPortalSession, getTenantPackagesOverview, getTenantPaddleTransactions, TenantPackageSummary, @@ -15,7 +17,8 @@ import { } from '../api'; import { TenantAddonHistoryEntry, getTenantAddonHistory } from '../api'; import { getApiErrorMessage } from '../lib/apiError'; -import { adminPath } from '../constants'; +import { ADMIN_EVENT_VIEW_PATH } from '../constants'; +import { buildPackageUsageMetrics, PackageUsageMetric, usagePercent } from './billingUsage'; export default function MobileBillingPage() { const { t } = useTranslation('management'); @@ -27,8 +30,10 @@ export default function MobileBillingPage() { const [addons, setAddons] = React.useState([]); const [loading, setLoading] = React.useState(true); const [error, setError] = React.useState(null); + const [portalBusy, setPortalBusy] = React.useState(false); const packagesRef = React.useRef(null); const invoicesRef = React.useRef(null); + const supportEmail = 'support@fotospiel.de'; const load = React.useCallback(async () => { setLoading(true); @@ -51,6 +56,35 @@ export default function MobileBillingPage() { } }, [t]); + const scrollToPackages = React.useCallback(() => { + packagesRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }, []); + + const openSupport = React.useCallback(() => { + if (typeof window !== 'undefined') { + window.location.href = `mailto:${supportEmail}`; + } + }, [supportEmail]); + + const openPortal = React.useCallback(async () => { + if (portalBusy) { + return; + } + + setPortalBusy(true); + try { + const { url } = await createTenantBillingPortalSession(); + if (typeof window !== 'undefined') { + window.open(url, '_blank', 'noopener'); + } + } catch (err) { + const message = getApiErrorMessage(err, t('billing.errors.portal', 'Konnte das Paddle-Portal nicht öffnen.')); + toast.error(message); + } finally { + setPortalBusy(false); + } + }, [portalBusy, t]); + React.useEffect(() => { void load(); }, [load]); @@ -80,6 +114,7 @@ export default function MobileBillingPage() { {error} + ) : null} @@ -90,6 +125,14 @@ export default function MobileBillingPage() { {t('billing.sections.packages.title', 'Packages')} + + {t('billing.sections.packages.hint', 'Active package, limits, and history at a glance.')} + + {loading ? ( {t('common.loading', 'Lädt...')} @@ -97,7 +140,11 @@ export default function MobileBillingPage() { ) : ( {activePackage ? ( - + ) : null} {packages .filter((pkg) => !activePackage || pkg.id !== activePackage.id) @@ -115,14 +162,21 @@ export default function MobileBillingPage() { {t('billing.sections.invoices.title', 'Invoices & Payments')} + + {t('billing.sections.invoices.hint', 'Review transactions and download receipts.')} + {loading ? ( {t('common.loading', 'Lädt...')} ) : transactions.length === 0 ? ( - - {t('billing.sections.invoices.empty', 'Keine Zahlungen gefunden.')} - + + + {t('billing.sections.invoices.empty', 'Keine Zahlungen gefunden.')} + + + + ) : ( {transactions.slice(0, 8).map((trx) => ( @@ -169,6 +223,9 @@ export default function MobileBillingPage() { {t('billing.sections.addOns.title', 'Add-ons')} + + {t('billing.sections.addOns.hint', 'Track extra photo, guest, or time bundles per event.')} + {loading ? ( {t('common.loading', 'Lädt...')} @@ -190,12 +247,13 @@ export default function MobileBillingPage() { ); } -function PackageCard({ pkg, label }: { pkg: TenantPackageSummary; label?: string }) { +function PackageCard({ pkg, label, isActive = false }: { pkg: TenantPackageSummary; label?: string; isActive?: boolean }) { const { t } = useTranslation('management'); const remaining = pkg.remaining_events ?? (pkg.package_limits?.max_events_per_year as number | undefined) ?? 0; const expires = pkg.expires_at ? formatDate(pkg.expires_at) : null; + const usageMetrics = buildPackageUsageMetrics(pkg); return ( - + {pkg.package_name ?? t('mobileBilling.packageFallback', 'Package')} @@ -217,9 +275,13 @@ function PackageCard({ pkg, label }: { pkg: TenantPackageSummary; label?: string {renderFeatureBadge(pkg, t, 'branding_allowed', t('billing.features.branding', 'Branding'))} {renderFeatureBadge(pkg, t, 'watermark_allowed', t('billing.features.watermark', 'Watermark'))} - - - + {usageMetrics.length ? ( + + {usageMetrics.map((metric) => ( + + ))} + + ) : null} ); } @@ -231,43 +293,45 @@ function renderFeatureBadge(pkg: TenantPackageSummary, t: any, key: string, labe return {enabled ? label : `${label} off`}; } -function FeatureList({ pkg }: { pkg: TenantPackageSummary }) { +function UsageBar({ metric }: { metric: PackageUsageMetric }) { const { t } = useTranslation('management'); - const limits = pkg.package_limits ?? {}; - const features = (pkg as any).features as string[] | undefined; + const labelMap: Record = { + events: t('mobileBilling.usage.events', 'Events'), + guests: t('mobileBilling.usage.guests', 'Guests'), + photos: t('mobileBilling.usage.photos', 'Photos'), + }; - const rows: Array<{ label: string; value: string }> = []; - - if (limits.max_photos !== undefined && limits.max_photos !== null) { - rows.push({ label: t('billing.features.maxPhotos', 'Max photos'), value: String(limits.max_photos) }); - } - if (limits.max_guests !== undefined && limits.max_guests !== null) { - rows.push({ label: t('billing.features.maxGuests', 'Max guests'), value: String(limits.max_guests) }); - } - if (limits.gallery_days !== undefined && limits.gallery_days !== null) { - rows.push({ label: t('billing.features.galleryDays', 'Gallery days'), value: String(limits.gallery_days) }); - } - if (limits.max_tasks !== undefined && limits.max_tasks !== null) { - rows.push({ label: t('billing.features.maxTasks', 'Max tasks'), value: String(limits.max_tasks) }); - } - if (Array.isArray(features) && features.length) { - rows.push({ label: t('billing.features.featureList', 'Included features'), value: features.join(', ') }); + if (!metric.limit) { + return null; } - if (!rows.length) return null; + const hasUsage = metric.used !== null; + const valueText = hasUsage + ? t('mobileBilling.usage.value', { used: metric.used, limit: metric.limit }) + : t('mobileBilling.usage.limit', { limit: metric.limit }); + const remainingText = metric.remaining !== null + ? t('mobileBilling.usage.remaining', { count: metric.remaining }) + : null; + const fill = usagePercent(metric); return ( - - {rows.map((row) => ( - - - {row.label} - - - {row.value} - - - ))} + + + + {labelMap[metric.key]} + + + {valueText} + + + + + + {remainingText ? ( + + {remainingText} + + ) : null} ); } @@ -286,6 +350,7 @@ function formatAmount(value: number | null | undefined, currency: string | null function AddonRow({ addon }: { addon: TenantAddonHistoryEntry }) { const { t } = useTranslation('management'); + const navigate = useNavigate(); const labels: Record = { completed: { tone: 'success', text: t('mobileBilling.status.completed', 'Completed') }, pending: { tone: 'warning', text: t('mobileBilling.status.pending', 'Pending') }, @@ -296,6 +361,21 @@ function AddonRow({ addon }: { addon: TenantAddonHistoryEntry }) { (addon.event?.name && typeof addon.event.name === 'string' && addon.event.name) || (addon.event?.name && typeof addon.event.name === 'object' ? addon.event.name?.en ?? addon.event.name?.de ?? Object.values(addon.event.name)[0] : null) || null; + const eventPath = addon.event?.slug ? ADMIN_EVENT_VIEW_PATH(addon.event.slug) : null; + const hasImpact = Boolean(addon.extra_photos || addon.extra_guests || addon.extra_gallery_days); + const impactBadges = hasImpact ? ( + + {addon.extra_photos ? ( + {t('mobileBilling.extra.photos', '+{{count}} photos', { count: addon.extra_photos })} + ) : null} + {addon.extra_guests ? ( + {t('mobileBilling.extra.guests', '+{{count}} guests', { count: addon.extra_guests })} + ) : null} + {addon.extra_gallery_days ? ( + {t('mobileBilling.extra.days', '+{{count}} days', { count: addon.extra_gallery_days })} + ) : null} + + ) : null; return ( @@ -305,28 +385,31 @@ function AddonRow({ addon }: { addon: TenantAddonHistoryEntry }) { {status.text} + {eventName ? ( + eventPath ? ( + navigate(eventPath)}> + + + {eventName} + + + {t('mobileBilling.openEvent', 'Open event')} + + + + ) : ( + + {eventName} + + ) + ) : null} + {impactBadges} + + {formatAmount(addon.amount, addon.currency)} + {formatDate(addon.purchased_at)} - {eventName ? ( - - {eventName} - - ) : null} - - {addon.extra_photos ? ( - {t('mobileBilling.extra.photos', '+{{count}} photos', { count: addon.extra_photos })} - ) : null} - {addon.extra_guests ? ( - {t('mobileBilling.extra.guests', '+{{count}} guests', { count: addon.extra_guests })} - ) : null} - {addon.extra_gallery_days ? ( - {t('mobileBilling.extra.days', '+{{count}} days', { count: addon.extra_gallery_days })} - ) : null} - - - {formatAmount(addon.amount, addon.currency)} - ); } diff --git a/resources/js/admin/mobile/EventFormPage.tsx b/resources/js/admin/mobile/EventFormPage.tsx index 9101245..baea1a7 100644 --- a/resources/js/admin/mobile/EventFormPage.tsx +++ b/resources/js/admin/mobile/EventFormPage.tsx @@ -7,11 +7,12 @@ import { SizableText as Text } from '@tamagui/text'; import { Switch } from '@tamagui/switch'; import { MobileShell } from './components/MobileShell'; import { MobileCard, CTAButton } from './components/Primitives'; +import { LegalConsentSheet } from './components/LegalConsentSheet'; import { createEvent, getEvent, updateEvent, getEventTypes, TenantEvent, TenantEventType } from '../api'; import { resolveEventSlugAfterUpdate } from './eventFormNavigation'; import { adminPath } from '../constants'; import { isAuthError } from '../auth/tokens'; -import { getApiValidationMessage } from '../lib/apiError'; +import { getApiValidationMessage, isApiError } from '../lib/apiError'; import toast from 'react-hot-toast'; type FormState = { @@ -46,6 +47,9 @@ export default function MobileEventFormPage() { const [typesLoading, setTypesLoading] = React.useState(false); const [loading, setLoading] = React.useState(isEdit); const [saving, setSaving] = React.useState(false); + const [consentOpen, setConsentOpen] = React.useState(false); + const [consentBusy, setConsentBusy] = React.useState(false); + const [pendingPayload, setPendingPayload] = React.useState[0] | null>(null); const [error, setError] = React.useState(null); React.useEffect(() => { @@ -99,24 +103,24 @@ export default function MobileEventFormPage() { async function handleSubmit() { setSaving(true); setError(null); - try { - if (isEdit && slug) { - const updated = await updateEvent(slug, { - name: form.name, - event_date: form.date || undefined, - event_type_id: form.eventTypeId ?? undefined, - status: form.published ? 'published' : 'draft', - settings: { - location: form.location, - guest_upload_visibility: form.autoApproveUploads ? 'immediate' : 'review', - engagement_mode: form.tasksEnabled ? 'tasks' : 'photo_only', - }, - }); - const nextSlug = resolveEventSlugAfterUpdate(slug, updated); - navigate(adminPath(`/mobile/events/${nextSlug}`)); - } else { - const payload = { - name: form.name || t('eventForm.fields.name.fallback', 'Event'), + try { + if (isEdit && slug) { + const updated = await updateEvent(slug, { + name: form.name, + event_date: form.date || undefined, + event_type_id: form.eventTypeId ?? undefined, + status: form.published ? 'published' : 'draft', + settings: { + location: form.location, + guest_upload_visibility: form.autoApproveUploads ? 'immediate' : 'review', + engagement_mode: form.tasksEnabled ? 'tasks' : 'photo_only', + }, + }); + const nextSlug = resolveEventSlugAfterUpdate(slug, updated); + navigate(adminPath(`/mobile/events/${nextSlug}`)); + } else { + const payload = { + name: form.name || t('eventForm.fields.name.fallback', 'Event'), slug: `${Date.now()}`, event_type_id: form.eventTypeId ?? undefined, event_date: form.date || undefined, @@ -126,10 +130,56 @@ export default function MobileEventFormPage() { guest_upload_visibility: form.autoApproveUploads ? 'immediate' : 'review', engagement_mode: form.tasksEnabled ? 'tasks' : 'photo_only', }, - }; - const { event } = await createEvent(payload as any); + } as Parameters[0]; + const { event } = await createEvent(payload); navigate(adminPath(`/mobile/events/${event.slug}`)); } + } catch (err) { + if (isAuthError(err)) { + return; + } + + if (!isEdit && isWaiverRequiredError(err)) { + const payload = { + name: form.name || t('eventForm.fields.name.fallback', 'Event'), + slug: `${Date.now()}`, + event_type_id: form.eventTypeId ?? undefined, + event_date: form.date || undefined, + status: (form.published ? 'published' : 'draft') as const, + settings: { + location: form.location, + guest_upload_visibility: form.autoApproveUploads ? 'immediate' : 'review', + engagement_mode: form.tasksEnabled ? 'tasks' : 'photo_only', + }, + } as Parameters[0]; + setPendingPayload(payload); + setConsentOpen(true); + return; + } + + const message = getApiValidationMessage(err, t('eventForm.errors.saveFailed', 'Event could not be saved.')); + setError(message); + toast.error(message); + } finally { + setSaving(false); + } + } + + async function handleConsentConfirm(consents: { acceptedTerms: boolean; acceptedWaiver: boolean }) { + if (!pendingPayload) { + setConsentOpen(false); + return; + } + + setConsentBusy(true); + try { + const { event } = await createEvent({ + ...pendingPayload, + accepted_waiver: consents.acceptedWaiver, + }); + navigate(adminPath(`/mobile/events/${event.slug}`)); + setConsentOpen(false); + setPendingPayload(null); } catch (err) { if (!isAuthError(err)) { const message = getApiValidationMessage(err, t('eventForm.errors.saveFailed', 'Event could not be saved.')); @@ -137,7 +187,7 @@ export default function MobileEventFormPage() { toast.error(message); } } finally { - setSaving(false); + setConsentBusy(false); } } @@ -331,6 +381,37 @@ export default function MobileEventFormPage() { onPress={() => handleSubmit()} /> + + { + if (consentBusy) return; + setConsentOpen(false); + setPendingPayload(null); + }} + onConfirm={handleConsentConfirm} + busy={consentBusy} + requireTerms={false} + requireWaiver + copy={{ + title: t('events.eventStartConsent.title', 'Before your first event'), + description: t( + 'events.eventStartConsent.description', + 'Please confirm the immediate start of the digital service before creating your first event.', + ), + checkboxWaiver: t( + 'events.eventStartConsent.checkboxWaiver', + 'I expressly request that the digital service begins now and understand my right of withdrawal expires once the contract has been fully performed.', + ), + errorWaiver: t( + 'events.eventStartConsent.errorWaiver', + 'Please confirm the immediate start of the digital service and the early expiry of the right of withdrawal.', + ), + confirm: t('events.eventStartConsent.confirm', 'Create event'), + cancel: t('events.eventStartConsent.cancel', 'Cancel'), + }} + t={t} + /> ); } @@ -364,6 +445,19 @@ function renderName(name: TenantEvent['name']): string { return ''; } +function isWaiverRequiredError(error: unknown): boolean { + if (!isApiError(error)) { + return false; + } + + const metaErrors = error.meta?.errors; + if (!metaErrors || typeof metaErrors !== 'object') { + return false; + } + + return 'accepted_waiver' in metaErrors; +} + function toDateTimeLocal(value?: string | null): string { if (!value) return ''; diff --git a/resources/js/admin/mobile/__tests__/billingUsage.test.ts b/resources/js/admin/mobile/__tests__/billingUsage.test.ts new file mode 100644 index 0000000..a9287bb --- /dev/null +++ b/resources/js/admin/mobile/__tests__/billingUsage.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; +import type { TenantPackageSummary } from '../../api'; +import { buildPackageUsageMetrics, usagePercent } from '../billingUsage'; + +const basePackage: TenantPackageSummary = { + id: 1, + package_id: 1, + package_name: 'Pro', + active: true, + used_events: 2, + remaining_events: 3, + price: 120, + currency: 'EUR', + purchased_at: null, + expires_at: null, + package_limits: { + max_events_per_year: 5, + max_guests: 150, + max_photos: 1000, + }, + branding_allowed: true, + watermark_allowed: true, + features: null, +}; + +describe('buildPackageUsageMetrics', () => { + it('builds usage metrics for event, guest, and photo limits', () => { + const metrics = buildPackageUsageMetrics(basePackage); + const keys = metrics.map((metric) => metric.key); + expect(keys).toEqual(['events', 'guests', 'photos']); + + const eventMetric = metrics.find((metric) => metric.key === 'events'); + expect(eventMetric?.used).toBe(2); + expect(eventMetric?.limit).toBe(5); + expect(eventMetric?.remaining).toBe(3); + }); + + it('filters metrics without limits', () => { + const metrics = buildPackageUsageMetrics({ + ...basePackage, + package_limits: { max_events_per_year: 0 }, + }); + expect(metrics).toHaveLength(0); + }); +}); + +describe('usagePercent', () => { + it('calculates usage percent when usage is known', () => { + const metrics = buildPackageUsageMetrics(basePackage); + const eventMetric = metrics.find((metric) => metric.key === 'events'); + expect(eventMetric).toBeTruthy(); + expect(usagePercent(eventMetric!)).toBe(40); + }); + + it('defaults to full bar when usage is unknown', () => { + const metrics = buildPackageUsageMetrics({ + ...basePackage, + used_events: NaN, + remaining_events: null, + package_limits: { max_events_per_year: 10 }, + }); + expect(usagePercent(metrics[0])).toBe(100); + }); +}); diff --git a/resources/js/admin/mobile/billingUsage.ts b/resources/js/admin/mobile/billingUsage.ts new file mode 100644 index 0000000..6b96142 --- /dev/null +++ b/resources/js/admin/mobile/billingUsage.ts @@ -0,0 +1,83 @@ +import type { TenantPackageSummary } from '../api'; + +export type PackageUsageMetricKey = 'events' | 'guests' | 'photos'; + +export type PackageUsageMetric = { + key: PackageUsageMetricKey; + limit: number | null; + used: number | null; + remaining: number | null; +}; + +const toNumber = (value: unknown): number | null => { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + + if (typeof value === 'string' && value.trim() !== '') { + const parsed = Number(value); + if (Number.isFinite(parsed)) { + return parsed; + } + } + + return null; +}; + +const resolveLimitValue = (limits: Record | null, key: string): number | null => { + const value = limits ? toNumber(limits[key]) : null; + if (value === null || value <= 0) { + return null; + } + return value; +}; + +const resolveUsageValue = (value: unknown): number | null => { + const normalized = toNumber(value); + if (normalized === null || normalized < 0) { + return null; + } + return normalized; +}; + +const deriveUsedFromRemaining = (limit: number | null, remaining: number | null): number | null => { + if (limit === null || remaining === null) { + return null; + } + + return Math.max(limit - remaining, 0); +}; + +export const buildPackageUsageMetrics = (pkg: TenantPackageSummary): PackageUsageMetric[] => { + const limits = pkg.package_limits ?? {}; + + const eventLimit = resolveLimitValue(limits, 'max_events_per_year'); + const eventRemaining = resolveUsageValue(pkg.remaining_events); + const eventUsed = resolveUsageValue(pkg.used_events) ?? deriveUsedFromRemaining(eventLimit, eventRemaining); + + const guestLimit = resolveLimitValue(limits, 'max_guests'); + const guestRemaining = resolveUsageValue(limits['remaining_guests']); + const guestUsed = resolveUsageValue(limits['used_guests']) ?? deriveUsedFromRemaining(guestLimit, guestRemaining); + + const photoLimit = resolveLimitValue(limits, 'max_photos'); + const photoRemaining = resolveUsageValue(limits['remaining_photos']); + const photoUsed = resolveUsageValue(limits['used_photos']) ?? deriveUsedFromRemaining(photoLimit, photoRemaining); + + return [ + { key: 'events', limit: eventLimit, used: eventUsed, remaining: eventRemaining }, + { key: 'guests', limit: guestLimit, used: guestUsed, remaining: guestRemaining }, + { key: 'photos', limit: photoLimit, used: photoUsed, remaining: photoRemaining }, + ].filter((metric) => metric.limit !== null); +}; + +export const usagePercent = (metric: PackageUsageMetric): number => { + if (!metric.limit || metric.limit <= 0) { + return 0; + } + + if (metric.used === null || metric.used < 0) { + return 100; + } + + return Math.min(100, Math.max(0, Math.round((metric.used / metric.limit) * 100))); +}; diff --git a/resources/js/admin/mobile/components/LegalConsentSheet.tsx b/resources/js/admin/mobile/components/LegalConsentSheet.tsx index 7c43731..5b9a6a4 100644 --- a/resources/js/admin/mobile/components/LegalConsentSheet.tsx +++ b/resources/js/admin/mobile/components/LegalConsentSheet.tsx @@ -11,11 +11,31 @@ type LegalConsentSheetProps = { onClose: () => void; onConfirm: (consents: { acceptedTerms: boolean; acceptedWaiver: boolean }) => Promise | void; busy?: boolean; + requireTerms?: boolean; requireWaiver?: boolean; + copy?: { + title?: string; + description?: string; + checkboxTerms?: string; + checkboxWaiver?: string; + errorTerms?: string; + errorWaiver?: string; + confirm?: string; + cancel?: string; + }; t: Translator; }; -export function LegalConsentSheet({ open, onClose, onConfirm, busy = false, requireWaiver = true, t }: LegalConsentSheetProps) { +export function LegalConsentSheet({ + open, + onClose, + onConfirm, + busy = false, + requireTerms = true, + requireWaiver = true, + copy, + t, +}: LegalConsentSheetProps) { const [acceptedTerms, setAcceptedTerms] = React.useState(false); const [acceptedWaiver, setAcceptedWaiver] = React.useState(false); const [error, setError] = React.useState(null); @@ -29,25 +49,28 @@ export function LegalConsentSheet({ open, onClose, onConfirm, busy = false, requ }, [open]); async function handleConfirm() { - if (!acceptedTerms) { - setError(t('events.legalConsent.errorTerms', 'Please confirm the terms.')); + if (requireTerms && !acceptedTerms) { + setError(copy?.errorTerms ?? t('events.legalConsent.errorTerms', 'Please confirm the terms.')); return; } if (requireWaiver && !acceptedWaiver) { - setError(t('events.legalConsent.errorWaiver', 'Please confirm the waiver.')); + setError(copy?.errorWaiver ?? t('events.legalConsent.errorWaiver', 'Please confirm the waiver.')); return; } setError(null); - await onConfirm({ acceptedTerms, acceptedWaiver: requireWaiver ? acceptedWaiver : true }); + await onConfirm({ + acceptedTerms: requireTerms ? acceptedTerms : true, + acceptedWaiver: requireWaiver ? acceptedWaiver : true, + }); } return ( {error ? ( @@ -55,29 +78,41 @@ export function LegalConsentSheet({ open, onClose, onConfirm, busy = false, requ {error} ) : null} - - + + } > - {t('events.legalConsent.description', 'Please confirm the legal notes before buying an add-on.')} + {copy?.description ?? t('events.legalConsent.description', 'Please confirm the legal notes before buying an add-on.')} - + {requireTerms ? ( + + ) : null} {requireWaiver ? (