verschieben des sofortigen verzichts auf das Widerrrufsrecht zum Anlegen des Events
This commit is contained in:
@@ -8,10 +8,12 @@ use App\Http\Requests\Tenant\EventStoreRequest;
|
|||||||
use App\Http\Resources\Tenant\EventJoinTokenResource;
|
use App\Http\Resources\Tenant\EventJoinTokenResource;
|
||||||
use App\Http\Resources\Tenant\EventResource;
|
use App\Http\Resources\Tenant\EventResource;
|
||||||
use App\Http\Resources\Tenant\PhotoResource;
|
use App\Http\Resources\Tenant\PhotoResource;
|
||||||
|
use App\Models\CheckoutSession;
|
||||||
use App\Models\Event;
|
use App\Models\Event;
|
||||||
use App\Models\EventPackage;
|
use App\Models\EventPackage;
|
||||||
use App\Models\GuestNotification;
|
use App\Models\GuestNotification;
|
||||||
use App\Models\Package;
|
use App\Models\Package;
|
||||||
|
use App\Models\PackagePurchase;
|
||||||
use App\Models\Photo;
|
use App\Models\Photo;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Services\EventJoinTokenService;
|
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, [
|
$eventData = array_merge($validated, [
|
||||||
'tenant_id' => $tenantId,
|
'tenant_id' => $tenantId,
|
||||||
'status' => $validated['status'] ?? 'draft',
|
'status' => $validated['status'] ?? 'draft',
|
||||||
@@ -180,15 +193,21 @@ class EventController extends Controller
|
|||||||
'gallery_expires_at' => $package->gallery_days ? now()->addDays($package->gallery_days) : null,
|
'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)) {
|
if (! $tenant->consumeEventAllowance(1, 'event.create', $note)) {
|
||||||
throw new HttpException(402, 'Insufficient package allowance.');
|
throw new HttpException(402, 'Insufficient package allowance.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $event;
|
return $event;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if ($needsWaiver) {
|
||||||
|
$this->recordEventStartWaiver($tenant, $package, $latestPurchase);
|
||||||
|
}
|
||||||
|
|
||||||
$tenant->refresh();
|
$tenant->refresh();
|
||||||
$event->load(['eventType', 'tenant', 'eventPackages.package', 'eventPackages.addons']);
|
$event->load(['eventType', 'tenant', 'eventPackages.package', 'eventPackages.addons']);
|
||||||
|
|
||||||
@@ -200,6 +219,45 @@ class EventController extends Controller
|
|||||||
], 201);
|
], 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
|
public function show(Request $request, Event $event): JsonResponse
|
||||||
{
|
{
|
||||||
$tenantId = $request->attributes->get('tenant_id');
|
$tenantId = $request->attributes->get('tenant_id');
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ namespace App\Http\Controllers\Api;
|
|||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\EventPackageAddon;
|
use App\Models\EventPackageAddon;
|
||||||
|
use App\Services\Paddle\PaddleCustomerPortalService;
|
||||||
|
use App\Services\Paddle\PaddleCustomerService;
|
||||||
use App\Services\Paddle\PaddleTransactionService;
|
use App\Services\Paddle\PaddleTransactionService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@@ -12,7 +14,11 @@ use Illuminate\Support\Facades\Log;
|
|||||||
|
|
||||||
class TenantBillingController extends Controller
|
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
|
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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -219,16 +219,6 @@ class CheckoutController extends Controller
|
|||||||
], 422);
|
], 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, [
|
$session = $sessions->createOrResume($user, $package, [
|
||||||
'tenant' => $user->tenant,
|
'tenant' => $user->tenant,
|
||||||
'locale' => $validated['locale'] ?? null,
|
'locale' => $validated['locale'] ?? null,
|
||||||
@@ -241,7 +231,7 @@ class CheckoutController extends Controller
|
|||||||
'accepted_terms_at' => $now,
|
'accepted_terms_at' => $now,
|
||||||
'accepted_privacy_at' => $now,
|
'accepted_privacy_at' => $now,
|
||||||
'accepted_withdrawal_notice_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()),
|
'legal_version' => config('app.legal_version', $now->toDateString()),
|
||||||
])->save();
|
])->save();
|
||||||
|
|
||||||
|
|||||||
@@ -38,14 +38,6 @@ class PaddleCheckoutController extends Controller
|
|||||||
throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Paddle price.']);
|
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, [
|
$session = $this->sessions->createOrResume($user, $package, [
|
||||||
'tenant' => $tenant,
|
'tenant' => $tenant,
|
||||||
]);
|
]);
|
||||||
@@ -58,7 +50,7 @@ class PaddleCheckoutController extends Controller
|
|||||||
'accepted_terms_at' => $now,
|
'accepted_terms_at' => $now,
|
||||||
'accepted_privacy_at' => $now,
|
'accepted_privacy_at' => $now,
|
||||||
'accepted_withdrawal_notice_at' => $now,
|
'accepted_withdrawal_notice_at' => $now,
|
||||||
'digital_content_waiver_at' => $requiresWaiver ? $now : null,
|
'digital_content_waiver_at' => null,
|
||||||
'legal_version' => $this->resolveLegalVersion(),
|
'legal_version' => $this->resolveLegalVersion(),
|
||||||
])->save();
|
])->save();
|
||||||
|
|
||||||
@@ -95,7 +87,6 @@ class PaddleCheckoutController extends Controller
|
|||||||
'checkout_session_id' => (string) $session->id,
|
'checkout_session_id' => (string) $session->id,
|
||||||
'legal_version' => $session->legal_version,
|
'legal_version' => $session->legal_version,
|
||||||
'accepted_terms' => '1',
|
'accepted_terms' => '1',
|
||||||
'accepted_waiver' => $requiresWaiver && $request->boolean('accepted_waiver') ? '1' : '0',
|
|
||||||
],
|
],
|
||||||
'customer' => array_filter([
|
'customer' => array_filter([
|
||||||
'email' => $user->email,
|
'email' => $user->email,
|
||||||
@@ -112,7 +103,6 @@ class PaddleCheckoutController extends Controller
|
|||||||
'coupon_code' => $couponCode ?: null,
|
'coupon_code' => $couponCode ?: null,
|
||||||
'legal_version' => $session->legal_version,
|
'legal_version' => $session->legal_version,
|
||||||
'accepted_terms' => true,
|
'accepted_terms' => true,
|
||||||
'accepted_waiver' => $requiresWaiver && $request->boolean('accepted_waiver'),
|
|
||||||
],
|
],
|
||||||
'discount_id' => $discountId,
|
'discount_id' => $discountId,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ class CheckoutFreeActivationRequest extends FormRequest
|
|||||||
return [
|
return [
|
||||||
'package_id' => ['required', 'exists:packages,id'],
|
'package_id' => ['required', 'exists:packages,id'],
|
||||||
'accepted_terms' => ['required', 'boolean', 'accepted'],
|
'accepted_terms' => ['required', 'boolean', 'accepted'],
|
||||||
'accepted_waiver' => ['nullable', 'boolean'],
|
|
||||||
'locale' => ['nullable', 'string', 'max:10'],
|
'locale' => ['nullable', 'string', 'max:10'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ class PaddleCheckoutRequest extends FormRequest
|
|||||||
'inline' => ['sometimes', 'boolean'],
|
'inline' => ['sometimes', 'boolean'],
|
||||||
'coupon_code' => ['nullable', 'string', 'max:64'],
|
'coupon_code' => ['nullable', 'string', 'max:64'],
|
||||||
'accepted_terms' => ['required', 'boolean', 'accepted'],
|
'accepted_terms' => ['required', 'boolean', 'accepted'],
|
||||||
'accepted_waiver' => ['sometimes', 'boolean'],
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ class EventStoreRequest extends FormRequest
|
|||||||
'settings.watermark.offset_x' => ['nullable', 'integer', 'min:-500', 'max:500'],
|
'settings.watermark.offset_x' => ['nullable', 'integer', 'min:-500', 'max:500'],
|
||||||
'settings.watermark.offset_y' => ['nullable', 'integer', 'min:-500', 'max:500'],
|
'settings.watermark.offset_y' => ['nullable', 'integer', 'min:-500', 'max:500'],
|
||||||
'settings.watermark_serve_originals' => ['nullable', 'boolean'],
|
'settings.watermark_serve_originals' => ['nullable', 'boolean'],
|
||||||
|
'accepted_waiver' => ['nullable', 'boolean'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,15 @@ class PackageLimitEvaluator
|
|||||||
{
|
{
|
||||||
public function assessEventCreation(Tenant $tenant): ?array
|
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()) {
|
if ($tenant->hasEventAllowance()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
27
app/Services/Paddle/PaddleCustomerPortalService.php
Normal file
27
app/Services/Paddle/PaddleCustomerPortalService.php
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Paddle;
|
||||||
|
|
||||||
|
class PaddleCustomerPortalService
|
||||||
|
{
|
||||||
|
public function __construct(private readonly PaddleClient $client) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{subscription_ids?: array<int, string>} $options
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -682,6 +682,7 @@ type EventSavePayload = {
|
|||||||
status?: 'draft' | 'published' | 'archived';
|
status?: 'draft' | 'published' | 'archived';
|
||||||
is_active?: boolean;
|
is_active?: boolean;
|
||||||
package_id?: number;
|
package_id?: number;
|
||||||
|
accepted_waiver?: boolean;
|
||||||
settings?: Record<string, unknown> & {
|
settings?: Record<string, unknown> & {
|
||||||
watermark?: WatermarkSettings;
|
watermark?: WatermarkSettings;
|
||||||
watermark_serve_originals?: boolean | null;
|
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<{
|
export async function getTenantAddonHistory(page = 1, perPage = 25): Promise<{
|
||||||
data: TenantAddonHistoryEntry[];
|
data: TenantAddonHistoryEntry[];
|
||||||
meta: PaginationMeta;
|
meta: PaginationMeta;
|
||||||
|
|||||||
@@ -4,7 +4,11 @@
|
|||||||
"subtitle": "Verwalte deine gebuchten Pakete und behalte Laufzeiten im Blick.",
|
"subtitle": "Verwalte deine gebuchten Pakete und behalte Laufzeiten im Blick.",
|
||||||
"actions": {
|
"actions": {
|
||||||
"refresh": "Aktualisieren",
|
"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": {
|
"stats": {
|
||||||
"package": {
|
"package": {
|
||||||
@@ -17,7 +21,7 @@
|
|||||||
"helper": "Verfügbar: {{count}}"
|
"helper": "Verfügbar: {{count}}"
|
||||||
},
|
},
|
||||||
"addons": {
|
"addons": {
|
||||||
"label": "Add-ons",
|
"label": "Zusatzpakete",
|
||||||
"helper": "Historie insgesamt"
|
"helper": "Historie insgesamt"
|
||||||
},
|
},
|
||||||
"transactions": {
|
"transactions": {
|
||||||
@@ -27,16 +31,19 @@
|
|||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"load": "Paketdaten konnten nicht geladen werden.",
|
"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": {
|
"sections": {
|
||||||
"invoices": {
|
"invoices": {
|
||||||
"title": "Rechnungen & Zahlungen",
|
"title": "Rechnungen & Zahlungen",
|
||||||
|
"hint": "Zahlungen prüfen und Belege herunterladen.",
|
||||||
"empty": "Keine Zahlungen gefunden."
|
"empty": "Keine Zahlungen gefunden."
|
||||||
},
|
},
|
||||||
"addOns": {
|
"addOns": {
|
||||||
"title": "Add-ons",
|
"title": "Zusatzpakete",
|
||||||
"empty": "Keine Add-ons gebucht."
|
"hint": "Zusatzkontingente je Event im Blick behalten.",
|
||||||
|
"empty": "Keine Zusatzpakete gebucht."
|
||||||
},
|
},
|
||||||
"overview": {
|
"overview": {
|
||||||
"title": "Paketübersicht",
|
"title": "Paketübersicht",
|
||||||
@@ -68,7 +75,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packages": {
|
"packages": {
|
||||||
"title": "Paket-Historie",
|
"title": "Pakete",
|
||||||
|
"hint": "Aktives Paket, Limits und Historie auf einen Blick.",
|
||||||
"description": "Übersicht über aktive und vergangene Pakete.",
|
"description": "Übersicht über aktive und vergangene Pakete.",
|
||||||
"empty": "Noch keine Pakete gebucht.",
|
"empty": "Noch keine Pakete gebucht.",
|
||||||
"card": {
|
"card": {
|
||||||
@@ -335,6 +343,14 @@
|
|||||||
"confirm": "Weiter zum Checkout",
|
"confirm": "Weiter zum Checkout",
|
||||||
"cancel": "Abbrechen"
|
"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": {
|
"placeholders": {
|
||||||
"untitled": "Unbenanntes Event"
|
"untitled": "Unbenanntes Event"
|
||||||
},
|
},
|
||||||
@@ -1369,55 +1385,6 @@
|
|||||||
"confirm": {
|
"confirm": {
|
||||||
"disable": "Photobooth-Zugang deaktivieren?"
|
"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": {
|
"settings": {
|
||||||
@@ -2185,6 +2152,15 @@
|
|||||||
"mobileBilling": {
|
"mobileBilling": {
|
||||||
"packageFallback": "Paket",
|
"packageFallback": "Paket",
|
||||||
"remainingEvents": "{{count}} Events",
|
"remainingEvents": "{{count}} Events",
|
||||||
|
"openEvent": "Event öffnen",
|
||||||
|
"usage": {
|
||||||
|
"events": "Events",
|
||||||
|
"guests": "Gäste",
|
||||||
|
"photos": "Fotos",
|
||||||
|
"value": "{{used}} / {{limit}}",
|
||||||
|
"limit": "Limit {{limit}}",
|
||||||
|
"remaining": "Verbleibend {{count}}"
|
||||||
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"completed": "Abgeschlossen",
|
"completed": "Abgeschlossen",
|
||||||
"pending": "Ausstehend",
|
"pending": "Ausstehend",
|
||||||
|
|||||||
@@ -4,7 +4,11 @@
|
|||||||
"subtitle": "Manage your purchased packages and track their durations.",
|
"subtitle": "Manage your purchased packages and track their durations.",
|
||||||
"actions": {
|
"actions": {
|
||||||
"refresh": "Refresh",
|
"refresh": "Refresh",
|
||||||
"exportCsv": "Export CSV"
|
"exportCsv": "Export CSV",
|
||||||
|
"portal": "Manage in Paddle",
|
||||||
|
"portalBusy": "Opening portal...",
|
||||||
|
"openPackages": "Open packages",
|
||||||
|
"contactSupport": "Contact support"
|
||||||
},
|
},
|
||||||
"stats": {
|
"stats": {
|
||||||
"package": {
|
"package": {
|
||||||
@@ -27,15 +31,18 @@
|
|||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"load": "Unable to load package data.",
|
"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": {
|
"sections": {
|
||||||
"invoices": {
|
"invoices": {
|
||||||
"title": "Invoices & payments",
|
"title": "Invoices & payments",
|
||||||
|
"hint": "Review transactions and download receipts.",
|
||||||
"empty": "No payments found."
|
"empty": "No payments found."
|
||||||
},
|
},
|
||||||
"addOns": {
|
"addOns": {
|
||||||
"title": "Add-ons",
|
"title": "Add-ons",
|
||||||
|
"hint": "Track extra photo, guest, or time bundles per event.",
|
||||||
"empty": "No add-ons booked."
|
"empty": "No add-ons booked."
|
||||||
},
|
},
|
||||||
"overview": {
|
"overview": {
|
||||||
@@ -68,7 +75,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packages": {
|
"packages": {
|
||||||
"title": "Package history",
|
"title": "Packages",
|
||||||
|
"hint": "Active package, limits, and history at a glance.",
|
||||||
"description": "Overview of active and past packages.",
|
"description": "Overview of active and past packages.",
|
||||||
"empty": "No packages purchased yet.",
|
"empty": "No packages purchased yet.",
|
||||||
"card": {
|
"card": {
|
||||||
@@ -938,6 +946,14 @@
|
|||||||
"confirm": "Continue to checkout",
|
"confirm": "Continue to checkout",
|
||||||
"cancel": "Cancel"
|
"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": {
|
"placeholders": {
|
||||||
"untitled": "Untitled event"
|
"untitled": "Untitled event"
|
||||||
},
|
},
|
||||||
@@ -1382,55 +1398,6 @@
|
|||||||
"confirm": {
|
"confirm": {
|
||||||
"disable": "Disable photobooth access?"
|
"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": {
|
"mobileBilling": {
|
||||||
"packageFallback": "Package",
|
"packageFallback": "Package",
|
||||||
"remainingEvents": "{{count}} events",
|
"remainingEvents": "{{count}} events",
|
||||||
|
"openEvent": "Open event",
|
||||||
|
"usage": {
|
||||||
|
"events": "Events",
|
||||||
|
"guests": "Guests",
|
||||||
|
"photos": "Photos",
|
||||||
|
"value": "{{used}} / {{limit}}",
|
||||||
|
"limit": "Limit {{limit}}",
|
||||||
|
"remaining": "Remaining {{count}}"
|
||||||
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"completed": "Completed",
|
"completed": "Completed",
|
||||||
"pending": "Pending",
|
"pending": "Pending",
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useNavigate, useLocation } from 'react-router-dom';
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 { YStack, XStack } from '@tamagui/stacks';
|
||||||
import { SizableText as Text } from '@tamagui/text';
|
import { SizableText as Text } from '@tamagui/text';
|
||||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
import { MobileShell } from './components/MobileShell';
|
import { MobileShell } from './components/MobileShell';
|
||||||
import { MobileCard, CTAButton, PillBadge } from './components/Primitives';
|
import { MobileCard, CTAButton, PillBadge } from './components/Primitives';
|
||||||
import {
|
import {
|
||||||
|
createTenantBillingPortalSession,
|
||||||
getTenantPackagesOverview,
|
getTenantPackagesOverview,
|
||||||
getTenantPaddleTransactions,
|
getTenantPaddleTransactions,
|
||||||
TenantPackageSummary,
|
TenantPackageSummary,
|
||||||
@@ -15,7 +17,8 @@ import {
|
|||||||
} from '../api';
|
} from '../api';
|
||||||
import { TenantAddonHistoryEntry, getTenantAddonHistory } from '../api';
|
import { TenantAddonHistoryEntry, getTenantAddonHistory } from '../api';
|
||||||
import { getApiErrorMessage } from '../lib/apiError';
|
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() {
|
export default function MobileBillingPage() {
|
||||||
const { t } = useTranslation('management');
|
const { t } = useTranslation('management');
|
||||||
@@ -27,8 +30,10 @@ export default function MobileBillingPage() {
|
|||||||
const [addons, setAddons] = React.useState<TenantAddonHistoryEntry[]>([]);
|
const [addons, setAddons] = React.useState<TenantAddonHistoryEntry[]>([]);
|
||||||
const [loading, setLoading] = React.useState(true);
|
const [loading, setLoading] = React.useState(true);
|
||||||
const [error, setError] = React.useState<string | null>(null);
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
|
const [portalBusy, setPortalBusy] = React.useState(false);
|
||||||
const packagesRef = React.useRef<HTMLDivElement | null>(null);
|
const packagesRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
const invoicesRef = React.useRef<HTMLDivElement | null>(null);
|
const invoicesRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
|
const supportEmail = 'support@fotospiel.de';
|
||||||
|
|
||||||
const load = React.useCallback(async () => {
|
const load = React.useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -51,6 +56,35 @@ export default function MobileBillingPage() {
|
|||||||
}
|
}
|
||||||
}, [t]);
|
}, [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(() => {
|
React.useEffect(() => {
|
||||||
void load();
|
void load();
|
||||||
}, [load]);
|
}, [load]);
|
||||||
@@ -80,6 +114,7 @@ export default function MobileBillingPage() {
|
|||||||
<Text fontWeight="700" color="#b91c1c">
|
<Text fontWeight="700" color="#b91c1c">
|
||||||
{error}
|
{error}
|
||||||
</Text>
|
</Text>
|
||||||
|
<CTAButton label={t('billing.actions.refresh', 'Refresh')} tone="ghost" onPress={load} />
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -90,6 +125,14 @@ export default function MobileBillingPage() {
|
|||||||
{t('billing.sections.packages.title', 'Packages')}
|
{t('billing.sections.packages.title', 'Packages')}
|
||||||
</Text>
|
</Text>
|
||||||
</XStack>
|
</XStack>
|
||||||
|
<Text fontSize="$xs" color="#6b7280">
|
||||||
|
{t('billing.sections.packages.hint', 'Active package, limits, and history at a glance.')}
|
||||||
|
</Text>
|
||||||
|
<CTAButton
|
||||||
|
label={portalBusy ? t('billing.actions.portalBusy', 'Öffne Portal...') : t('billing.actions.portal', 'Manage in Paddle')}
|
||||||
|
onPress={openPortal}
|
||||||
|
disabled={portalBusy}
|
||||||
|
/>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<Text fontSize="$sm" color="#6b7280">
|
<Text fontSize="$sm" color="#6b7280">
|
||||||
{t('common.loading', 'Lädt...')}
|
{t('common.loading', 'Lädt...')}
|
||||||
@@ -97,7 +140,11 @@ export default function MobileBillingPage() {
|
|||||||
) : (
|
) : (
|
||||||
<YStack space="$2">
|
<YStack space="$2">
|
||||||
{activePackage ? (
|
{activePackage ? (
|
||||||
<PackageCard pkg={activePackage} label={t('billing.sections.packages.card.statusActive', 'Aktiv')} />
|
<PackageCard
|
||||||
|
pkg={activePackage}
|
||||||
|
label={t('billing.sections.packages.card.statusActive', 'Aktiv')}
|
||||||
|
isActive
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{packages
|
{packages
|
||||||
.filter((pkg) => !activePackage || pkg.id !== activePackage.id)
|
.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.title', 'Invoices & Payments')}
|
||||||
</Text>
|
</Text>
|
||||||
</XStack>
|
</XStack>
|
||||||
|
<Text fontSize="$xs" color="#6b7280">
|
||||||
|
{t('billing.sections.invoices.hint', 'Review transactions and download receipts.')}
|
||||||
|
</Text>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<Text fontSize="$sm" color="#6b7280">
|
<Text fontSize="$sm" color="#6b7280">
|
||||||
{t('common.loading', 'Lädt...')}
|
{t('common.loading', 'Lädt...')}
|
||||||
</Text>
|
</Text>
|
||||||
) : transactions.length === 0 ? (
|
) : transactions.length === 0 ? (
|
||||||
<Text fontSize="$sm" color="#4b5563">
|
<YStack space="$2">
|
||||||
{t('billing.sections.invoices.empty', 'Keine Zahlungen gefunden.')}
|
<Text fontSize="$sm" color="#4b5563">
|
||||||
</Text>
|
{t('billing.sections.invoices.empty', 'Keine Zahlungen gefunden.')}
|
||||||
|
</Text>
|
||||||
|
<CTAButton label={t('billing.actions.openPackages', 'Open packages')} onPress={scrollToPackages} />
|
||||||
|
<CTAButton label={t('billing.actions.contactSupport', 'Contact support')} tone="ghost" onPress={openSupport} />
|
||||||
|
</YStack>
|
||||||
) : (
|
) : (
|
||||||
<YStack space="$1.5">
|
<YStack space="$1.5">
|
||||||
{transactions.slice(0, 8).map((trx) => (
|
{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.title', 'Add-ons')}
|
||||||
</Text>
|
</Text>
|
||||||
</XStack>
|
</XStack>
|
||||||
|
<Text fontSize="$xs" color="#6b7280">
|
||||||
|
{t('billing.sections.addOns.hint', 'Track extra photo, guest, or time bundles per event.')}
|
||||||
|
</Text>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<Text fontSize="$sm" color="#6b7280">
|
<Text fontSize="$sm" color="#6b7280">
|
||||||
{t('common.loading', 'Lädt...')}
|
{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 { t } = useTranslation('management');
|
||||||
const remaining = pkg.remaining_events ?? (pkg.package_limits?.max_events_per_year as number | undefined) ?? 0;
|
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 expires = pkg.expires_at ? formatDate(pkg.expires_at) : null;
|
||||||
|
const usageMetrics = buildPackageUsageMetrics(pkg);
|
||||||
return (
|
return (
|
||||||
<MobileCard borderColor="#e5e7eb" space="$2">
|
<MobileCard borderColor={isActive ? '#2563eb' : '#e5e7eb'} borderWidth={isActive ? 2 : 1} backgroundColor={isActive ? '#eff6ff' : undefined} space="$2">
|
||||||
<XStack alignItems="center" justifyContent="space-between">
|
<XStack alignItems="center" justifyContent="space-between">
|
||||||
<Text fontSize="$md" fontWeight="800" color="#0f172a">
|
<Text fontSize="$md" fontWeight="800" color="#0f172a">
|
||||||
{pkg.package_name ?? t('mobileBilling.packageFallback', 'Package')}
|
{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, 'branding_allowed', t('billing.features.branding', 'Branding'))}
|
||||||
{renderFeatureBadge(pkg, t, 'watermark_allowed', t('billing.features.watermark', 'Watermark'))}
|
{renderFeatureBadge(pkg, t, 'watermark_allowed', t('billing.features.watermark', 'Watermark'))}
|
||||||
</XStack>
|
</XStack>
|
||||||
<YStack space="$1.5" marginTop="$2">
|
{usageMetrics.length ? (
|
||||||
<FeatureList pkg={pkg} />
|
<YStack space="$2" marginTop="$2">
|
||||||
</YStack>
|
{usageMetrics.map((metric) => (
|
||||||
|
<UsageBar key={metric.key} metric={metric} />
|
||||||
|
))}
|
||||||
|
</YStack>
|
||||||
|
) : null}
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -231,43 +293,45 @@ function renderFeatureBadge(pkg: TenantPackageSummary, t: any, key: string, labe
|
|||||||
return <PillBadge tone={enabled ? 'success' : 'muted'}>{enabled ? label : `${label} off`}</PillBadge>;
|
return <PillBadge tone={enabled ? 'success' : 'muted'}>{enabled ? label : `${label} off`}</PillBadge>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function FeatureList({ pkg }: { pkg: TenantPackageSummary }) {
|
function UsageBar({ metric }: { metric: PackageUsageMetric }) {
|
||||||
const { t } = useTranslation('management');
|
const { t } = useTranslation('management');
|
||||||
const limits = pkg.package_limits ?? {};
|
const labelMap: Record<PackageUsageMetric['key'], string> = {
|
||||||
const features = (pkg as any).features as string[] | undefined;
|
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 (!metric.limit) {
|
||||||
|
return null;
|
||||||
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 (!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 (
|
return (
|
||||||
<YStack space="$1">
|
<YStack space="$1.5">
|
||||||
{rows.map((row) => (
|
<XStack alignItems="center" justifyContent="space-between">
|
||||||
<XStack key={row.label} alignItems="center" justifyContent="space-between">
|
<Text fontSize="$xs" color="#6b7280">
|
||||||
<Text fontSize="$xs" color="#6b7280">
|
{labelMap[metric.key]}
|
||||||
{row.label}
|
</Text>
|
||||||
</Text>
|
<Text fontSize="$xs" color="#0f172a" fontWeight="700">
|
||||||
<Text fontSize="$xs" color="#0f172a" fontWeight="700">
|
{valueText}
|
||||||
{row.value}
|
</Text>
|
||||||
</Text>
|
</XStack>
|
||||||
</XStack>
|
<YStack height={6} borderRadius={999} backgroundColor="#e5e7eb" overflow="hidden">
|
||||||
))}
|
<YStack height="100%" width={`${fill}%`} backgroundColor={hasUsage ? '#2563eb' : '#94a3b8'} />
|
||||||
|
</YStack>
|
||||||
|
{remainingText ? (
|
||||||
|
<Text fontSize="$xs" color="#6b7280">
|
||||||
|
{remainingText}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
</YStack>
|
</YStack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -286,6 +350,7 @@ function formatAmount(value: number | null | undefined, currency: string | null
|
|||||||
|
|
||||||
function AddonRow({ addon }: { addon: TenantAddonHistoryEntry }) {
|
function AddonRow({ addon }: { addon: TenantAddonHistoryEntry }) {
|
||||||
const { t } = useTranslation('management');
|
const { t } = useTranslation('management');
|
||||||
|
const navigate = useNavigate();
|
||||||
const labels: Record<TenantAddonHistoryEntry['status'], { tone: 'success' | 'warning' | 'muted'; text: string }> = {
|
const labels: Record<TenantAddonHistoryEntry['status'], { tone: 'success' | 'warning' | 'muted'; text: string }> = {
|
||||||
completed: { tone: 'success', text: t('mobileBilling.status.completed', 'Completed') },
|
completed: { tone: 'success', text: t('mobileBilling.status.completed', 'Completed') },
|
||||||
pending: { tone: 'warning', text: t('mobileBilling.status.pending', 'Pending') },
|
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 === '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) ||
|
(addon.event?.name && typeof addon.event.name === 'object' ? addon.event.name?.en ?? addon.event.name?.de ?? Object.values(addon.event.name)[0] : null) ||
|
||||||
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 ? (
|
||||||
|
<XStack space="$2" marginTop="$1.5" flexWrap="wrap">
|
||||||
|
{addon.extra_photos ? (
|
||||||
|
<PillBadge tone="muted">{t('mobileBilling.extra.photos', '+{{count}} photos', { count: addon.extra_photos })}</PillBadge>
|
||||||
|
) : null}
|
||||||
|
{addon.extra_guests ? (
|
||||||
|
<PillBadge tone="muted">{t('mobileBilling.extra.guests', '+{{count}} guests', { count: addon.extra_guests })}</PillBadge>
|
||||||
|
) : null}
|
||||||
|
{addon.extra_gallery_days ? (
|
||||||
|
<PillBadge tone="muted">{t('mobileBilling.extra.days', '+{{count}} days', { count: addon.extra_gallery_days })}</PillBadge>
|
||||||
|
) : null}
|
||||||
|
</XStack>
|
||||||
|
) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MobileCard borderColor="#e5e7eb" padding="$3" space="$1.5">
|
<MobileCard borderColor="#e5e7eb" padding="$3" space="$1.5">
|
||||||
@@ -305,28 +385,31 @@ function AddonRow({ addon }: { addon: TenantAddonHistoryEntry }) {
|
|||||||
</Text>
|
</Text>
|
||||||
<PillBadge tone={status.tone}>{status.text}</PillBadge>
|
<PillBadge tone={status.tone}>{status.text}</PillBadge>
|
||||||
</XStack>
|
</XStack>
|
||||||
|
{eventName ? (
|
||||||
|
eventPath ? (
|
||||||
|
<Pressable onPress={() => navigate(eventPath)}>
|
||||||
|
<XStack alignItems="center" justifyContent="space-between">
|
||||||
|
<Text fontSize="$xs" color="#0f172a" fontWeight="600">
|
||||||
|
{eventName}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="$xs" color="#2563eb" fontWeight="700">
|
||||||
|
{t('mobileBilling.openEvent', 'Open event')}
|
||||||
|
</Text>
|
||||||
|
</XStack>
|
||||||
|
</Pressable>
|
||||||
|
) : (
|
||||||
|
<Text fontSize="$xs" color="#9ca3af">
|
||||||
|
{eventName}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
{impactBadges}
|
||||||
|
<Text fontSize="$sm" color="#0f172a" marginTop="$1.5">
|
||||||
|
{formatAmount(addon.amount, addon.currency)}
|
||||||
|
</Text>
|
||||||
<Text fontSize="$xs" color="#6b7280">
|
<Text fontSize="$xs" color="#6b7280">
|
||||||
{formatDate(addon.purchased_at)}
|
{formatDate(addon.purchased_at)}
|
||||||
</Text>
|
</Text>
|
||||||
{eventName ? (
|
|
||||||
<Text fontSize="$xs" color="#9ca3af">
|
|
||||||
{eventName}
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
<XStack space="$2" marginTop="$1">
|
|
||||||
{addon.extra_photos ? (
|
|
||||||
<PillBadge tone="muted">{t('mobileBilling.extra.photos', '+{{count}} photos', { count: addon.extra_photos })}</PillBadge>
|
|
||||||
) : null}
|
|
||||||
{addon.extra_guests ? (
|
|
||||||
<PillBadge tone="muted">{t('mobileBilling.extra.guests', '+{{count}} guests', { count: addon.extra_guests })}</PillBadge>
|
|
||||||
) : null}
|
|
||||||
{addon.extra_gallery_days ? (
|
|
||||||
<PillBadge tone="muted">{t('mobileBilling.extra.days', '+{{count}} days', { count: addon.extra_gallery_days })}</PillBadge>
|
|
||||||
) : null}
|
|
||||||
</XStack>
|
|
||||||
<Text fontSize="$sm" color="#0f172a" marginTop="$1">
|
|
||||||
{formatAmount(addon.amount, addon.currency)}
|
|
||||||
</Text>
|
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,11 +7,12 @@ import { SizableText as Text } from '@tamagui/text';
|
|||||||
import { Switch } from '@tamagui/switch';
|
import { Switch } from '@tamagui/switch';
|
||||||
import { MobileShell } from './components/MobileShell';
|
import { MobileShell } from './components/MobileShell';
|
||||||
import { MobileCard, CTAButton } from './components/Primitives';
|
import { MobileCard, CTAButton } from './components/Primitives';
|
||||||
|
import { LegalConsentSheet } from './components/LegalConsentSheet';
|
||||||
import { createEvent, getEvent, updateEvent, getEventTypes, TenantEvent, TenantEventType } from '../api';
|
import { createEvent, getEvent, updateEvent, getEventTypes, TenantEvent, TenantEventType } from '../api';
|
||||||
import { resolveEventSlugAfterUpdate } from './eventFormNavigation';
|
import { resolveEventSlugAfterUpdate } from './eventFormNavigation';
|
||||||
import { adminPath } from '../constants';
|
import { adminPath } from '../constants';
|
||||||
import { isAuthError } from '../auth/tokens';
|
import { isAuthError } from '../auth/tokens';
|
||||||
import { getApiValidationMessage } from '../lib/apiError';
|
import { getApiValidationMessage, isApiError } from '../lib/apiError';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
type FormState = {
|
type FormState = {
|
||||||
@@ -46,6 +47,9 @@ export default function MobileEventFormPage() {
|
|||||||
const [typesLoading, setTypesLoading] = React.useState(false);
|
const [typesLoading, setTypesLoading] = React.useState(false);
|
||||||
const [loading, setLoading] = React.useState(isEdit);
|
const [loading, setLoading] = React.useState(isEdit);
|
||||||
const [saving, setSaving] = React.useState(false);
|
const [saving, setSaving] = React.useState(false);
|
||||||
|
const [consentOpen, setConsentOpen] = React.useState(false);
|
||||||
|
const [consentBusy, setConsentBusy] = React.useState(false);
|
||||||
|
const [pendingPayload, setPendingPayload] = React.useState<Parameters<typeof createEvent>[0] | null>(null);
|
||||||
const [error, setError] = React.useState<string | null>(null);
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -99,24 +103,24 @@ export default function MobileEventFormPage() {
|
|||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
if (isEdit && slug) {
|
if (isEdit && slug) {
|
||||||
const updated = await updateEvent(slug, {
|
const updated = await updateEvent(slug, {
|
||||||
name: form.name,
|
name: form.name,
|
||||||
event_date: form.date || undefined,
|
event_date: form.date || undefined,
|
||||||
event_type_id: form.eventTypeId ?? undefined,
|
event_type_id: form.eventTypeId ?? undefined,
|
||||||
status: form.published ? 'published' : 'draft',
|
status: form.published ? 'published' : 'draft',
|
||||||
settings: {
|
settings: {
|
||||||
location: form.location,
|
location: form.location,
|
||||||
guest_upload_visibility: form.autoApproveUploads ? 'immediate' : 'review',
|
guest_upload_visibility: form.autoApproveUploads ? 'immediate' : 'review',
|
||||||
engagement_mode: form.tasksEnabled ? 'tasks' : 'photo_only',
|
engagement_mode: form.tasksEnabled ? 'tasks' : 'photo_only',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const nextSlug = resolveEventSlugAfterUpdate(slug, updated);
|
const nextSlug = resolveEventSlugAfterUpdate(slug, updated);
|
||||||
navigate(adminPath(`/mobile/events/${nextSlug}`));
|
navigate(adminPath(`/mobile/events/${nextSlug}`));
|
||||||
} else {
|
} else {
|
||||||
const payload = {
|
const payload = {
|
||||||
name: form.name || t('eventForm.fields.name.fallback', 'Event'),
|
name: form.name || t('eventForm.fields.name.fallback', 'Event'),
|
||||||
slug: `${Date.now()}`,
|
slug: `${Date.now()}`,
|
||||||
event_type_id: form.eventTypeId ?? undefined,
|
event_type_id: form.eventTypeId ?? undefined,
|
||||||
event_date: form.date || undefined,
|
event_date: form.date || undefined,
|
||||||
@@ -126,10 +130,56 @@ export default function MobileEventFormPage() {
|
|||||||
guest_upload_visibility: form.autoApproveUploads ? 'immediate' : 'review',
|
guest_upload_visibility: form.autoApproveUploads ? 'immediate' : 'review',
|
||||||
engagement_mode: form.tasksEnabled ? 'tasks' : 'photo_only',
|
engagement_mode: form.tasksEnabled ? 'tasks' : 'photo_only',
|
||||||
},
|
},
|
||||||
};
|
} as Parameters<typeof createEvent>[0];
|
||||||
const { event } = await createEvent(payload as any);
|
const { event } = await createEvent(payload);
|
||||||
navigate(adminPath(`/mobile/events/${event.slug}`));
|
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<typeof createEvent>[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) {
|
} catch (err) {
|
||||||
if (!isAuthError(err)) {
|
if (!isAuthError(err)) {
|
||||||
const message = getApiValidationMessage(err, t('eventForm.errors.saveFailed', 'Event could not be saved.'));
|
const message = getApiValidationMessage(err, t('eventForm.errors.saveFailed', 'Event could not be saved.'));
|
||||||
@@ -137,7 +187,7 @@ export default function MobileEventFormPage() {
|
|||||||
toast.error(message);
|
toast.error(message);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setConsentBusy(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,6 +381,37 @@ export default function MobileEventFormPage() {
|
|||||||
onPress={() => handleSubmit()}
|
onPress={() => handleSubmit()}
|
||||||
/>
|
/>
|
||||||
</YStack>
|
</YStack>
|
||||||
|
|
||||||
|
<LegalConsentSheet
|
||||||
|
open={consentOpen}
|
||||||
|
onClose={() => {
|
||||||
|
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}
|
||||||
|
/>
|
||||||
</MobileShell>
|
</MobileShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -364,6 +445,19 @@ function renderName(name: TenantEvent['name']): string {
|
|||||||
return '';
|
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 {
|
function toDateTimeLocal(value?: string | null): string {
|
||||||
if (!value) return '';
|
if (!value) return '';
|
||||||
|
|||||||
64
resources/js/admin/mobile/__tests__/billingUsage.test.ts
Normal file
64
resources/js/admin/mobile/__tests__/billingUsage.test.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
83
resources/js/admin/mobile/billingUsage.ts
Normal file
83
resources/js/admin/mobile/billingUsage.ts
Normal file
@@ -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<string, unknown> | 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)));
|
||||||
|
};
|
||||||
@@ -11,11 +11,31 @@ type LegalConsentSheetProps = {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onConfirm: (consents: { acceptedTerms: boolean; acceptedWaiver: boolean }) => Promise<void> | void;
|
onConfirm: (consents: { acceptedTerms: boolean; acceptedWaiver: boolean }) => Promise<void> | void;
|
||||||
busy?: boolean;
|
busy?: boolean;
|
||||||
|
requireTerms?: boolean;
|
||||||
requireWaiver?: boolean;
|
requireWaiver?: boolean;
|
||||||
|
copy?: {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
checkboxTerms?: string;
|
||||||
|
checkboxWaiver?: string;
|
||||||
|
errorTerms?: string;
|
||||||
|
errorWaiver?: string;
|
||||||
|
confirm?: string;
|
||||||
|
cancel?: string;
|
||||||
|
};
|
||||||
t: Translator;
|
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 [acceptedTerms, setAcceptedTerms] = React.useState(false);
|
||||||
const [acceptedWaiver, setAcceptedWaiver] = React.useState(false);
|
const [acceptedWaiver, setAcceptedWaiver] = React.useState(false);
|
||||||
const [error, setError] = React.useState<string | null>(null);
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
@@ -29,25 +49,28 @@ export function LegalConsentSheet({ open, onClose, onConfirm, busy = false, requ
|
|||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
async function handleConfirm() {
|
async function handleConfirm() {
|
||||||
if (!acceptedTerms) {
|
if (requireTerms && !acceptedTerms) {
|
||||||
setError(t('events.legalConsent.errorTerms', 'Please confirm the terms.'));
|
setError(copy?.errorTerms ?? t('events.legalConsent.errorTerms', 'Please confirm the terms.'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (requireWaiver && !acceptedWaiver) {
|
if (requireWaiver && !acceptedWaiver) {
|
||||||
setError(t('events.legalConsent.errorWaiver', 'Please confirm the waiver.'));
|
setError(copy?.errorWaiver ?? t('events.legalConsent.errorWaiver', 'Please confirm the waiver.'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setError(null);
|
setError(null);
|
||||||
await onConfirm({ acceptedTerms, acceptedWaiver: requireWaiver ? acceptedWaiver : true });
|
await onConfirm({
|
||||||
|
acceptedTerms: requireTerms ? acceptedTerms : true,
|
||||||
|
acceptedWaiver: requireWaiver ? acceptedWaiver : true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MobileSheet
|
<MobileSheet
|
||||||
open={open}
|
open={open}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
title={t('events.legalConsent.title', 'Before purchase')}
|
title={copy?.title ?? t('events.legalConsent.title', 'Before purchase')}
|
||||||
footer={
|
footer={
|
||||||
<YStack space="$2">
|
<YStack space="$2">
|
||||||
{error ? (
|
{error ? (
|
||||||
@@ -55,29 +78,41 @@ export function LegalConsentSheet({ open, onClose, onConfirm, busy = false, requ
|
|||||||
{error}
|
{error}
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
<CTAButton label={t('events.legalConsent.confirm', 'Continue to checkout')} onPress={handleConfirm} loading={busy} disabled={busy} />
|
<CTAButton
|
||||||
<CTAButton label={t('events.legalConsent.cancel', 'Cancel')} tone="ghost" onPress={onClose} disabled={busy} />
|
label={copy?.confirm ?? t('events.legalConsent.confirm', 'Continue to checkout')}
|
||||||
|
onPress={handleConfirm}
|
||||||
|
loading={busy}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
<CTAButton
|
||||||
|
label={copy?.cancel ?? t('events.legalConsent.cancel', 'Cancel')}
|
||||||
|
tone="ghost"
|
||||||
|
onPress={onClose}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
</YStack>
|
</YStack>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<YStack space="$2">
|
<YStack space="$2">
|
||||||
<Text fontSize="$sm" color="#111827">
|
<Text fontSize="$sm" color="#111827">
|
||||||
{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.')}
|
||||||
</Text>
|
</Text>
|
||||||
<label style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
|
{requireTerms ? (
|
||||||
<input
|
<label style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
|
||||||
type="checkbox"
|
<input
|
||||||
checked={acceptedTerms}
|
type="checkbox"
|
||||||
onChange={(event) => setAcceptedTerms(event.target.checked)}
|
checked={acceptedTerms}
|
||||||
style={{ marginTop: 4, width: 16, height: 16 }}
|
onChange={(event) => setAcceptedTerms(event.target.checked)}
|
||||||
/>
|
style={{ marginTop: 4, width: 16, height: 16 }}
|
||||||
<Text fontSize="$sm" color="#111827">
|
/>
|
||||||
{t(
|
<Text fontSize="$sm" color="#111827">
|
||||||
'events.legalConsent.checkboxTerms',
|
{copy?.checkboxTerms ?? t(
|
||||||
'I have read and accept the Terms & Conditions, Privacy Policy, and Right of Withdrawal.',
|
'events.legalConsent.checkboxTerms',
|
||||||
)}
|
'I have read and accept the Terms & Conditions, Privacy Policy, and Right of Withdrawal.',
|
||||||
</Text>
|
)}
|
||||||
</label>
|
</Text>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
{requireWaiver ? (
|
{requireWaiver ? (
|
||||||
<label style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
|
<label style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
|
||||||
<input
|
<input
|
||||||
@@ -87,7 +122,7 @@ export function LegalConsentSheet({ open, onClose, onConfirm, busy = false, requ
|
|||||||
style={{ marginTop: 4, width: 16, height: 16 }}
|
style={{ marginTop: 4, width: 16, height: 16 }}
|
||||||
/>
|
/>
|
||||||
<Text fontSize="$sm" color="#111827">
|
<Text fontSize="$sm" color="#111827">
|
||||||
{t(
|
{copy?.checkboxWaiver ?? t(
|
||||||
'events.legalConsent.checkboxWaiver',
|
'events.legalConsent.checkboxWaiver',
|
||||||
'I expressly request immediate provision of the digital service and understand my right of withdrawal expires once fulfilled.',
|
'I expressly request immediate provision of the digital service and understand my right of withdrawal expires once fulfilled.',
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -73,16 +73,29 @@ export function CTAButton({
|
|||||||
onPress,
|
onPress,
|
||||||
tone = 'primary',
|
tone = 'primary',
|
||||||
fullWidth = true,
|
fullWidth = true,
|
||||||
|
disabled = false,
|
||||||
|
loading = false,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
tone?: 'primary' | 'ghost';
|
tone?: 'primary' | 'ghost';
|
||||||
fullWidth?: boolean;
|
fullWidth?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
loading?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const isPrimary = tone === 'primary';
|
const isPrimary = tone === 'primary';
|
||||||
|
const isDisabled = disabled || loading;
|
||||||
return (
|
return (
|
||||||
<Pressable onPress={onPress} style={{ width: fullWidth ? '100%' : undefined, flex: fullWidth ? undefined : 1 }}>
|
<Pressable
|
||||||
|
onPress={isDisabled ? undefined : onPress}
|
||||||
|
disabled={isDisabled}
|
||||||
|
style={{
|
||||||
|
width: fullWidth ? '100%' : undefined,
|
||||||
|
flex: fullWidth ? undefined : 1,
|
||||||
|
opacity: isDisabled ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<XStack
|
<XStack
|
||||||
height={56}
|
height={56}
|
||||||
borderRadius={14}
|
borderRadius={14}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { cleanup, render, screen } from '@testing-library/react';
|
||||||
|
import { CheckoutWizardProvider } from '../WizardContext';
|
||||||
|
import { PaymentStep } from '../steps/PaymentStep';
|
||||||
|
|
||||||
|
const basePackage = {
|
||||||
|
id: 1,
|
||||||
|
price: 49,
|
||||||
|
paddle_price_id: 'pri_test_123',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('PaymentStep', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
window.Paddle = {
|
||||||
|
Environment: { set: vi.fn() },
|
||||||
|
Checkout: { open: vi.fn() },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
delete window.Paddle;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the payment experience without crashing', async () => {
|
||||||
|
render(
|
||||||
|
<CheckoutWizardProvider initialPackage={basePackage} packageOptions={[basePackage]}>
|
||||||
|
<PaymentStep />
|
||||||
|
</CheckoutWizardProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await screen.findByText('checkout.payment_step.guided_title')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('checkout.legal.checkbox_digital_content_label')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -120,13 +120,12 @@ const PaddleCta: React.FC<{ onCheckout: () => Promise<void>; disabled: boolean;
|
|||||||
|
|
||||||
export const PaymentStep: React.FC = () => {
|
export const PaymentStep: React.FC = () => {
|
||||||
const { t, i18n } = useTranslation('marketing');
|
const { t, i18n } = useTranslation('marketing');
|
||||||
const { selectedPackage, nextStep, paddleConfig, authUser, setPaymentCompleted } = useCheckoutWizard();
|
const { selectedPackage, nextStep, paddleConfig, authUser, paymentCompleted, setPaymentCompleted } = useCheckoutWizard();
|
||||||
const [status, setStatus] = useState<PaymentStatus>('idle');
|
const [status, setStatus] = useState<PaymentStatus>('idle');
|
||||||
const [message, setMessage] = useState<string>('');
|
const [message, setMessage] = useState<string>('');
|
||||||
const [initialised, setInitialised] = useState(false);
|
const [initialised, setInitialised] = useState(false);
|
||||||
const [inlineActive, setInlineActive] = useState(false);
|
const [inlineActive, setInlineActive] = useState(false);
|
||||||
const [acceptedTerms, setAcceptedTerms] = useState(false);
|
const [acceptedTerms, setAcceptedTerms] = useState(false);
|
||||||
const [acceptedWaiver, setAcceptedWaiver] = useState(false);
|
|
||||||
const [consentError, setConsentError] = useState<string | null>(null);
|
const [consentError, setConsentError] = useState<string | null>(null);
|
||||||
const [couponCode, setCouponCode] = useState<string>(() => {
|
const [couponCode, setCouponCode] = useState<string>(() => {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
@@ -171,7 +170,6 @@ export const PaymentStep: React.FC = () => {
|
|||||||
}, [i18n.language]);
|
}, [i18n.language]);
|
||||||
|
|
||||||
const isFree = useMemo(() => (selectedPackage ? Number(selectedPackage.price) <= 0 : false), [selectedPackage]);
|
const isFree = useMemo(() => (selectedPackage ? Number(selectedPackage.price) <= 0 : false), [selectedPackage]);
|
||||||
const requiresImmediateWaiver = useMemo(() => Boolean(selectedPackage?.activates_immediately), [selectedPackage]);
|
|
||||||
|
|
||||||
const applyCoupon = useCallback(async (code: string) => {
|
const applyCoupon = useCallback(async (code: string) => {
|
||||||
if (!selectedPackage) {
|
if (!selectedPackage) {
|
||||||
@@ -263,7 +261,7 @@ export const PaymentStep: React.FC = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!acceptedTerms || (requiresImmediateWaiver && !acceptedWaiver)) {
|
if (!acceptedTerms) {
|
||||||
setConsentError(t('checkout.legal.checkbox_terms_error'));
|
setConsentError(t('checkout.legal.checkbox_terms_error'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -285,7 +283,6 @@ export const PaymentStep: React.FC = () => {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
package_id: selectedPackage.id,
|
package_id: selectedPackage.id,
|
||||||
accepted_terms: acceptedTerms,
|
accepted_terms: acceptedTerms,
|
||||||
accepted_waiver: requiresImmediateWaiver ? acceptedWaiver : false,
|
|
||||||
locale: paddleLocale,
|
locale: paddleLocale,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -317,7 +314,7 @@ export const PaymentStep: React.FC = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!acceptedTerms || (requiresImmediateWaiver && !acceptedWaiver)) {
|
if (!acceptedTerms) {
|
||||||
setConsentError(t('checkout.legal.checkbox_terms_error'));
|
setConsentError(t('checkout.legal.checkbox_terms_error'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -362,7 +359,6 @@ export const PaymentStep: React.FC = () => {
|
|||||||
locale: paddleLocale,
|
locale: paddleLocale,
|
||||||
coupon_code: couponPreview?.coupon.code ?? undefined,
|
coupon_code: couponPreview?.coupon.code ?? undefined,
|
||||||
accepted_terms: acceptedTerms,
|
accepted_terms: acceptedTerms,
|
||||||
accepted_waiver: requiresImmediateWaiver ? acceptedWaiver : false,
|
|
||||||
inline: inlineSupported,
|
inline: inlineSupported,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -792,29 +788,6 @@ export const PaymentStep: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{requiresImmediateWaiver && (
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<Checkbox
|
|
||||||
id="checkout-waiver-free"
|
|
||||||
checked={acceptedWaiver}
|
|
||||||
onCheckedChange={(checked) => {
|
|
||||||
setAcceptedWaiver(Boolean(checked));
|
|
||||||
if (consentError) {
|
|
||||||
setConsentError(null);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="space-y-1 text-sm">
|
|
||||||
<Label htmlFor="checkout-waiver-free" className="cursor-pointer">
|
|
||||||
{t('checkout.legal.checkbox_digital_content_label')}
|
|
||||||
</Label>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{t('checkout.legal.hint_subscription_withdrawal')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{consentError && (
|
{consentError && (
|
||||||
<div className="flex items-center gap-2 text-sm text-destructive">
|
<div className="flex items-center gap-2 text-sm text-destructive">
|
||||||
<XCircle className="h-4 w-4" />
|
<XCircle className="h-4 w-4" />
|
||||||
@@ -827,7 +800,7 @@ export const PaymentStep: React.FC = () => {
|
|||||||
<Button
|
<Button
|
||||||
size="lg"
|
size="lg"
|
||||||
onClick={handleFreeActivation}
|
onClick={handleFreeActivation}
|
||||||
disabled={freeActivationBusy || !acceptedTerms || (requiresImmediateWaiver && !acceptedWaiver)}
|
disabled={freeActivationBusy || !acceptedTerms}
|
||||||
>
|
>
|
||||||
{freeActivationBusy && <LoaderCircle className="mr-2 h-4 w-4 animate-spin" />}
|
{freeActivationBusy && <LoaderCircle className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
{t('checkout.payment_step.activate_package')}
|
{t('checkout.payment_step.activate_package')}
|
||||||
@@ -940,30 +913,6 @@ export const PaymentStep: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{requiresImmediateWaiver && (
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<Checkbox
|
|
||||||
id="checkout-waiver-hero"
|
|
||||||
checked={acceptedWaiver}
|
|
||||||
onCheckedChange={(checked) => {
|
|
||||||
setAcceptedWaiver(Boolean(checked));
|
|
||||||
if (consentError) {
|
|
||||||
setConsentError(null);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="border-white/60 data-[state=checked]:bg-white data-[state=checked]:text-[#001835]"
|
|
||||||
/>
|
|
||||||
<div className="space-y-1 text-sm">
|
|
||||||
<Label htmlFor="checkout-waiver-hero" className="cursor-pointer text-white">
|
|
||||||
{t('checkout.legal.checkbox_digital_content_label')}
|
|
||||||
</Label>
|
|
||||||
<p className="text-xs text-white/80">
|
|
||||||
{t('checkout.legal.hint_subscription_withdrawal')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{consentError && (
|
{consentError && (
|
||||||
<div className="flex items-center gap-2 text-sm text-red-200">
|
<div className="flex items-center gap-2 text-sm text-red-200">
|
||||||
<XCircle className="h-4 w-4" />
|
<XCircle className="h-4 w-4" />
|
||||||
@@ -975,7 +924,7 @@ export const PaymentStep: React.FC = () => {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<PaddleCta
|
<PaddleCta
|
||||||
onCheckout={startPaddleCheckout}
|
onCheckout={startPaddleCheckout}
|
||||||
disabled={status === 'processing' || !acceptedTerms || (requiresImmediateWaiver && !acceptedWaiver)}
|
disabled={status === 'processing' || !acceptedTerms}
|
||||||
isProcessing={status === 'processing'}
|
isProcessing={status === 'processing'}
|
||||||
className={cn('bg-white text-[#001835] hover:bg-white/90', PRIMARY_CTA_STYLES)}
|
className={cn('bg-white text-[#001835] hover:bg-white/90', PRIMARY_CTA_STYLES)}
|
||||||
/>
|
/>
|
||||||
@@ -1070,7 +1019,7 @@ export const PaymentStep: React.FC = () => {
|
|||||||
</p>
|
</p>
|
||||||
<PaddleCta
|
<PaddleCta
|
||||||
onCheckout={startPaddleCheckout}
|
onCheckout={startPaddleCheckout}
|
||||||
disabled={status === 'processing' || !acceptedTerms || (requiresImmediateWaiver && !acceptedWaiver)}
|
disabled={status === 'processing' || !acceptedTerms}
|
||||||
isProcessing={status === 'processing'}
|
isProcessing={status === 'processing'}
|
||||||
className={PRIMARY_CTA_STYLES}
|
className={PRIMARY_CTA_STYLES}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -316,11 +316,14 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
|
|||||||
->name('tenant.billing.transactions');
|
->name('tenant.billing.transactions');
|
||||||
Route::get('addons', [TenantBillingController::class, 'addons'])
|
Route::get('addons', [TenantBillingController::class, 'addons'])
|
||||||
->name('tenant.billing.addons');
|
->name('tenant.billing.addons');
|
||||||
|
Route::post('portal', [TenantBillingController::class, 'portal'])
|
||||||
|
->name('tenant.billing.portal');
|
||||||
});
|
});
|
||||||
|
|
||||||
Route::prefix('tenant/billing')->middleware('tenant.admin')->group(function () {
|
Route::prefix('tenant/billing')->middleware('tenant.admin')->group(function () {
|
||||||
Route::get('transactions', [TenantBillingController::class, 'transactions']);
|
Route::get('transactions', [TenantBillingController::class, 'transactions']);
|
||||||
Route::get('addons', [TenantBillingController::class, 'addons']);
|
Route::get('addons', [TenantBillingController::class, 'addons']);
|
||||||
|
Route::post('portal', [TenantBillingController::class, 'portal']);
|
||||||
});
|
});
|
||||||
|
|
||||||
Route::post('feedback', [TenantFeedbackController::class, 'store'])
|
Route::post('feedback', [TenantFeedbackController::class, 'store'])
|
||||||
|
|||||||
39
tests/Feature/Api/Tenant/BillingPortalTest.php
Normal file
39
tests/Feature/Api/Tenant/BillingPortalTest.php
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Api\Tenant;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Tests\Feature\Tenant\TenantTestCase;
|
||||||
|
|
||||||
|
class BillingPortalTest extends TenantTestCase
|
||||||
|
{
|
||||||
|
public function test_tenant_can_create_paddle_portal_session(): void
|
||||||
|
{
|
||||||
|
Http::fake([
|
||||||
|
'*paddle.com/customers' => Http::response([
|
||||||
|
'data' => ['id' => 'cus_123'],
|
||||||
|
], 200),
|
||||||
|
'*paddle.com/customer-portal-sessions' => Http::response([
|
||||||
|
'data' => [
|
||||||
|
'urls' => [
|
||||||
|
'general' => [
|
||||||
|
'overview' => 'https://portal.example/overview',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
], 200),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->tenant->forceFill(['paddle_customer_id' => null])->save();
|
||||||
|
|
||||||
|
$response = $this->authenticatedRequest('POST', '/api/v1/tenant/billing/portal');
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$response->assertJsonPath('url', 'https://portal.example/overview');
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('tenants', [
|
||||||
|
'id' => $this->tenant->id,
|
||||||
|
'paddle_customer_id' => 'cus_123',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,7 +36,6 @@ class CheckoutFreeActivationTest extends TestCase
|
|||||||
$response = $this->postJson(route('checkout.free-activate'), [
|
$response = $this->postJson(route('checkout.free-activate'), [
|
||||||
'package_id' => $package->id,
|
'package_id' => $package->id,
|
||||||
'accepted_terms' => true,
|
'accepted_terms' => true,
|
||||||
'accepted_waiver' => true,
|
|
||||||
'locale' => 'de',
|
'locale' => 'de',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -63,7 +62,7 @@ class CheckoutFreeActivationTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_free_checkout_requires_waiver_when_package_activates_immediately(): void
|
public function test_free_checkout_does_not_require_waiver_before_first_use(): void
|
||||||
{
|
{
|
||||||
$tenant = Tenant::factory()->create();
|
$tenant = Tenant::factory()->create();
|
||||||
$user = User::factory()->for($tenant)->create();
|
$user = User::factory()->for($tenant)->create();
|
||||||
@@ -76,14 +75,13 @@ class CheckoutFreeActivationTest extends TestCase
|
|||||||
$response = $this->postJson(route('checkout.free-activate'), [
|
$response = $this->postJson(route('checkout.free-activate'), [
|
||||||
'package_id' => $package->id,
|
'package_id' => $package->id,
|
||||||
'accepted_terms' => true,
|
'accepted_terms' => true,
|
||||||
'accepted_waiver' => false,
|
|
||||||
'locale' => 'de',
|
'locale' => 'de',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$response->assertStatus(422)
|
$response->assertOk()
|
||||||
->assertJsonValidationErrors(['accepted_waiver']);
|
->assertJsonPath('status', 'completed');
|
||||||
|
|
||||||
$this->assertDatabaseMissing('package_purchases', [
|
$this->assertDatabaseHas('package_purchases', [
|
||||||
'tenant_id' => $tenant->id,
|
'tenant_id' => $tenant->id,
|
||||||
'package_id' => $package->id,
|
'package_id' => $package->id,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -4,40 +4,51 @@ namespace Tests\Feature;
|
|||||||
|
|
||||||
use App\Models\Event;
|
use App\Models\Event;
|
||||||
use App\Models\EventPackage;
|
use App\Models\EventPackage;
|
||||||
|
use App\Models\EventType;
|
||||||
use App\Models\Package;
|
use App\Models\Package;
|
||||||
use App\Models\Tenant;
|
use App\Models\PackagePurchase;
|
||||||
use App\Models\TenantPackage;
|
use App\Models\TenantPackage;
|
||||||
use App\Models\User;
|
|
||||||
use App\Services\EventJoinTokenService;
|
use App\Services\EventJoinTokenService;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
use Illuminate\Http\UploadedFile;
|
use Illuminate\Http\UploadedFile;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Tests\TestCase;
|
use Tests\Feature\Tenant\TenantTestCase;
|
||||||
|
|
||||||
class EventControllerTest extends TestCase
|
class EventControllerTest extends TenantTestCase
|
||||||
{
|
{
|
||||||
use RefreshDatabase;
|
|
||||||
|
|
||||||
public function test_create_event_with_valid_package_succeeds(): void
|
public function test_create_event_with_valid_package_succeeds(): void
|
||||||
{
|
{
|
||||||
$tenant = Tenant::factory()->create();
|
$tenant = $this->tenant;
|
||||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
$eventType = EventType::factory()->create();
|
||||||
$package = Package::factory()->create(['type' => 'endcustomer', 'max_photos' => 100]);
|
$package = Package::factory()->create(['type' => 'endcustomer', 'max_photos' => 100]);
|
||||||
|
TenantPackage::factory()->create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'package_id' => $package->id,
|
||||||
|
'active' => true,
|
||||||
|
]);
|
||||||
|
$purchase = PackagePurchase::factory()->create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'package_id' => $package->id,
|
||||||
|
'type' => 'endcustomer_event',
|
||||||
|
'metadata' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
$response = $this->actingAs($user)
|
$response = $this->authenticatedRequest('POST', '/api/v1/tenant/events', [
|
||||||
->postJson('/api/v1/tenant/events', [
|
'name' => 'Test Event',
|
||||||
'name' => 'Test Event',
|
'slug' => 'test-event',
|
||||||
'slug' => 'test-event',
|
'event_date' => Carbon::now()->addDays(10)->toDateString(),
|
||||||
'date' => '2025-10-01',
|
'event_type_id' => $eventType->id,
|
||||||
'package_id' => $package->id,
|
'package_id' => $package->id,
|
||||||
]);
|
'accepted_waiver' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
$response->assertStatus(201);
|
$response->assertStatus(201);
|
||||||
|
|
||||||
$this->assertDatabaseHas('events', [
|
$this->assertDatabaseHas('events', [
|
||||||
'tenant_id' => $tenant->id,
|
'tenant_id' => $tenant->id,
|
||||||
'name' => 'Test Event',
|
'name' => json_encode('Test Event'),
|
||||||
'slug' => 'test-event',
|
'slug' => 'test-event',
|
||||||
|
'event_type_id' => $eventType->id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$event = Event::latest()->first();
|
$event = Event::latest()->first();
|
||||||
@@ -50,35 +61,50 @@ class EventControllerTest extends TestCase
|
|||||||
'event_id' => $event->id,
|
'event_id' => $event->id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->assertDatabaseHas('package_purchases', [
|
$purchase->refresh();
|
||||||
'event_id' => $event->id,
|
$this->assertNotNull(data_get($purchase->metadata, 'consents.digital_content_waiver_at'));
|
||||||
'package_id' => $package->id,
|
|
||||||
'type' => 'endcustomer_event',
|
|
||||||
'provider' => 'manual',
|
|
||||||
'provider_id' => 'manual',
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_create_event_without_package_fails(): void
|
public function test_create_event_without_package_fails(): void
|
||||||
{
|
{
|
||||||
$tenant = Tenant::factory()->create();
|
$response = $this->authenticatedRequest('POST', '/api/v1/tenant/events', [
|
||||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
'name' => 'Test Event',
|
||||||
|
'slug' => 'test-event',
|
||||||
|
'event_date' => Carbon::now()->addDays(10)->toDateString(),
|
||||||
|
]);
|
||||||
|
|
||||||
$response = $this->actingAs($user)
|
$response->assertStatus(402)
|
||||||
->postJson('/api/v1/tenant/events', [
|
->assertJsonPath('error.code', 'event_limit_missing');
|
||||||
'name' => 'Test Event',
|
}
|
||||||
'slug' => 'test-event',
|
|
||||||
'date' => '2025-10-01',
|
public function test_create_event_requires_waiver_for_endcustomer_package(): void
|
||||||
]);
|
{
|
||||||
|
$tenant = $this->tenant;
|
||||||
|
$eventType = EventType::factory()->create();
|
||||||
|
$package = Package::factory()->create(['type' => 'endcustomer', 'max_photos' => 100]);
|
||||||
|
TenantPackage::factory()->create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'package_id' => $package->id,
|
||||||
|
'active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->authenticatedRequest('POST', '/api/v1/tenant/events', [
|
||||||
|
'name' => 'Test Event',
|
||||||
|
'slug' => 'test-event',
|
||||||
|
'event_date' => Carbon::now()->addDays(10)->toDateString(),
|
||||||
|
'event_type_id' => $eventType->id,
|
||||||
|
'package_id' => $package->id,
|
||||||
|
'accepted_waiver' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
$response->assertStatus(422)
|
$response->assertStatus(422)
|
||||||
->assertJsonValidationErrors(['package_id']);
|
->assertJsonValidationErrors(['accepted_waiver']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_create_event_with_reseller_package_limits_events(): void
|
public function test_create_event_with_reseller_package_limits_events(): void
|
||||||
{
|
{
|
||||||
$tenant = Tenant::factory()->create();
|
$tenant = $this->tenant;
|
||||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
$eventType = EventType::factory()->create();
|
||||||
$package = Package::factory()->create(['type' => 'reseller', 'max_events_per_year' => 1]);
|
$package = Package::factory()->create(['type' => 'reseller', 'max_events_per_year' => 1]);
|
||||||
TenantPackage::factory()->create([
|
TenantPackage::factory()->create([
|
||||||
'tenant_id' => $tenant->id,
|
'tenant_id' => $tenant->id,
|
||||||
@@ -89,37 +115,38 @@ class EventControllerTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// First event succeeds
|
// First event succeeds
|
||||||
$response1 = $this->actingAs($user)
|
$response1 = $this->authenticatedRequest('POST', '/api/v1/tenant/events', [
|
||||||
->postJson('/api/v1/tenant/events', [
|
'name' => 'First Event',
|
||||||
'name' => 'First Event',
|
'slug' => 'first-event',
|
||||||
'slug' => 'first-event',
|
'event_date' => Carbon::now()->addDays(10)->toDateString(),
|
||||||
'date' => '2025-10-01',
|
'event_type_id' => $eventType->id,
|
||||||
'package_id' => $package->id, // Use reseller package for event? Adjust if needed
|
'package_id' => $package->id, // Use reseller package for event? Adjust if needed
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$response1->assertStatus(201);
|
$response1->assertStatus(201);
|
||||||
|
|
||||||
// Second event fails due to limit
|
// Second event fails due to limit
|
||||||
$response2 = $this->actingAs($user)
|
$response2 = $this->authenticatedRequest('POST', '/api/v1/tenant/events', [
|
||||||
->postJson('/api/v1/tenant/events', [
|
'name' => 'Second Event',
|
||||||
'name' => 'Second Event',
|
'slug' => 'second-event',
|
||||||
'slug' => 'second-event',
|
'event_date' => Carbon::now()->addDays(11)->toDateString(),
|
||||||
'date' => '2025-10-02',
|
'event_type_id' => $eventType->id,
|
||||||
'package_id' => $package->id,
|
'package_id' => $package->id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$response2->assertStatus(402)
|
$response2->assertStatus(402)
|
||||||
->assertJson(['error' => 'No available package for event creation']);
|
->assertJsonPath('error.code', 'event_limit_exceeded');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_upload_exceeds_package_limit_fails(): void
|
public function test_upload_exceeds_package_limit_fails(): void
|
||||||
{
|
{
|
||||||
$tenant = Tenant::factory()->create();
|
$tenant = $this->tenant;
|
||||||
$event = Event::factory()->create(['tenant_id' => $tenant->id, 'status' => 'published']);
|
$event = Event::factory()->create(['tenant_id' => $tenant->id, 'status' => 'published']);
|
||||||
$package = Package::factory()->create(['type' => 'endcustomer', 'max_photos' => 0]); // Limit 0
|
$package = Package::factory()->create(['type' => 'endcustomer', 'max_photos' => 0]); // Limit 0
|
||||||
EventPackage::factory()->create([
|
EventPackage::create([
|
||||||
'event_id' => $event->id,
|
'event_id' => $event->id,
|
||||||
'package_id' => $package->id,
|
'package_id' => $package->id,
|
||||||
|
'purchased_price' => $package->price,
|
||||||
'used_photos' => 0,
|
'used_photos' => 0,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -81,7 +81,6 @@ class PaddleCheckoutControllerTest extends TestCase
|
|||||||
'package_id' => $package->id,
|
'package_id' => $package->id,
|
||||||
'coupon_code' => 'SAVE15',
|
'coupon_code' => 'SAVE15',
|
||||||
'accepted_terms' => true,
|
'accepted_terms' => true,
|
||||||
'accepted_waiver' => true,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$response->assertOk()
|
$response->assertOk()
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ class EventManagementTest extends TenantTestCase
|
|||||||
'event_type_id' => $eventType->id,
|
'event_type_id' => $eventType->id,
|
||||||
'event_date' => Carbon::now()->addDays(10)->toDateString(),
|
'event_date' => Carbon::now()->addDays(10)->toDateString(),
|
||||||
'status' => 'draft',
|
'status' => 'draft',
|
||||||
|
'accepted_waiver' => true,
|
||||||
];
|
];
|
||||||
|
|
||||||
$response = $this->authenticatedRequest('POST', '/api/v1/tenant/events', $payload);
|
$response = $this->authenticatedRequest('POST', '/api/v1/tenant/events', $payload);
|
||||||
|
|||||||
@@ -164,11 +164,6 @@ async function acceptCheckoutTerms(page: import('@playwright/test').Page) {
|
|||||||
const termsCheckbox = page.locator('#checkout-terms-hero');
|
const termsCheckbox = page.locator('#checkout-terms-hero');
|
||||||
await expect(termsCheckbox).toBeVisible();
|
await expect(termsCheckbox).toBeVisible();
|
||||||
await termsCheckbox.click();
|
await termsCheckbox.click();
|
||||||
|
|
||||||
const waiverCheckbox = page.locator('#checkout-waiver-hero');
|
|
||||||
if (await waiverCheckbox.isVisible()) {
|
|
||||||
await waiverCheckbox.click();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|||||||
@@ -44,11 +44,6 @@ test.describe('Paddle sandbox full flow (staging)', () => {
|
|||||||
await expect(termsCheckbox).toBeVisible();
|
await expect(termsCheckbox).toBeVisible();
|
||||||
await termsCheckbox.click();
|
await termsCheckbox.click();
|
||||||
|
|
||||||
const waiverCheckbox = page.locator('#checkout-waiver-hero');
|
|
||||||
if (await waiverCheckbox.isVisible()) {
|
|
||||||
await waiverCheckbox.click();
|
|
||||||
}
|
|
||||||
|
|
||||||
const checkoutCta = page.getByRole('button', { name: /Weiter mit Paddle|Continue with Paddle/i }).first();
|
const checkoutCta = page.getByRole('button', { name: /Weiter mit Paddle|Continue with Paddle/i }).first();
|
||||||
await expect(checkoutCta).toBeVisible({ timeout: 20000 });
|
await expect(checkoutCta).toBeVisible({ timeout: 20000 });
|
||||||
|
|
||||||
|
|||||||
@@ -103,11 +103,6 @@ test.describe('Standard package checkout with Paddle completion', () => {
|
|||||||
await expect(termsCheckbox).toBeVisible();
|
await expect(termsCheckbox).toBeVisible();
|
||||||
await termsCheckbox.click();
|
await termsCheckbox.click();
|
||||||
|
|
||||||
const waiverCheckbox = page.locator('#checkout-waiver-hero');
|
|
||||||
if (await waiverCheckbox.isVisible()) {
|
|
||||||
await waiverCheckbox.click();
|
|
||||||
}
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: /Weiter mit Paddle|Continue with Paddle/i }).first().click();
|
await page.getByRole('button', { name: /Weiter mit Paddle|Continue with Paddle/i }).first().click();
|
||||||
|
|
||||||
let checkoutMode: 'inline' | 'hosted' | null = null;
|
let checkoutMode: 'inline' | 'hosted' | null = null;
|
||||||
|
|||||||
Reference in New Issue
Block a user