Fix PayPal billing flow and mobile admin UX
This commit is contained in:
128
AGENTS.md
128
AGENTS.md
@@ -623,6 +623,134 @@ export default () => (
|
||||
| overflow-ellipsis | text-ellipsis |
|
||||
| decoration-slice | box-decoration-slice |
|
||||
| decoration-clone | box-decoration-clone |
|
||||
|
||||
=== filament/filament rules ===
|
||||
|
||||
## Filament
|
||||
|
||||
- Filament is used by this application. Follow existing conventions for how and where it's implemented.
|
||||
- Filament is a Server-Driven UI (SDUI) framework for Laravel that lets you define user interfaces in PHP using structured configuration objects. Built on Livewire, Alpine.js, and Tailwind CSS.
|
||||
- Use the `search-docs` tool for official documentation on Artisan commands, code examples, testing, relationships, and idiomatic practices.
|
||||
|
||||
### Artisan
|
||||
|
||||
- Use Filament-specific Artisan commands to create files. Find them with `list-artisan-commands` or `php artisan --help`.
|
||||
- Inspect required options and always pass `--no-interaction`.
|
||||
|
||||
### Patterns
|
||||
|
||||
Use static `make()` methods to initialize components. Most configuration methods accept a `Closure` for dynamic values.
|
||||
|
||||
Use `Get $get` to read other form field values for conditional logic:
|
||||
|
||||
<code-snippet name="Conditional form field" lang="php">
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Schemas\Components\Utilities\Get;
|
||||
|
||||
Select::make('type')
|
||||
->options(CompanyType::class)
|
||||
->required()
|
||||
->live(),
|
||||
|
||||
TextInput::make('company_name')
|
||||
->required()
|
||||
->visible(fn (Get $get): bool => $get('type') === 'business'),
|
||||
</code-snippet>
|
||||
|
||||
Use `state()` with a `Closure` to compute derived column values:
|
||||
|
||||
<code-snippet name="Computed table column" lang="php">
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
|
||||
TextColumn::make('full_name')
|
||||
->state(fn (User $record): string => "{$record->first_name} {$record->last_name}"),
|
||||
</code-snippet>
|
||||
|
||||
Actions encapsulate a button with optional modal form and logic:
|
||||
|
||||
<code-snippet name="Action with modal form" lang="php">
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
|
||||
Action::make('updateEmail')
|
||||
->form([
|
||||
TextInput::make('email')->email()->required(),
|
||||
])
|
||||
->action(fn (array $data, User $record): void => $record->update($data)),
|
||||
</code-snippet>
|
||||
|
||||
### Testing
|
||||
|
||||
Authenticate before testing panel functionality. Filament uses Livewire, so use `livewire()` or `Livewire::test()`:
|
||||
|
||||
<code-snippet name="Filament Table Test" lang="php">
|
||||
livewire(ListUsers::class)
|
||||
->assertCanSeeTableRecords($users)
|
||||
->searchTable($users->first()->name)
|
||||
->assertCanSeeTableRecords($users->take(1))
|
||||
->assertCanNotSeeTableRecords($users->skip(1));
|
||||
</code-snippet>
|
||||
|
||||
<code-snippet name="Filament Create Resource Test" lang="php">
|
||||
livewire(CreateUser::class)
|
||||
->fillForm([
|
||||
'name' => 'Test',
|
||||
'email' => 'test@example.com',
|
||||
])
|
||||
->call('create')
|
||||
->assertNotified()
|
||||
->assertRedirect();
|
||||
|
||||
assertDatabaseHas(User::class, [
|
||||
'name' => 'Test',
|
||||
'email' => 'test@example.com',
|
||||
]);
|
||||
</code-snippet>
|
||||
|
||||
<code-snippet name="Testing Validation" lang="php">
|
||||
livewire(CreateUser::class)
|
||||
->fillForm([
|
||||
'name' => null,
|
||||
'email' => 'invalid-email',
|
||||
])
|
||||
->call('create')
|
||||
->assertHasFormErrors([
|
||||
'name' => 'required',
|
||||
'email' => 'email',
|
||||
])
|
||||
->assertNotNotified();
|
||||
</code-snippet>
|
||||
|
||||
<code-snippet name="Calling Actions" lang="php">
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\Testing\TestAction;
|
||||
|
||||
livewire(EditUser::class, ['record' => $user->id])
|
||||
->callAction(DeleteAction::class)
|
||||
->assertNotified()
|
||||
->assertRedirect();
|
||||
|
||||
livewire(ListUsers::class)
|
||||
->callAction(TestAction::make('promote')->table($user), [
|
||||
'role' => 'admin',
|
||||
])
|
||||
->assertNotified();
|
||||
</code-snippet>
|
||||
|
||||
### Common Mistakes
|
||||
|
||||
**Commonly Incorrect Namespaces:**
|
||||
- Form fields (TextInput, Select, etc.): `Filament\Forms\Components\`
|
||||
- Infolist entries (for read-only views) (TextEntry, IconEntry, etc.): `Filament\Forms\Components\`
|
||||
- Layout components (Grid, Section, Fieldset, Tabs, Wizard, etc.): `Filament\Schemas\Components\`
|
||||
- Schema utilities (Get, Set, etc.): `Filament\Schemas\Components\Utilities\`
|
||||
- Actions: `Filament\Actions\` (no `Filament\Tables\Actions\` etc.)
|
||||
- Icons: `Filament\Support\Icons\Heroicon` enum (e.g., `Heroicon::PencilSquare`)
|
||||
|
||||
**Recent breaking changes to Filament:**
|
||||
- File visibility is `private` by default. Use `->visibility('public')` for public access.
|
||||
- `Grid`, `Section`, and `Fieldset` no longer span all columns by default.
|
||||
</laravel-boost-guidelines>
|
||||
|
||||
## Issue Tracking
|
||||
|
||||
@@ -24,12 +24,6 @@ class CouponPreviewController extends Controller
|
||||
|
||||
$package = Package::findOrFail($data['package_id']);
|
||||
|
||||
if (! $package->lemonsqueezy_variant_id) {
|
||||
throw ValidationException::withMessages([
|
||||
'code' => __('marketing.coupon.errors.package_not_configured'),
|
||||
]);
|
||||
}
|
||||
|
||||
$tenant = Auth::user()?->tenant;
|
||||
|
||||
try {
|
||||
|
||||
@@ -4,18 +4,21 @@ namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\EventPackageAddon;
|
||||
use App\Models\PackagePurchase;
|
||||
use App\Services\LemonSqueezy\Exceptions\LemonSqueezyException;
|
||||
use App\Services\LemonSqueezy\LemonSqueezyOrderService;
|
||||
use App\Services\LemonSqueezy\LemonSqueezySubscriptionService;
|
||||
use Dompdf\Dompdf;
|
||||
use Dompdf\Options;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class TenantBillingController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly LemonSqueezyOrderService $orders,
|
||||
private readonly LemonSqueezySubscriptionService $subscriptions,
|
||||
) {}
|
||||
|
||||
@@ -30,45 +33,45 @@ class TenantBillingController extends Controller
|
||||
], 404);
|
||||
}
|
||||
|
||||
if (! $tenant->lemonsqueezy_customer_id) {
|
||||
return response()->json([
|
||||
'data' => [],
|
||||
'meta' => [
|
||||
'next' => null,
|
||||
'previous' => null,
|
||||
'has_more' => false,
|
||||
],
|
||||
]);
|
||||
}
|
||||
$perPage = max(1, min((int) $request->query('per_page', 25), 100));
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$locale = $request->user()?->preferred_locale ?? app()->getLocale();
|
||||
|
||||
$cursor = $request->query('cursor');
|
||||
$perPage = (int) $request->query('per_page', 25);
|
||||
$paginator = PackagePurchase::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->with(['package'])
|
||||
->orderByDesc('purchased_at')
|
||||
->orderByDesc('id')
|
||||
->paginate($perPage, ['*'], 'page', $page);
|
||||
|
||||
$query = [
|
||||
'per_page' => max(1, min($perPage, 100)),
|
||||
];
|
||||
$data = $paginator->getCollection()->map(function (PackagePurchase $purchase) use ($locale) {
|
||||
$totals = $this->resolvePurchaseTotals($purchase);
|
||||
$transactionId = $purchase->provider_id ? (string) $purchase->provider_id : (string) $purchase->getKey();
|
||||
|
||||
if ($cursor) {
|
||||
$query['after'] = $cursor;
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->orders->listForCustomer($tenant->lemonsqueezy_customer_id, $query);
|
||||
} catch (\Throwable $exception) {
|
||||
Log::warning('Failed to load Lemon Squeezy transactions', [
|
||||
'tenant_id' => $tenant->id,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'data' => [],
|
||||
'message' => 'Failed to load Lemon Squeezy transactions.',
|
||||
], 502);
|
||||
}
|
||||
return [
|
||||
'id' => $purchase->getKey(),
|
||||
'status' => $purchase->refunded ? 'refunded' : 'completed',
|
||||
'amount' => $totals['total'],
|
||||
'currency' => $totals['currency'],
|
||||
'tax' => $totals['tax'],
|
||||
'provider' => $purchase->provider ?? 'paypal',
|
||||
'provider_id' => $transactionId,
|
||||
'package_name' => $this->resolvePackageName($purchase, $locale),
|
||||
'purchased_at' => $purchase->purchased_at?->toIso8601String(),
|
||||
'receipt_url' => route('api.v1.tenant.billing.transactions.receipt', [
|
||||
'purchase' => $purchase->getKey(),
|
||||
], absolute: false),
|
||||
];
|
||||
})->values();
|
||||
|
||||
return response()->json([
|
||||
'data' => $result['data'],
|
||||
'meta' => $result['meta'],
|
||||
'data' => $data,
|
||||
'meta' => [
|
||||
'current_page' => $paginator->currentPage(),
|
||||
'last_page' => $paginator->lastPage(),
|
||||
'per_page' => $paginator->perPage(),
|
||||
'total' => $paginator->total(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -201,4 +204,184 @@ class TenantBillingController extends Controller
|
||||
'url' => $url,
|
||||
]);
|
||||
}
|
||||
|
||||
public function receipt(Request $request, PackagePurchase $purchase): Response
|
||||
{
|
||||
$tenant = $request->attributes->get('tenant');
|
||||
|
||||
if (! $tenant || (int) $purchase->tenant_id !== (int) $tenant->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$purchase->loadMissing(['tenant.user', 'package']);
|
||||
|
||||
$locale = $request->user()?->preferred_locale ?? app()->getLocale();
|
||||
app()->setLocale($locale);
|
||||
|
||||
$totals = $this->resolvePurchaseTotals($purchase);
|
||||
$currency = $totals['currency'];
|
||||
$total = $totals['total'];
|
||||
$tax = $totals['tax'];
|
||||
|
||||
$buyer = $purchase->tenant?->user;
|
||||
$buyerName = $buyer?->full_name ?? $buyer?->name ?? $buyer?->email ?? '';
|
||||
$buyerEmail = $buyer?->email ?? '';
|
||||
$buyerAddress = $buyer?->address ?? '';
|
||||
|
||||
$packageName = $this->resolvePackageName($purchase, $locale);
|
||||
$packageTypeLabel = $this->resolvePackageTypeLabel($purchase->package?->type);
|
||||
$providerLabel = $this->resolveProviderLabel($purchase->provider);
|
||||
|
||||
$purchaseDate = $this->formatDate($purchase->purchased_at, $locale);
|
||||
$amountFormatted = $this->formatCurrency($total, $currency, $locale);
|
||||
$taxFormatted = $tax !== null ? $this->formatCurrency($tax, $currency, $locale) : null;
|
||||
$totalFormatted = $amountFormatted;
|
||||
|
||||
$html = view('billing.receipt', [
|
||||
'receiptNumber' => (string) $purchase->getKey(),
|
||||
'purchaseDate' => $purchaseDate,
|
||||
'packageName' => $packageName,
|
||||
'packageTypeLabel' => $packageTypeLabel,
|
||||
'providerLabel' => $providerLabel,
|
||||
'orderId' => $purchase->provider_id ?? $purchase->getKey(),
|
||||
'buyerName' => $buyerName,
|
||||
'buyerEmail' => $buyerEmail,
|
||||
'buyerAddress' => $buyerAddress,
|
||||
'amountFormatted' => $amountFormatted,
|
||||
'taxFormatted' => $taxFormatted,
|
||||
'totalFormatted' => $totalFormatted,
|
||||
'currency' => $currency,
|
||||
'companyName' => config('app.name', 'Fotospiel'),
|
||||
'companyEmail' => config('mail.from.address', 'info@fotospiel.app'),
|
||||
])->render();
|
||||
|
||||
$options = new Options;
|
||||
$options->set('isHtml5ParserEnabled', true);
|
||||
$options->set('isRemoteEnabled', true);
|
||||
$options->set('defaultFont', 'Helvetica');
|
||||
|
||||
$dompdf = new Dompdf($options);
|
||||
$dompdf->setPaper('A4', 'portrait');
|
||||
$dompdf->loadHtml($html, 'UTF-8');
|
||||
$dompdf->render();
|
||||
|
||||
$pdfBinary = $dompdf->output();
|
||||
$filenameStem = Str::slug($packageName ?: 'receipt');
|
||||
|
||||
return response($pdfBinary)
|
||||
->header('Content-Type', 'application/pdf')
|
||||
->header('Content-Disposition', 'inline; filename="receipt-'.$filenameStem.'.pdf"');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{currency: string, total: float, tax: float|null}
|
||||
*/
|
||||
private function resolvePurchaseTotals(PackagePurchase $purchase): array
|
||||
{
|
||||
$metadata = $purchase->metadata ?? [];
|
||||
$totals = $metadata['paypal_totals'] ?? $metadata['lemonsqueezy_totals'] ?? [];
|
||||
|
||||
$currency = $totals['currency']
|
||||
?? $metadata['currency']
|
||||
?? $purchase->package?->currency
|
||||
?? 'EUR';
|
||||
|
||||
$total = array_key_exists('total', $totals)
|
||||
? (float) $totals['total']
|
||||
: (float) $purchase->price;
|
||||
|
||||
$tax = array_key_exists('tax', $totals) ? (float) $totals['tax'] : null;
|
||||
|
||||
return [
|
||||
'currency' => strtoupper((string) $currency),
|
||||
'total' => round($total, 2),
|
||||
'tax' => $tax !== null ? round($tax, 2) : null,
|
||||
];
|
||||
}
|
||||
|
||||
private function resolvePackageName(PackagePurchase $purchase, string $locale): string
|
||||
{
|
||||
$package = $purchase->package;
|
||||
if (! $package) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$localized = $package->getNameForLocale($locale);
|
||||
|
||||
return $localized ?: (string) $package->name;
|
||||
}
|
||||
|
||||
private function resolveProviderLabel(?string $provider): string
|
||||
{
|
||||
$provider = $provider ?: 'paypal';
|
||||
$labelKey = 'emails.purchase.provider.'.$provider;
|
||||
$label = __($labelKey);
|
||||
|
||||
if ($label === $labelKey) {
|
||||
return ucfirst($provider);
|
||||
}
|
||||
|
||||
return $label;
|
||||
}
|
||||
|
||||
private function resolvePackageTypeLabel(?string $type): string
|
||||
{
|
||||
$type = $type ?: 'endcustomer';
|
||||
$labelKey = 'emails.purchase.package_type.'.$type;
|
||||
$label = __($labelKey);
|
||||
|
||||
if ($label === $labelKey) {
|
||||
return ucfirst($type);
|
||||
}
|
||||
|
||||
return $label;
|
||||
}
|
||||
|
||||
private function formatCurrency(float $amount, string $currency, string $locale): string
|
||||
{
|
||||
$formatter = class_exists(\NumberFormatter::class)
|
||||
? new \NumberFormatter($this->mapLocale($locale), \NumberFormatter::CURRENCY)
|
||||
: null;
|
||||
|
||||
if ($formatter) {
|
||||
$formatted = $formatter->formatCurrency($amount, $currency);
|
||||
if ($formatted !== false) {
|
||||
return $formatted;
|
||||
}
|
||||
}
|
||||
|
||||
$symbol = match (strtoupper($currency)) {
|
||||
'EUR' => '€',
|
||||
'USD' => '$',
|
||||
default => strtoupper($currency).' ',
|
||||
};
|
||||
|
||||
return $symbol.number_format($amount, 2, ',', '.');
|
||||
}
|
||||
|
||||
private function formatDate(?\Carbon\CarbonInterface $date, string $locale): string
|
||||
{
|
||||
if (! $date) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$localized = $date->locale($locale);
|
||||
|
||||
if (str_starts_with($locale, 'en')) {
|
||||
return $localized->translatedFormat('F j, Y');
|
||||
}
|
||||
|
||||
return $localized->translatedFormat('d. F Y');
|
||||
}
|
||||
|
||||
private function mapLocale(string $locale): string
|
||||
{
|
||||
$normalized = strtolower(str_replace('_', '-', $locale));
|
||||
|
||||
return match (true) {
|
||||
str_starts_with($normalized, 'de') => 'de_DE',
|
||||
str_starts_with($normalized, 'en') => 'en_US',
|
||||
default => 'de_DE',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +81,18 @@ class ContentSecurityPolicy
|
||||
'https:',
|
||||
];
|
||||
|
||||
$paypalSources = [
|
||||
'https://www.paypal.com',
|
||||
'https://www.paypalobjects.com',
|
||||
'https://*.paypal.com',
|
||||
'https://*.paypalobjects.com',
|
||||
];
|
||||
|
||||
$scriptSources = array_merge($scriptSources, $paypalSources);
|
||||
$connectSources = array_merge($connectSources, $paypalSources);
|
||||
$frameSources = array_merge($frameSources, $paypalSources);
|
||||
$imgSources = array_merge($imgSources, $paypalSources);
|
||||
|
||||
if ($matomoOrigin) {
|
||||
$scriptSources[] = $matomoOrigin;
|
||||
$connectSources[] = $matomoOrigin;
|
||||
@@ -90,6 +102,18 @@ class ContentSecurityPolicy
|
||||
$isDev = app()->environment(['local', 'development']) || config('app.debug');
|
||||
|
||||
if ($isDev) {
|
||||
$paypalSandboxSources = [
|
||||
'https://www.sandbox.paypal.com',
|
||||
'https://www.sandbox.paypalobjects.com',
|
||||
'https://*.sandbox.paypal.com',
|
||||
'https://*.sandbox.paypalobjects.com',
|
||||
];
|
||||
|
||||
$scriptSources = array_merge($scriptSources, $paypalSandboxSources);
|
||||
$connectSources = array_merge($connectSources, $paypalSandboxSources);
|
||||
$frameSources = array_merge($frameSources, $paypalSandboxSources);
|
||||
$imgSources = array_merge($imgSources, $paypalSandboxSources);
|
||||
|
||||
$devHosts = [
|
||||
'http://fotospiel-app.test:5173',
|
||||
'http://127.0.0.1:5173',
|
||||
|
||||
@@ -80,7 +80,7 @@ class TenantPackage extends Model
|
||||
public function getRemainingEventsAttribute(): ?int
|
||||
{
|
||||
if (! $this->package->isReseller()) {
|
||||
return 0;
|
||||
return max(0, 1 - (int) $this->used_events);
|
||||
}
|
||||
|
||||
$max = $this->package->max_events_per_year;
|
||||
|
||||
@@ -4,7 +4,6 @@ namespace App\Services\Coupons;
|
||||
|
||||
use App\Enums\CouponStatus;
|
||||
use App\Enums\CouponType;
|
||||
use App\Models\CheckoutSession;
|
||||
use App\Models\Coupon;
|
||||
use App\Models\CouponRedemption;
|
||||
use App\Models\Package;
|
||||
@@ -36,12 +35,6 @@ class CouponService
|
||||
|
||||
public function ensureCouponCanBeApplied(Coupon $coupon, Package $package, ?Tenant $tenant = null, ?string $provider = null): void
|
||||
{
|
||||
if ($provider !== CheckoutSession::PROVIDER_PAYPAL && ! $coupon->lemonsqueezy_discount_id) {
|
||||
throw ValidationException::withMessages([
|
||||
'code' => __('marketing.coupon.errors.not_synced'),
|
||||
]);
|
||||
}
|
||||
|
||||
if (! $coupon->enabled_for_checkout) {
|
||||
throw ValidationException::withMessages([
|
||||
'code' => __('marketing.coupon.errors.disabled'),
|
||||
|
||||
@@ -139,7 +139,7 @@ class PayPalOrderService
|
||||
$headers['PayPal-Request-Id'] = $requestId;
|
||||
}
|
||||
|
||||
return $this->client->post(sprintf('/v2/checkout/orders/%s/capture', $orderId), [], $headers);
|
||||
return $this->client->post(sprintf('/v2/checkout/orders/%s/capture', $orderId), (object) [], $headers);
|
||||
}
|
||||
|
||||
public function resolveApproveUrl(array $payload): ?string
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
"codex"
|
||||
],
|
||||
"editors": [
|
||||
"codex",
|
||||
"vscode"
|
||||
],
|
||||
"guidelines": []
|
||||
"guidelines": [
|
||||
"filament/filament"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -687,12 +687,12 @@
|
||||
"secure_payment_desc": "Sichere Zahlung ueber PayPal.",
|
||||
"lemonsqueezy_intro": "Starte den PayPal-Checkout direkt hier im Wizard – ganz ohne Seitenwechsel.",
|
||||
"guided_title": "Sichere Zahlung mit PayPal",
|
||||
"guided_body": "Bezahle schnell und sicher mit PayPal. Dein Paket wird nach der Bestaetigung sofort freigeschaltet.",
|
||||
"guided_body": "Bezahle schnell und sicher mit PayPal. Es oeffnet sich ein PayPal-Fenster – kehre danach hierher zurueck.",
|
||||
"lemonsqueezy_partner": "Powered by PayPal",
|
||||
"trust_secure": "Verschlüsselte Zahlung",
|
||||
"trust_tax": "Automatische Steuerberechnung",
|
||||
"trust_support": "Support in Minuten",
|
||||
"guided_cta_hint": "Sicher abgewickelt ueber PayPal",
|
||||
"guided_cta_hint": "Zahlung in PayPal abschliessen und hierher zurueckkehren.",
|
||||
"toast_success": "Zahlung erfolgreich – wir bereiten alles vor.",
|
||||
"lemonsqueezy_preparing": "PayPal-Checkout wird vorbereitet...",
|
||||
"lemonsqueezy_overlay_ready": "Der PayPal-Checkout läuft jetzt in einem Overlay. Schließe die Zahlung dort ab und kehre anschließend hierher zurück.",
|
||||
@@ -738,11 +738,13 @@
|
||||
"processing_confirmation": "Zahlung eingegangen. Wir schliessen deine Bestellung ab...",
|
||||
"paypal_partner": "Powered by PayPal",
|
||||
"paypal_preparing": "PayPal-Checkout wird vorbereitet...",
|
||||
"paypal_ready": "PayPal-Checkout ist bereit. Schließe die Zahlung ab, um fortzufahren.",
|
||||
"paypal_ready": "PayPal-Checkout ist bereit. Schliesse die Zahlung in PayPal ab und kehre hierher zurueck.",
|
||||
"paypal_error": "Der PayPal-Checkout konnte nicht gestartet werden. Bitte versuche es erneut.",
|
||||
"paypal_not_configured": "PayPal ist noch nicht konfiguriert. Bitte kontaktiere den Support.",
|
||||
"paypal_cancelled": "PayPal-Checkout wurde abgebrochen.",
|
||||
"paypal_disclaimer": "Zahlungen werden sicher über PayPal verarbeitet. Du erhältst im Anschluss eine Bestätigung."
|
||||
"paypal_cancelled": "PayPal-Checkout wurde abgebrochen. Du kannst es unten erneut versuchen.",
|
||||
"paypal_disclaimer": "Zahlungen werden sicher über PayPal verarbeitet. Du erhältst im Anschluss eine Bestätigung.",
|
||||
"resume_paypal": "Weiter in PayPal",
|
||||
"resume_hint": "Falls PayPal nicht geoeffnet wurde oder geschlossen wurde, hier im neuen Tab fortsetzen."
|
||||
},
|
||||
"confirmation_step": {
|
||||
"title": "Bestätigung",
|
||||
@@ -753,21 +755,28 @@
|
||||
"email_followup": "Wir haben dir gerade alle Details per E-Mail geschickt – inklusive Rechnung und den nächsten Schritten.",
|
||||
"hero_badge": "Checkout abgeschlossen",
|
||||
"hero_title": "Weiter geht's im Marketing-Dashboard",
|
||||
"hero_body": "Wir haben deinen Zugang aktiviert und PayPal synchronisiert. Mit diesen Aufgaben startest du direkt durch.",
|
||||
"hero_body": "Wir haben deinen Zugang aktiviert und synchronisieren PayPal. Mit diesen Aufgaben startest du direkt durch.",
|
||||
"hero_next": "Nutze den Button unten, um in deinen Kundenbereich zu wechseln – diese Übersicht kannst du jederzeit erneut öffnen.",
|
||||
"status_title": "Bestellstatus",
|
||||
"status_subtitle": "Wir schließen die Aktivierung ab und synchronisieren dein Konto.",
|
||||
"status_state": {
|
||||
"processing": "Wird bestätigt",
|
||||
"completed": "Bestätigt",
|
||||
"failed": "Aktion nötig"
|
||||
"failed": "Aktion nötig",
|
||||
"action_required": "Aktion erforderlich"
|
||||
},
|
||||
"status_body_processing": "Wir synchronisieren dein Konto mit PayPal. Das kann einen Moment dauern.",
|
||||
"status_body_completed": "Alles ist bereit. Dein Konto ist vollständig freigeschaltet.",
|
||||
"status_body_failed": "Wir konnten den Kauf noch nicht bestätigen. Bitte prüfe den Status erneut oder kontaktiere den Support.",
|
||||
"status_body_action_required": "PayPal benoetigt noch eine kurze Bestaetigung. Schliesse den Checkout ab, um dein Paket zu aktivieren.",
|
||||
"status_manual_hint": "Dauert es zu lange? Du kannst den Status erneut prüfen oder die Seite aktualisieren.",
|
||||
"status_retry": "Status prüfen",
|
||||
"status_refresh": "Seite aktualisieren",
|
||||
"status_action_hint": "Wir benoetigen noch deine PayPal-Bestaetigung, bevor wir dein Paket aktivieren koennen.",
|
||||
"status_action_button": "PayPal-Checkout fortsetzen",
|
||||
"status_action_back": "Zurueck zur Zahlung",
|
||||
"status_failed_hint": "Die Zahlung wurde nicht abgeschlossen. Du kannst den Checkout erneut starten.",
|
||||
"status_failed_back": "Zurueck zur Zahlung",
|
||||
"status_items": {
|
||||
"payment": {
|
||||
"title": "Zahlung bestätigt",
|
||||
|
||||
@@ -685,12 +685,12 @@
|
||||
"secure_payment_desc": "Secure payment with PayPal.",
|
||||
"lemonsqueezy_intro": "Start the PayPal checkout right here in the wizard - no page changes required.",
|
||||
"guided_title": "Secure checkout with PayPal",
|
||||
"guided_body": "Pay quickly and securely with PayPal. Your package unlocks immediately after confirmation.",
|
||||
"guided_body": "Pay quickly and securely with PayPal. A PayPal window opens; return here after approval to finish.",
|
||||
"lemonsqueezy_partner": "Powered by PayPal",
|
||||
"trust_secure": "Encrypted payment",
|
||||
"trust_tax": "Automatic tax handling",
|
||||
"trust_support": "Live support within minutes",
|
||||
"guided_cta_hint": "Securely processed via PayPal",
|
||||
"guided_cta_hint": "Complete the payment in PayPal and return here to finish.",
|
||||
"toast_success": "Payment received – setting everything up for you.",
|
||||
"lemonsqueezy_preparing": "Preparing PayPal checkout...",
|
||||
"lemonsqueezy_overlay_ready": "PayPal checkout is running in a secure overlay. Complete the payment there and then continue here.",
|
||||
@@ -736,11 +736,13 @@
|
||||
"processing_confirmation": "Payment received. Finalising your order...",
|
||||
"paypal_partner": "Powered by PayPal",
|
||||
"paypal_preparing": "Preparing PayPal checkout...",
|
||||
"paypal_ready": "PayPal checkout is ready. Complete the payment to continue.",
|
||||
"paypal_ready": "PayPal checkout is ready. Complete the payment in PayPal and return here.",
|
||||
"paypal_error": "We could not start the PayPal checkout. Please try again.",
|
||||
"paypal_not_configured": "PayPal checkout is not configured yet. Please contact support.",
|
||||
"paypal_cancelled": "PayPal checkout was cancelled.",
|
||||
"paypal_disclaimer": "Payments are processed securely by PayPal. You will receive a receipt after purchase."
|
||||
"paypal_cancelled": "PayPal checkout was cancelled. You can try again below.",
|
||||
"paypal_disclaimer": "Payments are processed securely by PayPal. You will receive a receipt after purchase.",
|
||||
"resume_paypal": "Continue in PayPal",
|
||||
"resume_hint": "If PayPal did not open or you closed it, continue in a new tab."
|
||||
},
|
||||
"confirmation_step": {
|
||||
"title": "Confirmation",
|
||||
@@ -751,21 +753,28 @@
|
||||
"email_followup": "We've just sent a confirmation email with your receipt and the next steps.",
|
||||
"hero_badge": "Checkout complete",
|
||||
"hero_title": "You're ready for the Marketing Dashboard",
|
||||
"hero_body": "We activated your access and synced PayPal. Follow the checklist below to launch your first event.",
|
||||
"hero_body": "We activated your access and are syncing PayPal. Follow the checklist below to launch your first event.",
|
||||
"hero_next": "Use the button below whenever you're ready to jump into your customer area—this summary is always available.",
|
||||
"status_title": "Purchase status",
|
||||
"status_subtitle": "We are finishing the handoff and syncing your account.",
|
||||
"status_state": {
|
||||
"processing": "Finalising",
|
||||
"completed": "Confirmed",
|
||||
"failed": "Needs attention"
|
||||
"failed": "Needs attention",
|
||||
"action_required": "Action required"
|
||||
},
|
||||
"status_body_processing": "We are syncing your account with PayPal. This can take a minute.",
|
||||
"status_body_completed": "Everything is ready. Your account is fully unlocked.",
|
||||
"status_body_failed": "We could not confirm the purchase yet. Please try again or contact support.",
|
||||
"status_body_action_required": "PayPal still needs a quick confirmation. Complete the checkout to activate your package.",
|
||||
"status_manual_hint": "Still waiting? You can re-check the status or refresh the page.",
|
||||
"status_retry": "Check status",
|
||||
"status_refresh": "Refresh page",
|
||||
"status_action_hint": "We still need PayPal approval before we can activate your package.",
|
||||
"status_action_button": "Continue PayPal checkout",
|
||||
"status_action_back": "Back to payment",
|
||||
"status_failed_hint": "The payment did not complete. You can start the checkout again.",
|
||||
"status_failed_back": "Back to payment",
|
||||
"status_items": {
|
||||
"payment": {
|
||||
"title": "Payment confirmed",
|
||||
|
||||
@@ -870,6 +870,11 @@ html.guest-theme.dark {
|
||||
background-color: #000;
|
||||
}
|
||||
|
||||
.guest-layout {
|
||||
padding-bottom: var(--guest-bottom-nav-offset, calc(env(safe-area-inset-bottom, 0px) + 88px));
|
||||
scroll-padding-bottom: var(--guest-bottom-nav-offset, calc(env(safe-area-inset-bottom, 0px) + 88px));
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--animate-spin:
|
||||
spin 2s linear infinite;
|
||||
|
||||
@@ -571,6 +571,19 @@ export type LemonSqueezyOrderSummary = {
|
||||
tax?: number | null;
|
||||
};
|
||||
|
||||
export type TenantBillingTransactionSummary = {
|
||||
id: string | number | null;
|
||||
status: string | null;
|
||||
amount: number | null;
|
||||
currency: string | null;
|
||||
provider: string | null;
|
||||
provider_id: string | null;
|
||||
package_name: string | null;
|
||||
purchased_at: string | null;
|
||||
receipt_url?: string | null;
|
||||
tax?: number | null;
|
||||
};
|
||||
|
||||
export type TenantAddonEventSummary = {
|
||||
id: number;
|
||||
slug: string;
|
||||
@@ -1125,21 +1138,21 @@ export function normalizeTenantPackage(pkg: JsonValue): TenantPackageSummary {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeLemonSqueezyOrder(entry: JsonValue): LemonSqueezyOrderSummary {
|
||||
const amountValue = entry.amount ?? entry.grand_total ?? (entry.totals && entry.totals.grand_total);
|
||||
const taxValue = entry.tax ?? (entry.totals && entry.totals.tax_total);
|
||||
function normalizeTenantBillingTransaction(entry: JsonValue): TenantBillingTransactionSummary {
|
||||
const idValue = (entry as { id?: unknown }).id;
|
||||
const amountValue = (entry as { amount?: unknown }).amount;
|
||||
const taxValue = (entry as { tax?: unknown }).tax;
|
||||
|
||||
return {
|
||||
id: typeof entry.id === 'string' ? entry.id : entry.id ? String(entry.id) : null,
|
||||
status: entry.status ?? null,
|
||||
id: typeof idValue === 'string' || typeof idValue === 'number' ? idValue : idValue ? String(idValue) : null,
|
||||
status: typeof (entry as { status?: unknown }).status === 'string' ? (entry as { status?: unknown }).status : null,
|
||||
amount: amountValue !== undefined && amountValue !== null ? Number(amountValue) : null,
|
||||
currency: entry.currency ?? entry.currency_code ?? 'EUR',
|
||||
origin: entry.origin ?? null,
|
||||
checkout_id: entry.checkout_id ?? (entry.details?.checkout_id ?? null),
|
||||
created_at: entry.created_at ?? null,
|
||||
updated_at: entry.updated_at ?? null,
|
||||
receipt_url: entry.receipt_url ?? entry.invoice_url ?? null,
|
||||
grand_total: entry.grand_total !== undefined && entry.grand_total !== null ? Number(entry.grand_total) : null,
|
||||
currency: typeof (entry as { currency?: unknown }).currency === 'string' ? (entry as { currency?: unknown }).currency : 'EUR',
|
||||
provider: typeof (entry as { provider?: unknown }).provider === 'string' ? (entry as { provider?: unknown }).provider : null,
|
||||
provider_id: typeof (entry as { provider_id?: unknown }).provider_id === 'string' ? (entry as { provider_id?: unknown }).provider_id : null,
|
||||
package_name: typeof (entry as { package_name?: unknown }).package_name === 'string' ? (entry as { package_name?: unknown }).package_name : null,
|
||||
purchased_at: typeof (entry as { purchased_at?: unknown }).purchased_at === 'string' ? (entry as { purchased_at?: unknown }).purchased_at : null,
|
||||
receipt_url: typeof (entry as { receipt_url?: unknown }).receipt_url === 'string' ? (entry as { receipt_url?: unknown }).receipt_url : null,
|
||||
tax: taxValue !== undefined && taxValue !== null ? Number(taxValue) : null,
|
||||
};
|
||||
}
|
||||
@@ -2731,32 +2744,43 @@ export async function downloadTenantDataExport(downloadUrl: string): Promise<Blo
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
export async function getTenantLemonSqueezyTransactions(cursor?: string): Promise<{
|
||||
data: LemonSqueezyOrderSummary[];
|
||||
nextCursor: string | null;
|
||||
hasMore: boolean;
|
||||
export async function downloadTenantBillingReceipt(receiptUrl: string): Promise<Blob> {
|
||||
const response = await authorizedFetch(receiptUrl, {
|
||||
headers: { 'Accept': 'application/pdf' },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = await safeJson(response);
|
||||
console.error('[API] Failed to download billing receipt', response.status, payload);
|
||||
throw new Error('Failed to download billing receipt');
|
||||
}
|
||||
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
export async function getTenantBillingTransactions(page = 1): Promise<{
|
||||
data: TenantBillingTransactionSummary[];
|
||||
}> {
|
||||
const query = cursor ? `?cursor=${encodeURIComponent(cursor)}` : '';
|
||||
const response = await authorizedFetch(`/api/v1/tenant/billing/transactions${query}`);
|
||||
const params = new URLSearchParams({
|
||||
page: String(Math.max(1, page)),
|
||||
});
|
||||
const response = await authorizedFetch(`/api/v1/tenant/billing/transactions?${params.toString()}`);
|
||||
|
||||
if (response.status === 404) {
|
||||
return { data: [], nextCursor: null, hasMore: false };
|
||||
return { data: [] };
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = await safeJson(response);
|
||||
console.error('[API] Failed to load Lemon Squeezy transactions', response.status, payload);
|
||||
throw new Error('Failed to load Lemon Squeezy transactions');
|
||||
console.error('[API] Failed to load billing transactions', response.status, payload);
|
||||
throw new Error('Failed to load billing transactions');
|
||||
}
|
||||
|
||||
const payload = await safeJson(response) ?? {};
|
||||
const entries = Array.isArray(payload.data) ? payload.data : [];
|
||||
const meta = payload.meta ?? {};
|
||||
|
||||
return {
|
||||
data: entries.map(normalizeLemonSqueezyOrder),
|
||||
nextCursor: typeof meta.next === 'string' ? meta.next : null,
|
||||
hasMore: Boolean(meta.has_more),
|
||||
data: entries.map(normalizeTenantBillingTransaction),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -6,10 +6,12 @@
|
||||
"language": "Sprache",
|
||||
"languageDe": "Deutsch",
|
||||
"languageEn": "Englisch",
|
||||
"theme": "Theme",
|
||||
"theme": "Erscheinungsbild",
|
||||
"themeLight": "Hell",
|
||||
"themeDark": "Dunkel",
|
||||
"themeSystem": "System",
|
||||
"themeSystemLabel": "System ({{mode}})",
|
||||
"themeSystemHint": "Folgt der Geräteeinstellung: {{mode}}",
|
||||
"logout": "Abmelden",
|
||||
"logoutTitle": "Ausloggen",
|
||||
"logoutHint": "Aus der App ausloggen"
|
||||
@@ -27,8 +29,9 @@
|
||||
"actions": {
|
||||
"refresh": "Aktualisieren",
|
||||
"exportCsv": "Export als CSV",
|
||||
"portal": "Im PayPal-Portal verwalten",
|
||||
"portalBusy": "Portal wird geöffnet...",
|
||||
"portal": "Abrechnungsdetails",
|
||||
"portalBusy": "Details werden geöffnet...",
|
||||
"receiptDownloaded": "Beleg heruntergeladen.",
|
||||
"openPackages": "Pakete öffnen",
|
||||
"contactSupport": "Support kontaktieren"
|
||||
},
|
||||
@@ -54,7 +57,8 @@
|
||||
"errors": {
|
||||
"load": "Paketdaten konnten nicht geladen werden.",
|
||||
"more": "Weitere Einträge konnten nicht geladen werden.",
|
||||
"portal": "PayPal-Portal konnte nicht geöffnet werden."
|
||||
"portal": "Abrechnungsdetails konnten nicht geöffnet werden.",
|
||||
"receipt": "Beleg konnte nicht heruntergeladen werden."
|
||||
},
|
||||
"checkoutSuccess": "Checkout abgeschlossen. Dein Paket wird in Kürze aktiviert.",
|
||||
"checkoutCancelled": "Checkout wurde abgebrochen.",
|
||||
@@ -140,14 +144,14 @@
|
||||
}
|
||||
},
|
||||
"transactions": {
|
||||
"title": "PayPal-Transaktionen",
|
||||
"description": "Neueste PayPal-Transaktionen für dieses Kundenkonto.",
|
||||
"empty": "Noch keine PayPal-Transaktionen.",
|
||||
"title": "Transaktionen",
|
||||
"description": "Neueste Paketkäufe für dieses Kundenkonto.",
|
||||
"empty": "Noch keine Transaktionen.",
|
||||
"labels": {
|
||||
"transactionId": "Transaktion {{id}}",
|
||||
"checkoutId": "Checkout-ID: {{id}}",
|
||||
"origin": "Herkunft: {{origin}}",
|
||||
"receipt": "Beleg ansehen",
|
||||
"provider": "Anbieter: {{provider}}",
|
||||
"receipt": "Beleg herunterladen",
|
||||
"packageFallback": "Paket",
|
||||
"tax": "Steuer: {{value}}"
|
||||
},
|
||||
"table": {
|
||||
@@ -162,6 +166,7 @@
|
||||
"processing": "Verarbeitung",
|
||||
"failed": "Fehlgeschlagen",
|
||||
"cancelled": "Storniert",
|
||||
"refunded": "Erstattet",
|
||||
"unknown": "Unbekannt"
|
||||
},
|
||||
"loadMore": "Weitere Transaktionen laden",
|
||||
|
||||
@@ -6,10 +6,12 @@
|
||||
"language": "Language",
|
||||
"languageDe": "Deutsch",
|
||||
"languageEn": "English",
|
||||
"theme": "Theme",
|
||||
"theme": "Appearance",
|
||||
"themeLight": "Light",
|
||||
"themeDark": "Dark",
|
||||
"themeSystem": "System",
|
||||
"themeSystemLabel": "System ({{mode}})",
|
||||
"themeSystemHint": "Following device setting: {{mode}}",
|
||||
"logout": "Log out",
|
||||
"logoutTitle": "Sign out",
|
||||
"logoutHint": "Sign out from this app."
|
||||
@@ -27,8 +29,9 @@
|
||||
"actions": {
|
||||
"refresh": "Refresh",
|
||||
"exportCsv": "Export CSV",
|
||||
"portal": "Manage in PayPal",
|
||||
"portalBusy": "Opening portal...",
|
||||
"portal": "Billing details",
|
||||
"portalBusy": "Opening billing details...",
|
||||
"receiptDownloaded": "Receipt downloaded.",
|
||||
"openPackages": "Open packages",
|
||||
"contactSupport": "Contact support"
|
||||
},
|
||||
@@ -54,7 +57,8 @@
|
||||
"errors": {
|
||||
"load": "Unable to load package data.",
|
||||
"more": "Unable to load more entries.",
|
||||
"portal": "Unable to open the PayPal portal."
|
||||
"portal": "Unable to open billing details.",
|
||||
"receipt": "Receipt download failed."
|
||||
},
|
||||
"checkoutSuccess": "Checkout completed. Your package will activate shortly.",
|
||||
"checkoutCancelled": "Checkout was cancelled.",
|
||||
@@ -140,14 +144,14 @@
|
||||
}
|
||||
},
|
||||
"transactions": {
|
||||
"title": "PayPal transactions",
|
||||
"description": "Recent PayPal transactions for this customer account.",
|
||||
"empty": "No PayPal transactions yet.",
|
||||
"title": "Transactions",
|
||||
"description": "Recent package purchases for this account.",
|
||||
"empty": "No transactions yet.",
|
||||
"labels": {
|
||||
"transactionId": "Transaction {{id}}",
|
||||
"checkoutId": "Checkout ID: {{id}}",
|
||||
"origin": "Origin: {{origin}}",
|
||||
"receipt": "View receipt",
|
||||
"provider": "Provider: {{provider}}",
|
||||
"receipt": "Download receipt",
|
||||
"packageFallback": "Package",
|
||||
"tax": "Tax: {{value}}"
|
||||
},
|
||||
"table": {
|
||||
@@ -162,6 +166,7 @@
|
||||
"processing": "Processing",
|
||||
"failed": "Failed",
|
||||
"cancelled": "Cancelled",
|
||||
"refunded": "Refunded",
|
||||
"unknown": "Unknown"
|
||||
},
|
||||
"loadMore": "Load more transactions",
|
||||
|
||||
@@ -10,12 +10,12 @@ import { MobileShell, HeaderActionButton } from './components/MobileShell';
|
||||
import { MobileCard, CTAButton, PillBadge } from './components/Primitives';
|
||||
import { ContextHelpLink } from './components/ContextHelpLink';
|
||||
import {
|
||||
createTenantBillingPortalSession,
|
||||
getTenantBillingTransactions,
|
||||
getTenantPackagesOverview,
|
||||
getTenantLemonSqueezyTransactions,
|
||||
getTenantPackageCheckoutStatus,
|
||||
TenantPackageSummary,
|
||||
LemonSqueezyOrderSummary,
|
||||
TenantBillingTransactionSummary,
|
||||
downloadTenantBillingReceipt,
|
||||
} from '../api';
|
||||
import { TenantAddonHistoryEntry, getTenantAddonHistory } from '../api';
|
||||
import { getApiErrorMessage } from '../lib/apiError';
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
shouldClearPendingCheckout,
|
||||
storePendingCheckout,
|
||||
} from './lib/billingCheckout';
|
||||
import { triggerDownloadFromBlob } from './invite-layout/export-utils';
|
||||
|
||||
const CHECKOUT_POLL_INTERVAL_MS = 10000;
|
||||
|
||||
@@ -52,11 +53,10 @@ export default function MobileBillingPage() {
|
||||
const { textStrong, text, muted, subtle, danger, border, primary, accentSoft } = useAdminTheme();
|
||||
const [packages, setPackages] = React.useState<TenantPackageSummary[]>([]);
|
||||
const [activePackage, setActivePackage] = React.useState<TenantPackageSummary | null>(null);
|
||||
const [transactions, setTransactions] = React.useState<LemonSqueezyOrderSummary[]>([]);
|
||||
const [transactions, setTransactions] = React.useState<TenantBillingTransactionSummary[]>([]);
|
||||
const [addons, setAddons] = React.useState<TenantAddonHistoryEntry[]>([]);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [portalBusy, setPortalBusy] = React.useState(false);
|
||||
const [pendingCheckout, setPendingCheckout] = React.useState<PendingCheckout | null>(() => loadPendingCheckout());
|
||||
const [checkoutStatus, setCheckoutStatus] = React.useState<string | null>(null);
|
||||
const [checkoutStatusReason, setCheckoutStatusReason] = React.useState<string | null>(null);
|
||||
@@ -78,7 +78,7 @@ export default function MobileBillingPage() {
|
||||
try {
|
||||
const [pkg, trx, addonHistory] = await Promise.all([
|
||||
getTenantPackagesOverview({ force: true }),
|
||||
getTenantLemonSqueezyTransactions().catch(() => ({ data: [] as LemonSqueezyOrderSummary[] })),
|
||||
getTenantBillingTransactions().catch(() => ({ data: [] as TenantBillingTransactionSummary[] })),
|
||||
getTenantAddonHistory().catch(() => ({ data: [] as TenantAddonHistoryEntry[] })),
|
||||
]);
|
||||
setPackages(pkg.packages ?? []);
|
||||
@@ -104,30 +104,32 @@ export default function MobileBillingPage() {
|
||||
}
|
||||
}, [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 Lemon Squeezy-Portal nicht öffnen.'));
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setPortalBusy(false);
|
||||
}
|
||||
}, [portalBusy, t]);
|
||||
|
||||
const persistPendingCheckout = React.useCallback((next: PendingCheckout | null) => {
|
||||
setPendingCheckout(next);
|
||||
storePendingCheckout(next);
|
||||
}, []);
|
||||
|
||||
const handleReceiptDownload = React.useCallback(
|
||||
async (transaction: TenantBillingTransactionSummary) => {
|
||||
if (!transaction.receipt_url) {
|
||||
return;
|
||||
}
|
||||
|
||||
const transactionId = transaction.provider_id
|
||||
?? (transaction.id !== null && transaction.id !== undefined ? String(transaction.id) : 'receipt');
|
||||
|
||||
try {
|
||||
const blob = await downloadTenantBillingReceipt(transaction.receipt_url);
|
||||
const filename = `fotospiel-receipt-${transactionId}.pdf`;
|
||||
triggerDownloadFromBlob(blob, filename);
|
||||
toast.success(t('billing.actions.receiptDownloaded', 'Receipt downloaded.'));
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('billing.errors.receipt', 'Receipt download failed.')));
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
@@ -387,11 +389,6 @@ export default function MobileBillingPage() {
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{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 Lemon Squeezy')}
|
||||
onPress={openPortal}
|
||||
disabled={portalBusy}
|
||||
/>
|
||||
{loading ? (
|
||||
<Text fontSize="$sm" color={muted}>
|
||||
{t('common.loading', 'Lädt...')}
|
||||
@@ -439,38 +436,55 @@ export default function MobileBillingPage() {
|
||||
</YStack>
|
||||
) : (
|
||||
<YStack gap="$1.5">
|
||||
{transactions.slice(0, 8).map((trx) => (
|
||||
<XStack key={trx.id} alignItems="center" justifyContent="space-between" borderBottomWidth={1} borderColor={border} paddingVertical="$1.5">
|
||||
<YStack>
|
||||
<Text fontSize="$sm" color={textStrong} fontWeight="700">
|
||||
{trx.status ?? '—'}
|
||||
</Text>
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{formatDate(trx.created_at)}
|
||||
</Text>
|
||||
{trx.origin ? (
|
||||
<Text fontSize="$xs" color={subtle}>
|
||||
{trx.origin}
|
||||
{transactions.slice(0, 8).map((trx) => {
|
||||
const statusLabel = trx.status
|
||||
? t(`billing.sections.transactions.status.${trx.status}`, trx.status)
|
||||
: '—';
|
||||
const providerLabel = trx.provider
|
||||
? t(`billing.providers.${trx.provider}`, trx.provider)
|
||||
: t('billing.providers.unknown', 'Unknown');
|
||||
const transactionId = trx.provider_id ?? (trx.id !== null && trx.id !== undefined ? String(trx.id) : '—');
|
||||
const packageName = trx.package_name ?? t('billing.sections.transactions.labels.packageFallback', 'Package');
|
||||
|
||||
return (
|
||||
<XStack key={trx.id ?? transactionId} alignItems="center" justifyContent="space-between" borderBottomWidth={1} borderColor={border} paddingVertical="$1.5">
|
||||
<YStack>
|
||||
<Text fontSize="$sm" color={textStrong} fontWeight="700">
|
||||
{packageName}
|
||||
</Text>
|
||||
) : null}
|
||||
</YStack>
|
||||
<YStack alignItems="flex-end">
|
||||
<Text fontSize="$sm" color={textStrong} fontWeight="700">
|
||||
{formatAmount(trx.amount, trx.currency)}
|
||||
</Text>
|
||||
{trx.tax ? (
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t('billing.sections.transactions.labels.tax', { value: formatAmount(trx.tax, trx.currency) })}
|
||||
{statusLabel}
|
||||
</Text>
|
||||
) : null}
|
||||
{trx.receipt_url ? (
|
||||
<a href={trx.receipt_url} target="_blank" rel="noreferrer" style={{ fontSize: 12, color: primary }}>
|
||||
{t('billing.sections.transactions.labels.receipt', 'Beleg')}
|
||||
</a>
|
||||
) : null}
|
||||
</YStack>
|
||||
</XStack>
|
||||
))}
|
||||
<Text fontSize="$xs" color={subtle}>
|
||||
{t('billing.sections.transactions.labels.provider', { provider: providerLabel })}
|
||||
</Text>
|
||||
<Text fontSize="$xs" color={subtle}>
|
||||
{t('billing.sections.transactions.labels.transactionId', { id: transactionId })}
|
||||
</Text>
|
||||
</YStack>
|
||||
<YStack alignItems="flex-end">
|
||||
<Text fontSize="$sm" color={textStrong} fontWeight="700">
|
||||
{formatAmount(trx.amount, trx.currency)}
|
||||
</Text>
|
||||
{trx.tax ? (
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t('billing.sections.transactions.labels.tax', { value: formatAmount(trx.tax, trx.currency) })}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{formatDate(trx.purchased_at)}
|
||||
</Text>
|
||||
{trx.receipt_url ? (
|
||||
<Pressable onPress={() => void handleReceiptDownload(trx)}>
|
||||
<Text fontSize="$xs" color={primary}>
|
||||
{t('billing.sections.transactions.labels.receipt', 'Beleg')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</YStack>
|
||||
</XStack>
|
||||
);
|
||||
})}
|
||||
</YStack>
|
||||
)}
|
||||
</MobileCard>
|
||||
|
||||
181
resources/js/admin/mobile/__tests__/BillingPage.test.tsx
Normal file
181
resources/js/admin/mobile/__tests__/BillingPage.test.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import React from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
const navigateMock = vi.fn();
|
||||
|
||||
const tMock = (
|
||||
_key: string,
|
||||
fallback?: string | Record<string, unknown>,
|
||||
options?: Record<string, unknown>,
|
||||
) => {
|
||||
let value = typeof fallback === 'string' ? fallback : _key;
|
||||
if (options) {
|
||||
Object.entries(options).forEach(([key, val]) => {
|
||||
value = value.replaceAll(`{{${key}}}`, String(val));
|
||||
});
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const downloadReceiptMock = vi.fn().mockResolvedValue(new Blob(['pdf'], { type: 'application/pdf' }));
|
||||
const triggerDownloadMock = vi.fn();
|
||||
|
||||
vi.mock('react-router-dom', () => ({
|
||||
useNavigate: () => navigateMock,
|
||||
useLocation: () => ({ pathname: '/mobile/billing', search: '', hash: '' }),
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: tMock }),
|
||||
initReactI18next: {
|
||||
type: '3rdParty',
|
||||
init: () => undefined,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('lucide-react', () => ({
|
||||
Package: () => <span />,
|
||||
Receipt: () => <span />,
|
||||
RefreshCcw: () => <span />,
|
||||
Sparkles: () => <span />,
|
||||
}));
|
||||
|
||||
vi.mock('react-hot-toast', () => {
|
||||
const toast = Object.assign(vi.fn(), {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
});
|
||||
return { default: toast };
|
||||
});
|
||||
|
||||
vi.mock('../hooks/useBackNavigation', () => ({
|
||||
useBackNavigation: () => vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../theme', () => ({
|
||||
useAdminTheme: () => ({
|
||||
textStrong: '#111827',
|
||||
text: '#111827',
|
||||
muted: '#6b7280',
|
||||
subtle: '#9ca3af',
|
||||
danger: '#b91c1c',
|
||||
border: '#e5e7eb',
|
||||
primary: '#2563eb',
|
||||
accentSoft: '#eef2ff',
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../components/MobileShell', () => ({
|
||||
MobileShell: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
HeaderActionButton: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../components/Primitives', () => ({
|
||||
MobileCard: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
CTAButton: ({ label, onPress }: { label: string; onPress?: () => void }) => (
|
||||
<button type="button" onClick={onPress}>
|
||||
{label}
|
||||
</button>
|
||||
),
|
||||
PillBadge: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
|
||||
}));
|
||||
|
||||
vi.mock('../components/ContextHelpLink', () => ({
|
||||
ContextHelpLink: () => <div />,
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/stacks', () => ({
|
||||
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
XStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/text', () => ({
|
||||
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/react-native-web-lite', () => ({
|
||||
Pressable: ({
|
||||
children,
|
||||
onPress,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onPress?: () => void;
|
||||
}) => (
|
||||
<button type="button" onClick={onPress}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../../lib/apiError', () => ({
|
||||
getApiErrorMessage: (_err: unknown, fallback: string) => fallback,
|
||||
}));
|
||||
|
||||
vi.mock('../../constants', () => ({
|
||||
ADMIN_EVENT_VIEW_PATH: '/mobile/events',
|
||||
adminPath: (path: string) => path,
|
||||
}));
|
||||
|
||||
vi.mock('../billingUsage', () => ({
|
||||
buildPackageUsageMetrics: () => [],
|
||||
formatPackageEventAllowance: () => '—',
|
||||
getUsageState: () => 'ok',
|
||||
usagePercent: () => 0,
|
||||
}));
|
||||
|
||||
vi.mock('../lib/packageSummary', () => ({
|
||||
collectPackageFeatures: () => [],
|
||||
formatEventUsage: () => '',
|
||||
getPackageFeatureLabel: () => '',
|
||||
getPackageLimitEntries: () => [],
|
||||
resolveTenantWatermarkFeatureKey: () => '',
|
||||
}));
|
||||
|
||||
vi.mock('../lib/billingCheckout', () => ({
|
||||
loadPendingCheckout: () => null,
|
||||
shouldClearPendingCheckout: () => false,
|
||||
storePendingCheckout: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../invite-layout/export-utils', () => ({
|
||||
triggerDownloadFromBlob: (...args: unknown[]) => triggerDownloadMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../../api', () => ({
|
||||
getTenantPackagesOverview: vi.fn().mockResolvedValue({ packages: [], activePackage: null }),
|
||||
getTenantBillingTransactions: vi.fn().mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: 1,
|
||||
status: 'completed',
|
||||
amount: 49,
|
||||
currency: 'EUR',
|
||||
provider: 'paypal',
|
||||
provider_id: 'ORDER-1',
|
||||
package_name: 'Starter',
|
||||
purchased_at: '2024-01-01T00:00:00Z',
|
||||
receipt_url: '/api/v1/billing/transactions/1/receipt',
|
||||
},
|
||||
],
|
||||
}),
|
||||
getTenantAddonHistory: vi.fn().mockResolvedValue({ data: [] }),
|
||||
getTenantPackageCheckoutStatus: vi.fn(),
|
||||
downloadTenantBillingReceipt: (...args: unknown[]) => downloadReceiptMock(...args),
|
||||
}));
|
||||
|
||||
import MobileBillingPage from '../BillingPage';
|
||||
|
||||
describe('MobileBillingPage', () => {
|
||||
it('downloads receipts via the API helper', async () => {
|
||||
render(<MobileBillingPage />);
|
||||
|
||||
const receiptLink = await screen.findByText('Beleg');
|
||||
fireEvent.click(receiptLink);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(downloadReceiptMock).toHaveBeenCalledWith('/api/v1/billing/transactions/1/receipt');
|
||||
expect(triggerDownloadMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
13
resources/js/admin/mobile/__tests__/packageSummary.test.ts
Normal file
13
resources/js/admin/mobile/__tests__/packageSummary.test.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getPackageLimitEntries } from '../lib/packageSummary';
|
||||
|
||||
const t = (_key: string, fallback?: string) => fallback ?? _key;
|
||||
|
||||
describe('getPackageLimitEntries', () => {
|
||||
it('defaults endcustomer event limit to 1 when missing', () => {
|
||||
const entries = getPackageLimitEntries({}, t, {}, { packageType: 'endcustomer' });
|
||||
const eventEntry = entries.find((entry) => entry.key === 'max_events_per_year');
|
||||
|
||||
expect(eventEntry?.value).toBe('1');
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,7 @@ export function BottomNav({ active, onNavigate }: { active: NavKey; onNavigate:
|
||||
const { t } = useTranslation('mobile');
|
||||
const location = useLocation();
|
||||
const theme = useAdminTheme();
|
||||
const navRef = React.useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Modern Glass Background
|
||||
const navSurface = theme.glassSurfaceStrong ?? theme.surfaceMuted ?? theme.surface;
|
||||
@@ -35,8 +36,46 @@ export function BottomNav({ active, onNavigate }: { active: NavKey; onNavigate:
|
||||
{ key: 'profile', icon: User, label: t('nav.profile', 'Profile') },
|
||||
];
|
||||
|
||||
const setBottomOffset = React.useCallback(() => {
|
||||
if (typeof document === 'undefined' || !navRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const height = Math.ceil(navRef.current.getBoundingClientRect().height);
|
||||
document.documentElement.style.setProperty('--admin-bottom-nav-offset', `${height}px`);
|
||||
}, []);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
setBottomOffset();
|
||||
|
||||
const handleResize = () => setBottomOffset();
|
||||
if (typeof ResizeObserver !== 'undefined' && navRef.current) {
|
||||
const observer = new ResizeObserver(() => setBottomOffset());
|
||||
observer.observe(navRef.current);
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
window.removeEventListener('resize', handleResize);
|
||||
document.documentElement.style.removeProperty('--admin-bottom-nav-offset');
|
||||
};
|
||||
}
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
document.documentElement.style.removeProperty('--admin-bottom-nav-offset');
|
||||
};
|
||||
}, [setBottomOffset]);
|
||||
|
||||
return (
|
||||
<YStack
|
||||
ref={navRef as any}
|
||||
position="fixed"
|
||||
bottom={0}
|
||||
left={0}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { YStack, XStack, SizableText as Text, Image } from 'tamagui';
|
||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useEventContext } from '../../context/EventContext';
|
||||
import { BottomNav, BOTTOM_NAV_HEIGHT, BOTTOM_NAV_PADDING, NavKey } from './BottomNav';
|
||||
import { BottomNav, NavKey } from './BottomNav';
|
||||
import { useMobileNav } from '../hooks/useMobileNav';
|
||||
import { ADMIN_EVENTS_PATH, adminPath } from '../../constants';
|
||||
import { MobileCard, CTAButton } from './Primitives';
|
||||
@@ -302,6 +302,9 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
|
||||
backgroundColor={backgroundColor}
|
||||
minHeight="100vh"
|
||||
alignItems="center"
|
||||
style={{
|
||||
scrollPaddingBottom: 'var(--admin-bottom-nav-offset, calc(env(safe-area-inset-bottom, 0px) + 78px))',
|
||||
}}
|
||||
>
|
||||
<YStack
|
||||
backgroundColor={headerSurface}
|
||||
@@ -331,13 +334,12 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
|
||||
</YStack>
|
||||
|
||||
<YStack
|
||||
flex={1}
|
||||
padding="$4"
|
||||
gap="$3"
|
||||
width="100%"
|
||||
maxWidth={800}
|
||||
style={{
|
||||
paddingBottom: `calc(env(safe-area-inset-bottom, 0px) + ${BOTTOM_NAV_HEIGHT + BOTTOM_NAV_PADDING}px)`,
|
||||
paddingBottom: 'calc(var(--admin-bottom-nav-offset, calc(env(safe-area-inset-bottom, 0px) + 78px)) + 16px)',
|
||||
}}
|
||||
>
|
||||
{!online ? (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ChevronRight, CreditCard, FileText, HelpCircle, Moon, Sun, User, X } from 'lucide-react';
|
||||
import { ChevronRight, CreditCard, FileText, HelpCircle, Monitor, Moon, Sun, User, X } from 'lucide-react';
|
||||
import { XStack, YStack, SizableText as Text, ListItem, YGroup, Separator } from 'tamagui';
|
||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||
import { ToggleGroup } from '@tamagui/toggle-group';
|
||||
@@ -32,6 +32,12 @@ export function UserMenuSheet({ open, onClose, user, isMember, navigate }: UserM
|
||||
}, [i18n.language]);
|
||||
|
||||
const themeValue: 'light' | 'dark' = (appearance === 'system' ? resolved : appearance) ?? 'light';
|
||||
const selectedAppearance: 'light' | 'dark' | 'system' = appearance ?? 'system';
|
||||
const resolvedLabel =
|
||||
themeValue === 'dark'
|
||||
? t('mobileProfile.themeDark', 'Dark')
|
||||
: t('mobileProfile.themeLight', 'Light');
|
||||
const systemLabel = t('mobileProfile.themeSystemLabel', 'System ({{mode}})', { mode: resolvedLabel });
|
||||
const activeToggleBg = theme.accentSoft ?? withAlpha(theme.primary, 0.18);
|
||||
const activeToggleBorder = withAlpha(theme.primary, 0.45);
|
||||
|
||||
@@ -244,9 +250,9 @@ export function UserMenuSheet({ open, onClose, user, isMember, navigate }: UserM
|
||||
iconAfter={
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={themeValue}
|
||||
value={selectedAppearance}
|
||||
onValueChange={(next: string) => {
|
||||
if (next === 'light' || next === 'dark') {
|
||||
if (next === 'light' || next === 'dark' || next === 'system') {
|
||||
updateAppearance(next);
|
||||
}
|
||||
}}
|
||||
@@ -258,25 +264,24 @@ export function UserMenuSheet({ open, onClose, user, isMember, navigate }: UserM
|
||||
{([
|
||||
{ key: 'light', label: t('mobileProfile.themeLight', 'Light'), icon: Sun },
|
||||
{ key: 'dark', label: t('mobileProfile.themeDark', 'Dark'), icon: Moon },
|
||||
{ key: 'system', label: systemLabel, icon: Monitor },
|
||||
] as const).map((option) => {
|
||||
const active = option.key === themeValue;
|
||||
const active = option.key === selectedAppearance;
|
||||
const Icon = option.icon;
|
||||
return (
|
||||
<ToggleGroup.Item
|
||||
key={option.key}
|
||||
value={option.key}
|
||||
aria-label={option.label}
|
||||
borderRadius="$pill"
|
||||
borderWidth={1}
|
||||
borderColor={active ? activeToggleBorder : theme.border}
|
||||
backgroundColor={active ? activeToggleBg : 'transparent'}
|
||||
paddingHorizontal="$2.5"
|
||||
paddingVertical="$1.5"
|
||||
paddingHorizontal="$2"
|
||||
paddingVertical="$1"
|
||||
>
|
||||
<XStack alignItems="center" gap="$1.5">
|
||||
<Icon size={14} color={active ? theme.textStrong : theme.muted} />
|
||||
<Text fontSize="$xs" fontWeight={active ? '700' : '600'} color={active ? theme.textStrong : theme.muted}>
|
||||
{option.label}
|
||||
</Text>
|
||||
<XStack alignItems="center" justifyContent="center" minWidth={28} minHeight={24}>
|
||||
<Icon size={16} color={active ? theme.textStrong : theme.muted} />
|
||||
</XStack>
|
||||
</ToggleGroup.Item>
|
||||
);
|
||||
@@ -288,7 +293,7 @@ export function UserMenuSheet({ open, onClose, user, isMember, navigate }: UserM
|
||||
</YGroup>
|
||||
{appearance === 'system' ? (
|
||||
<Text fontSize="$xs" color={theme.muted}>
|
||||
{t('mobileProfile.themeSystem', 'System')}
|
||||
{t('mobileProfile.themeSystemHint', 'Following device setting: {{mode}}', { mode: resolvedLabel })}
|
||||
</Text>
|
||||
) : null}
|
||||
</YStack>
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import React from 'react';
|
||||
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
|
||||
const originalGetBoundingClientRect = HTMLElement.prototype.getBoundingClientRect;
|
||||
const originalResizeObserver = globalThis.ResizeObserver;
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (_key: string, fallback?: string) => fallback ?? _key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/stacks', () => ({
|
||||
YStack: ({ children, ...props }: { children: React.ReactNode }) => <div {...props}>{children}</div>,
|
||||
XStack: ({ children, ...props }: { children: React.ReactNode }) => <div {...props}>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/text', () => ({
|
||||
SizableText: ({ children, ...props }: { children: React.ReactNode }) => <span {...props}>{children}</span>,
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/react-native-web-lite', () => ({
|
||||
Pressable: ({ children, onPress, ...props }: { children: React.ReactNode; onPress?: () => void }) => (
|
||||
<button type="button" onClick={onPress} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../../theme', () => ({
|
||||
useAdminTheme: () => ({
|
||||
primary: '#FF5A5F',
|
||||
muted: '#6b7280',
|
||||
glassSurfaceStrong: 'rgba(255,255,255,0.9)',
|
||||
surfaceMuted: '#f8fafc',
|
||||
surface: '#ffffff',
|
||||
glassBorder: 'rgba(229,231,235,0.7)',
|
||||
border: '#e5e7eb',
|
||||
glassShadow: 'rgba(15,23,42,0.14)',
|
||||
shadow: 'rgba(0,0,0,0.12)',
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../../constants', () => ({
|
||||
adminPath: (path: string) => path,
|
||||
}));
|
||||
|
||||
import { BottomNav } from '../BottomNav';
|
||||
|
||||
describe('BottomNav', () => {
|
||||
beforeEach(() => {
|
||||
HTMLElement.prototype.getBoundingClientRect = () =>
|
||||
({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 0,
|
||||
height: 84,
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 84,
|
||||
toJSON: () => ({}),
|
||||
}) as DOMRect;
|
||||
|
||||
(globalThis as unknown as { ResizeObserver: typeof ResizeObserver }).ResizeObserver = class {
|
||||
private callback: () => void;
|
||||
|
||||
constructor(callback: () => void) {
|
||||
this.callback = callback;
|
||||
}
|
||||
|
||||
observe() {
|
||||
this.callback();
|
||||
}
|
||||
|
||||
disconnect() {}
|
||||
};
|
||||
|
||||
document.documentElement.style.removeProperty('--admin-bottom-nav-offset');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
HTMLElement.prototype.getBoundingClientRect = originalGetBoundingClientRect;
|
||||
document.documentElement.style.removeProperty('--admin-bottom-nav-offset');
|
||||
if (originalResizeObserver) {
|
||||
globalThis.ResizeObserver = originalResizeObserver;
|
||||
} else {
|
||||
delete (globalThis as unknown as { ResizeObserver?: typeof ResizeObserver }).ResizeObserver;
|
||||
}
|
||||
});
|
||||
|
||||
it('sets the admin bottom nav offset CSS variable', async () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/mobile/profile']}>
|
||||
<BottomNav active="profile" onNavigate={() => undefined} />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.documentElement.style.getPropertyValue('--admin-bottom-nav-offset')).toBe('84px');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -202,7 +202,13 @@ export function getPackageLimitEntries(
|
||||
key,
|
||||
label: t(labelKey, fallback),
|
||||
value: formatLimitWithRemaining(
|
||||
toNumber((limits as Record<string, number | null>)[key]),
|
||||
(() => {
|
||||
const limitValue = toNumber((limits as Record<string, number | null>)[key]);
|
||||
if (key === 'max_events_per_year' && options.packageType !== 'reseller' && limitValue === null) {
|
||||
return 1;
|
||||
}
|
||||
return limitValue;
|
||||
})(),
|
||||
resolveRemainingForKey(limits, key, usageOverrides),
|
||||
t
|
||||
),
|
||||
|
||||
@@ -54,6 +54,7 @@ export default function BottomNav() {
|
||||
const { event, status } = useEventData();
|
||||
const { t } = useTranslation();
|
||||
const { branding } = useEventBranding();
|
||||
const navRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const radius = branding.buttons?.radius ?? 12;
|
||||
const buttonStyle = branding.buttons?.style ?? 'filled';
|
||||
const linkColor = branding.buttons?.linkColor ?? branding.secondaryColor;
|
||||
@@ -83,9 +84,46 @@ export default function BottomNav() {
|
||||
|
||||
const compact = isUploadActive;
|
||||
const navPaddingBottom = `calc(env(safe-area-inset-bottom, 0px) + ${compact ? 12 : 18}px)`;
|
||||
const setBottomOffset = React.useCallback(() => {
|
||||
if (typeof document === 'undefined' || !navRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const height = Math.ceil(navRef.current.getBoundingClientRect().height);
|
||||
document.documentElement.style.setProperty('--guest-bottom-nav-offset', `${height}px`);
|
||||
}, []);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
setBottomOffset();
|
||||
|
||||
const handleResize = () => setBottomOffset();
|
||||
if (typeof ResizeObserver !== 'undefined' && navRef.current) {
|
||||
const observer = new ResizeObserver(() => setBottomOffset());
|
||||
observer.observe(navRef.current);
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
window.removeEventListener('resize', handleResize);
|
||||
document.documentElement.style.removeProperty('--guest-bottom-nav-offset');
|
||||
};
|
||||
}
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
document.documentElement.style.removeProperty('--guest-bottom-nav-offset');
|
||||
};
|
||||
}, [setBottomOffset, compact]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={navRef}
|
||||
className={`guest-bottom-nav fixed inset-x-0 bottom-0 z-30 border-t border-white/15 bg-gradient-to-t from-black/55 via-black/30 to-black/5 px-4 shadow-2xl backdrop-blur-xl transition-all duration-200 dark:border-white/10 dark:from-gray-950/85 dark:via-gray-900/55 dark:to-gray-900/20 ${
|
||||
compact ? 'pt-1' : 'pt-2 pb-1'
|
||||
}`}
|
||||
|
||||
104
resources/js/guest/components/__tests__/BottomNav.test.tsx
Normal file
104
resources/js/guest/components/__tests__/BottomNav.test.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import React from 'react';
|
||||
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
|
||||
import BottomNav from '../BottomNav';
|
||||
|
||||
const originalGetBoundingClientRect = HTMLElement.prototype.getBoundingClientRect;
|
||||
const originalResizeObserver = globalThis.ResizeObserver;
|
||||
|
||||
vi.mock('../../hooks/useEventData', () => ({
|
||||
useEventData: () => ({
|
||||
status: 'ready',
|
||||
event: {
|
||||
id: 1,
|
||||
default_locale: 'de',
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../i18n/useTranslation', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../context/EventBrandingContext', () => ({
|
||||
useEventBranding: () => ({
|
||||
branding: {
|
||||
primaryColor: '#0f172a',
|
||||
secondaryColor: '#38bdf8',
|
||||
backgroundColor: '#ffffff',
|
||||
palette: {
|
||||
surface: '#ffffff',
|
||||
},
|
||||
buttons: {
|
||||
radius: 12,
|
||||
style: 'filled',
|
||||
linkColor: '#0f172a',
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../lib/engagement', () => ({
|
||||
isTaskModeEnabled: () => false,
|
||||
}));
|
||||
|
||||
describe('BottomNav', () => {
|
||||
beforeEach(() => {
|
||||
HTMLElement.prototype.getBoundingClientRect = () =>
|
||||
({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 0,
|
||||
height: 80,
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 80,
|
||||
toJSON: () => ({}),
|
||||
}) as DOMRect;
|
||||
|
||||
(globalThis as unknown as { ResizeObserver: typeof ResizeObserver }).ResizeObserver = class {
|
||||
private callback: () => void;
|
||||
|
||||
constructor(callback: () => void) {
|
||||
this.callback = callback;
|
||||
}
|
||||
|
||||
observe() {
|
||||
this.callback();
|
||||
}
|
||||
|
||||
disconnect() {}
|
||||
};
|
||||
|
||||
document.documentElement.style.removeProperty('--guest-bottom-nav-offset');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
HTMLElement.prototype.getBoundingClientRect = originalGetBoundingClientRect;
|
||||
document.documentElement.style.removeProperty('--guest-bottom-nav-offset');
|
||||
if (originalResizeObserver) {
|
||||
globalThis.ResizeObserver = originalResizeObserver;
|
||||
} else {
|
||||
delete (globalThis as unknown as { ResizeObserver?: typeof ResizeObserver }).ResizeObserver;
|
||||
}
|
||||
});
|
||||
|
||||
it('sets the bottom nav offset CSS variable', async () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/e/demo']}>
|
||||
<Routes>
|
||||
<Route path="/e/:token/*" element={<BottomNav />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.documentElement.style.getPropertyValue('--guest-bottom-nav-offset')).toBe('80px');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -46,7 +46,7 @@ function HomeLayout() {
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<div className="pb-16">
|
||||
<div className="guest-layout">
|
||||
<Header title="Event" />
|
||||
<div className="px-4 py-3">
|
||||
<RouteTransition />
|
||||
@@ -132,7 +132,7 @@ function EventBoundary({ token }: { token: string }) {
|
||||
<EventBrandingProvider branding={branding}>
|
||||
<EventStatsProvider eventKey={token}>
|
||||
<NotificationCenterProvider eventToken={token}>
|
||||
<div className="pb-16">
|
||||
<div className="guest-layout">
|
||||
<Header eventToken={token} />
|
||||
<div className={contentPaddingClass}>
|
||||
<RouteTransition />
|
||||
@@ -341,7 +341,7 @@ function SimpleLayout({ title, children }: { title: string; children: React.Reac
|
||||
const matomoEnabled = typeof window !== 'undefined' && Boolean((window as any).__MATOMO_GUEST__?.enabled);
|
||||
return (
|
||||
<EventBrandingProvider>
|
||||
<div className="pb-16">
|
||||
<div className="guest-layout">
|
||||
<Header title={title} />
|
||||
<div className="px-4 py-3">
|
||||
<RouteTransition>{children}</RouteTransition>
|
||||
|
||||
@@ -168,7 +168,10 @@ const WizardBody: React.FC<{
|
||||
return true;
|
||||
}, [atLastStep, authUser, currentStep, isAuthenticated, paymentCompleted, selectedPackage]);
|
||||
|
||||
const shouldShowNextButton = useMemo(() => currentStep !== 'confirmation', [currentStep]);
|
||||
const shouldShowNextButton = useMemo(
|
||||
() => currentStep !== 'confirmation' && currentStep !== 'payment',
|
||||
[currentStep]
|
||||
);
|
||||
const highlightNextCta = currentStep === 'payment' && paymentCompleted;
|
||||
|
||||
const handleNext = useCallback(() => {
|
||||
|
||||
@@ -12,6 +12,7 @@ interface CheckoutState {
|
||||
error: string | null;
|
||||
paymentCompleted: boolean;
|
||||
checkoutSessionId: string | null;
|
||||
checkoutActionUrl: string | null;
|
||||
}
|
||||
|
||||
interface CheckoutWizardContextType {
|
||||
@@ -29,6 +30,7 @@ interface CheckoutWizardContextType {
|
||||
} | null;
|
||||
paymentCompleted: boolean;
|
||||
checkoutSessionId: string | null;
|
||||
checkoutActionUrl: string | null;
|
||||
selectPackage: (pkg: CheckoutPackage) => void;
|
||||
setSelectedPackage: (pkg: CheckoutPackage) => void;
|
||||
setAuthUser: (user: unknown) => void;
|
||||
@@ -44,6 +46,8 @@ interface CheckoutWizardContextType {
|
||||
setPaymentCompleted: (completed: boolean) => void;
|
||||
setCheckoutSessionId: (sessionId: string | null) => void;
|
||||
clearCheckoutSessionId: () => void;
|
||||
setCheckoutActionUrl: (url: string | null) => void;
|
||||
clearCheckoutActionUrl: () => void;
|
||||
}
|
||||
|
||||
const CheckoutWizardContext = createContext<CheckoutWizardContextType | null>(null);
|
||||
@@ -59,6 +63,7 @@ const initialState: CheckoutState = {
|
||||
error: null,
|
||||
paymentCompleted: false,
|
||||
checkoutSessionId: null,
|
||||
checkoutActionUrl: null,
|
||||
};
|
||||
|
||||
type CheckoutAction =
|
||||
@@ -71,7 +76,8 @@ type CheckoutAction =
|
||||
| { type: 'SET_LOADING'; payload: boolean }
|
||||
| { type: 'SET_ERROR'; payload: string | null }
|
||||
| { type: 'SET_PAYMENT_COMPLETED'; payload: boolean }
|
||||
| { type: 'SET_CHECKOUT_SESSION_ID'; payload: string | null };
|
||||
| { type: 'SET_CHECKOUT_SESSION_ID'; payload: string | null }
|
||||
| { type: 'SET_CHECKOUT_ACTION_URL'; payload: string | null };
|
||||
|
||||
function checkoutReducer(state: CheckoutState, action: CheckoutAction): CheckoutState {
|
||||
switch (action.type) {
|
||||
@@ -109,6 +115,8 @@ function checkoutReducer(state: CheckoutState, action: CheckoutAction): Checkout
|
||||
return { ...state, paymentCompleted: action.payload };
|
||||
case 'SET_CHECKOUT_SESSION_ID':
|
||||
return { ...state, checkoutSessionId: action.payload };
|
||||
case 'SET_CHECKOUT_ACTION_URL':
|
||||
return { ...state, checkoutActionUrl: action.payload };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
@@ -148,6 +156,7 @@ export function CheckoutWizardProvider({
|
||||
};
|
||||
|
||||
const checkoutSessionStorageKey = 'checkout-session-id';
|
||||
const checkoutActionStorageKey = 'checkout-action-url';
|
||||
|
||||
const [state, dispatch] = useReducer(checkoutReducer, customInitialState);
|
||||
|
||||
@@ -174,6 +183,11 @@ export function CheckoutWizardProvider({
|
||||
if (storedSession) {
|
||||
dispatch({ type: 'SET_CHECKOUT_SESSION_ID', payload: storedSession });
|
||||
}
|
||||
|
||||
const storedActionUrl = localStorage.getItem(checkoutActionStorageKey);
|
||||
if (storedActionUrl) {
|
||||
dispatch({ type: 'SET_CHECKOUT_ACTION_URL', payload: storedActionUrl });
|
||||
}
|
||||
}, [initialPackage]);
|
||||
|
||||
// Save state to localStorage whenever it changes
|
||||
@@ -199,6 +213,14 @@ export function CheckoutWizardProvider({
|
||||
}
|
||||
}, [state.checkoutSessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.checkoutActionUrl) {
|
||||
localStorage.setItem(checkoutActionStorageKey, state.checkoutActionUrl);
|
||||
} else {
|
||||
localStorage.removeItem(checkoutActionStorageKey);
|
||||
}
|
||||
}, [state.checkoutActionUrl]);
|
||||
|
||||
const selectPackage = useCallback((pkg: CheckoutPackage) => {
|
||||
dispatch({ type: 'SELECT_PACKAGE', payload: pkg });
|
||||
}, []);
|
||||
@@ -241,6 +263,7 @@ export function CheckoutWizardProvider({
|
||||
dispatch({ type: 'SET_ERROR', payload: null });
|
||||
dispatch({ type: 'SET_PAYMENT_COMPLETED', payload: false });
|
||||
dispatch({ type: 'SET_CHECKOUT_SESSION_ID', payload: null });
|
||||
dispatch({ type: 'SET_CHECKOUT_ACTION_URL', payload: null });
|
||||
}, []);
|
||||
|
||||
const setPaymentCompleted = useCallback((completed: boolean) => {
|
||||
@@ -253,6 +276,15 @@ export function CheckoutWizardProvider({
|
||||
|
||||
const clearCheckoutSessionId = useCallback(() => {
|
||||
dispatch({ type: 'SET_CHECKOUT_SESSION_ID', payload: null });
|
||||
dispatch({ type: 'SET_CHECKOUT_ACTION_URL', payload: null });
|
||||
}, []);
|
||||
|
||||
const setCheckoutActionUrl = useCallback((url: string | null) => {
|
||||
dispatch({ type: 'SET_CHECKOUT_ACTION_URL', payload: url });
|
||||
}, []);
|
||||
|
||||
const clearCheckoutActionUrl = useCallback(() => {
|
||||
dispatch({ type: 'SET_CHECKOUT_ACTION_URL', payload: null });
|
||||
}, []);
|
||||
|
||||
const cancelCheckout = useCallback(() => {
|
||||
@@ -277,9 +309,10 @@ export function CheckoutWizardProvider({
|
||||
// State aus localStorage entfernen
|
||||
localStorage.removeItem('checkout-wizard-state');
|
||||
localStorage.removeItem(checkoutSessionStorageKey);
|
||||
localStorage.removeItem(checkoutActionStorageKey);
|
||||
// Zur Package-Übersicht zurückleiten
|
||||
window.location.href = '/packages';
|
||||
}, [state, checkoutSessionStorageKey]);
|
||||
}, [state, checkoutActionStorageKey, checkoutSessionStorageKey]);
|
||||
|
||||
const value: CheckoutWizardContextType = {
|
||||
state,
|
||||
@@ -291,6 +324,7 @@ export function CheckoutWizardProvider({
|
||||
paypalConfig: paypal ?? null,
|
||||
paymentCompleted: state.paymentCompleted,
|
||||
checkoutSessionId: state.checkoutSessionId,
|
||||
checkoutActionUrl: state.checkoutActionUrl,
|
||||
selectPackage,
|
||||
setSelectedPackage,
|
||||
setAuthUser,
|
||||
@@ -306,6 +340,8 @@ export function CheckoutWizardProvider({
|
||||
setPaymentCompleted,
|
||||
setCheckoutSessionId,
|
||||
clearCheckoutSessionId,
|
||||
setCheckoutActionUrl,
|
||||
clearCheckoutActionUrl,
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -98,7 +98,7 @@ describe('CheckoutWizard auth step navigation guard', () => {
|
||||
expect(screen.queryByTestId('payment-step')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('only renders the next button on the payment step after the payment is completed', async () => {
|
||||
it('does not render the next button on the payment step', async () => {
|
||||
const paidPackage = { ...basePackage, id: 2, price: 99 };
|
||||
|
||||
render(
|
||||
@@ -113,12 +113,10 @@ describe('CheckoutWizard auth step navigation guard', () => {
|
||||
|
||||
await screen.findByTestId('payment-step');
|
||||
|
||||
const nextButtons = screen.getAllByRole('button', { name: 'checkout.next' });
|
||||
nextButtons.forEach((button) => expect(button).toBeDisabled());
|
||||
expect(screen.queryByRole('button', { name: 'checkout.next' })).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'mark-complete' }));
|
||||
|
||||
const activatedButtons = await screen.findAllByRole('button', { name: 'checkout.next' });
|
||||
activatedButtons.forEach((button) => expect(button).toBeEnabled());
|
||||
expect(screen.queryByRole('button', { name: 'checkout.next' })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useCheckoutWizard } from "../WizardContext";
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { CalendarDays, CheckCircle2, ClipboardList, LoaderCircle, MailCheck, QrCode, ShieldCheck, Smartphone, Sparkles, XCircle } from "lucide-react";
|
||||
import { AlertTriangle, CalendarDays, CheckCircle2, ClipboardList, LoaderCircle, MailCheck, QrCode, ShieldCheck, Smartphone, Sparkles, XCircle } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ConfirmationStepProps {
|
||||
@@ -25,8 +25,10 @@ export const ConfirmationStep: React.FC<ConfirmationStepProps> = ({ onViewProfil
|
||||
checkoutSessionId,
|
||||
setPaymentCompleted,
|
||||
clearCheckoutSessionId,
|
||||
checkoutActionUrl,
|
||||
goToStep,
|
||||
} = useCheckoutWizard();
|
||||
const [status, setStatus] = useState<'processing' | 'completed' | 'failed'>(
|
||||
const [status, setStatus] = useState<'processing' | 'completed' | 'failed' | 'action_required'>(
|
||||
checkoutSessionId ? 'processing' : 'completed',
|
||||
);
|
||||
const [elapsedMs, setElapsedMs] = useState(0);
|
||||
@@ -79,6 +81,15 @@ export const ConfirmationStep: React.FC<ConfirmationStepProps> = ({ onViewProfil
|
||||
badge: 'bg-rose-50 text-rose-700 border-rose-200',
|
||||
};
|
||||
}
|
||||
if (status === 'action_required') {
|
||||
return {
|
||||
label: t('checkout.confirmation_step.status_state.action_required'),
|
||||
body: t('checkout.confirmation_step.status_body_action_required'),
|
||||
tone: 'text-amber-600',
|
||||
icon: AlertTriangle,
|
||||
badge: 'bg-amber-50 text-amber-700 border-amber-200',
|
||||
};
|
||||
}
|
||||
return {
|
||||
label: t('checkout.confirmation_step.status_state.processing'),
|
||||
body: t('checkout.confirmation_step.status_body_processing'),
|
||||
@@ -88,7 +99,7 @@ export const ConfirmationStep: React.FC<ConfirmationStepProps> = ({ onViewProfil
|
||||
};
|
||||
}, [status, t]);
|
||||
|
||||
const checkSessionStatus = useCallback(async (): Promise<'processing' | 'completed' | 'failed'> => {
|
||||
const checkSessionStatus = useCallback(async (): Promise<'processing' | 'completed' | 'failed' | 'action_required'> => {
|
||||
if (!checkoutSessionId) {
|
||||
return 'completed';
|
||||
}
|
||||
@@ -114,6 +125,10 @@ export const ConfirmationStep: React.FC<ConfirmationStepProps> = ({ onViewProfil
|
||||
return 'completed';
|
||||
}
|
||||
|
||||
if (remoteStatus === 'requires_customer_action') {
|
||||
return 'action_required';
|
||||
}
|
||||
|
||||
if (remoteStatus === 'failed' || remoteStatus === 'cancelled') {
|
||||
clearCheckoutSessionId();
|
||||
return 'failed';
|
||||
@@ -204,10 +219,15 @@ export const ConfirmationStep: React.FC<ConfirmationStepProps> = ({ onViewProfil
|
||||
if (status === 'failed') {
|
||||
return { payment: false, email: false, access: false };
|
||||
}
|
||||
if (status === 'action_required') {
|
||||
return { payment: false, email: false, access: false };
|
||||
}
|
||||
return { payment: true, email: false, access: false };
|
||||
}, [status]);
|
||||
|
||||
const showManualActions = status === 'processing' && elapsedMs >= 30000;
|
||||
const showActionRequired = status === 'action_required';
|
||||
const showFailedActions = status === 'failed';
|
||||
const StatusIcon = statusCopy.icon;
|
||||
|
||||
const handleStatusRetry = useCallback(async () => {
|
||||
@@ -227,6 +247,18 @@ export const ConfirmationStep: React.FC<ConfirmationStepProps> = ({ onViewProfil
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleContinueCheckout = useCallback(() => {
|
||||
if (checkoutActionUrl && typeof window !== 'undefined') {
|
||||
window.open(checkoutActionUrl, '_blank', 'noopener');
|
||||
return;
|
||||
}
|
||||
goToStep('payment');
|
||||
}, [checkoutActionUrl, goToStep]);
|
||||
|
||||
const handleBackToPayment = useCallback(() => {
|
||||
goToStep('payment');
|
||||
}, [goToStep]);
|
||||
|
||||
return (
|
||||
<div className="grid gap-6 lg:grid-cols-[2fr_1fr]">
|
||||
<div className="space-y-6">
|
||||
@@ -314,6 +346,29 @@ export const ConfirmationStep: React.FC<ConfirmationStepProps> = ({ onViewProfil
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{showActionRequired && (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50/60 p-4 text-sm text-amber-900">
|
||||
<p>{t('checkout.confirmation_step.status_action_hint')}</p>
|
||||
<div className="mt-3 flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<Button type="button" onClick={handleContinueCheckout}>
|
||||
{t('checkout.confirmation_step.status_action_button')}
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" onClick={handleBackToPayment}>
|
||||
{t('checkout.confirmation_step.status_action_back')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{showFailedActions && (
|
||||
<div className="rounded-lg border border-rose-200 bg-rose-50/60 p-4 text-sm text-rose-900">
|
||||
<p>{t('checkout.confirmation_step.status_failed_hint')}</p>
|
||||
<div className="mt-3 flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<Button type="button" onClick={handleBackToPayment}>
|
||||
{t('checkout.confirmation_step.status_failed_back')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -108,6 +108,34 @@ export function resolveCheckoutLocale(rawLocale?: string | null): string {
|
||||
return short || 'en';
|
||||
}
|
||||
|
||||
const PAYPAL_LOCALE_FALLBACKS: Record<string, string> = {
|
||||
de: 'DE',
|
||||
en: 'US',
|
||||
};
|
||||
|
||||
function resolvePayPalLocale(rawLocale?: string | null): string | null {
|
||||
if (!rawLocale) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = rawLocale.trim();
|
||||
if (trimmed.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = trimmed.replace('_', '-');
|
||||
const parts = normalized.split('-').filter(Boolean);
|
||||
|
||||
if (parts.length >= 2) {
|
||||
return `${parts[0].toLowerCase()}_${parts[1].toUpperCase()}`;
|
||||
}
|
||||
|
||||
const language = parts[0].toLowerCase();
|
||||
const region = PAYPAL_LOCALE_FALLBACKS[language];
|
||||
|
||||
return region ? `${language}_${region}` : null;
|
||||
}
|
||||
|
||||
type PayPalSdkOptions = {
|
||||
clientId: string;
|
||||
currency: string;
|
||||
@@ -134,8 +162,9 @@ async function loadPayPalSdk(options: PayPalSdkOptions): Promise<typeof window.p
|
||||
components: 'buttons',
|
||||
});
|
||||
|
||||
if (options.locale) {
|
||||
params.set('locale', options.locale);
|
||||
const paypalLocale = resolvePayPalLocale(options.locale);
|
||||
if (paypalLocale) {
|
||||
params.set('locale', paypalLocale);
|
||||
}
|
||||
|
||||
const src = `${PAYPAL_SDK_BASE}?${params.toString()}`;
|
||||
@@ -173,6 +202,9 @@ export const PaymentStep: React.FC = () => {
|
||||
setPaymentCompleted,
|
||||
checkoutSessionId,
|
||||
setCheckoutSessionId,
|
||||
checkoutActionUrl,
|
||||
setCheckoutActionUrl,
|
||||
clearCheckoutActionUrl,
|
||||
paypalConfig,
|
||||
} = useCheckoutWizard();
|
||||
const [status, setStatus] = useState<PaymentStatus>('idle');
|
||||
@@ -292,18 +324,18 @@ export const PaymentStep: React.FC = () => {
|
||||
}
|
||||
}, [RateLimitHelper, selectedPackage, t, trackEvent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (couponCode && selectedPackage) {
|
||||
applyCoupon(couponCode);
|
||||
}
|
||||
}, [applyCoupon, couponCode, selectedPackage]);
|
||||
|
||||
useEffect(() => {
|
||||
setCouponPreview(null);
|
||||
setCouponNotice(null);
|
||||
setCouponError(null);
|
||||
}, [selectedPackage?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedPackage) {
|
||||
clearCheckoutActionUrl();
|
||||
}
|
||||
}, [clearCheckoutActionUrl, selectedPackage?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
@@ -343,6 +375,7 @@ export const PaymentStep: React.FC = () => {
|
||||
|
||||
setConsentError(null);
|
||||
setFreeActivationBusy(true);
|
||||
clearCheckoutActionUrl();
|
||||
|
||||
try {
|
||||
await refreshCheckoutCsrfToken();
|
||||
@@ -469,6 +502,7 @@ export const PaymentStep: React.FC = () => {
|
||||
setMessage(t('checkout.payment_step.paypal_preparing'));
|
||||
setPaymentCompleted(false);
|
||||
setCheckoutSessionId(null);
|
||||
setCheckoutActionUrl(null);
|
||||
|
||||
await refreshCheckoutCsrfToken();
|
||||
|
||||
@@ -498,6 +532,11 @@ export const PaymentStep: React.FC = () => {
|
||||
checkoutSessionRef.current = payload.checkout_session_id;
|
||||
}
|
||||
|
||||
const approveUrl = typeof payload?.approve_url === 'string' ? payload.approve_url : null;
|
||||
if (approveUrl) {
|
||||
setCheckoutActionUrl(approveUrl);
|
||||
}
|
||||
|
||||
const orderId = payload?.order_id;
|
||||
if (!orderId) {
|
||||
throw new Error('PayPal order ID missing.');
|
||||
@@ -524,6 +563,11 @@ export const PaymentStep: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload?.status === 'requires_customer_action') {
|
||||
nextStep();
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus('error');
|
||||
setMessage(t('checkout.payment_step.paypal_error'));
|
||||
} catch (error) {
|
||||
@@ -554,7 +598,7 @@ export const PaymentStep: React.FC = () => {
|
||||
paypalButtonsRef.current.close();
|
||||
}
|
||||
};
|
||||
}, [authUser, checkoutLocale, goToStep, isAuthenticated, isFree, paypalConfig?.client_id, paypalConfig?.currency, paypalConfig?.intent, paypalConfig?.locale, selectedPackage, setCheckoutSessionId, setPaymentCompleted, t, handlePayPalCapture, nextStep]);
|
||||
}, [authUser, checkoutLocale, goToStep, isAuthenticated, isFree, paypalConfig?.client_id, paypalConfig?.currency, paypalConfig?.intent, paypalConfig?.locale, selectedPackage, setCheckoutActionUrl, setCheckoutSessionId, setPaymentCompleted, t, handlePayPalCapture, nextStep]);
|
||||
|
||||
useEffect(() => {
|
||||
if (paypalActionsRef.current) {
|
||||
@@ -593,6 +637,12 @@ export const PaymentStep: React.FC = () => {
|
||||
}
|
||||
}, [couponPreview, trackEvent]);
|
||||
|
||||
const handleOpenPayPal = useCallback(() => {
|
||||
if (checkoutActionUrl && typeof window !== 'undefined') {
|
||||
window.open(checkoutActionUrl, '_blank', 'noopener');
|
||||
}
|
||||
}, [checkoutActionUrl]);
|
||||
|
||||
const openWithdrawalModal = useCallback(async () => {
|
||||
setShowWithdrawalModal(true);
|
||||
|
||||
@@ -771,6 +821,21 @@ export const PaymentStep: React.FC = () => {
|
||||
<p className="text-xs text-white/70 text-center">
|
||||
{t('checkout.payment_step.guided_cta_hint')}
|
||||
</p>
|
||||
{checkoutActionUrl && (
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full border-white/50 bg-transparent text-white hover:bg-white/15 hover:text-white"
|
||||
onClick={handleOpenPayPal}
|
||||
>
|
||||
{t('checkout.payment_step.resume_paypal')}
|
||||
</Button>
|
||||
<p className="text-xs text-white/70 text-center">
|
||||
{t('checkout.payment_step.resume_hint')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -88,6 +88,13 @@ return [
|
||||
'limits_title' => 'Ihre Paketdetails',
|
||||
'invoice_title' => 'Rechnung',
|
||||
'invoice_link' => 'Rechnung öffnen',
|
||||
'invoice_seller_label' => 'Anbieter',
|
||||
'invoice_customer_label' => 'Kunde',
|
||||
'invoice_item_label' => 'Leistung',
|
||||
'invoice_type_label' => 'Typ',
|
||||
'invoice_tax_label' => 'Steuer',
|
||||
'invoice_total_label' => 'Gesamt',
|
||||
'invoice_footer' => 'Dieser Beleg wurde automatisch von Fotospiel erstellt.',
|
||||
'cta' => 'Zum Event-Admin',
|
||||
'provider' => [
|
||||
'lemonsqueezy' => 'PayPal',
|
||||
|
||||
@@ -469,9 +469,9 @@
|
||||
"secure_payment_desc": "Sichere Zahlung ueber PayPal.",
|
||||
"lemonsqueezy_intro": "Starte den PayPal-Checkout direkt hier im Wizard – ganz ohne Seitenwechsel.",
|
||||
"guided_title": "Sichere Zahlung mit PayPal",
|
||||
"guided_body": "Bezahle schnell und sicher mit PayPal. Dein Paket wird nach der Bestaetigung sofort freigeschaltet.",
|
||||
"guided_body": "Bezahle schnell und sicher mit PayPal. Es oeffnet sich ein PayPal-Fenster – kehre danach hierher zurueck.",
|
||||
"lemonsqueezy_partner": "Powered by PayPal",
|
||||
"guided_cta_hint": "Sicher abgewickelt ueber PayPal",
|
||||
"guided_cta_hint": "Zahlung in PayPal abschliessen und hierher zurueckkehren.",
|
||||
"lemonsqueezy_preparing": "PayPal-Checkout wird vorbereitet...",
|
||||
"lemonsqueezy_overlay_ready": "Der PayPal-Checkout läuft jetzt in einem Overlay. Schließe die Zahlung dort ab und kehre anschließend hierher zurück.",
|
||||
"lemonsqueezy_ready": "PayPal-Checkout wurde in einem neuen Tab geöffnet. Schließe die Zahlung dort ab und kehre dann hierher zurück.",
|
||||
@@ -482,11 +482,88 @@
|
||||
"pay_with_lemonsqueezy": "Weiter mit PayPal",
|
||||
"paypal_partner": "Powered by PayPal",
|
||||
"paypal_preparing": "PayPal-Checkout wird vorbereitet...",
|
||||
"paypal_ready": "PayPal-Checkout ist bereit. Schließe die Zahlung ab, um fortzufahren.",
|
||||
"paypal_ready": "PayPal-Checkout ist bereit. Schliesse die Zahlung in PayPal ab und kehre hierher zurueck.",
|
||||
"paypal_error": "Der PayPal-Checkout konnte nicht gestartet werden. Bitte versuche es erneut.",
|
||||
"paypal_not_configured": "PayPal ist noch nicht konfiguriert. Bitte kontaktiere den Support.",
|
||||
"paypal_cancelled": "PayPal-Checkout wurde abgebrochen.",
|
||||
"paypal_disclaimer": "Zahlungen werden sicher über PayPal verarbeitet. Du erhältst im Anschluss eine Bestätigung."
|
||||
"paypal_cancelled": "PayPal-Checkout wurde abgebrochen. Du kannst es unten erneut versuchen.",
|
||||
"paypal_disclaimer": "Zahlungen werden sicher über PayPal verarbeitet. Du erhältst im Anschluss eine Bestätigung.",
|
||||
"resume_paypal": "Weiter in PayPal",
|
||||
"resume_hint": "Falls PayPal nicht geoeffnet wurde oder geschlossen wurde, hier im neuen Tab fortsetzen."
|
||||
},
|
||||
"confirmation_step": {
|
||||
"title": "Bestätigung",
|
||||
"subtitle": "Alles erledigt!",
|
||||
"description": "Dein Paket ist aktiviert. Prüfe deine E-Mails für Details.",
|
||||
"welcome": "Danke, dass du die Fotospiel App gewählt hast!",
|
||||
"package_summary": "Dein Paket <strong>{name}</strong> ist jetzt freigeschaltet. Du kannst sofort mit der Einrichtung loslegen.",
|
||||
"email_followup": "Wir haben dir gerade alle Details per E-Mail geschickt – inklusive Rechnung und den nächsten Schritten.",
|
||||
"hero_badge": "Checkout abgeschlossen",
|
||||
"hero_title": "Weiter geht's im Marketing-Dashboard",
|
||||
"hero_body": "Wir haben deinen Zugang aktiviert und synchronisieren PayPal. Mit diesen Aufgaben startest du direkt durch.",
|
||||
"hero_next": "Nutze den Button unten, um in deinen Kundenbereich zu wechseln – diese Übersicht kannst du jederzeit erneut öffnen.",
|
||||
"status_title": "Bestellstatus",
|
||||
"status_subtitle": "Wir schließen die Aktivierung ab und synchronisieren dein Konto.",
|
||||
"status_state": {
|
||||
"processing": "Wird bestätigt",
|
||||
"completed": "Bestätigt",
|
||||
"failed": "Aktion nötig",
|
||||
"action_required": "Aktion erforderlich"
|
||||
},
|
||||
"status_body_processing": "Wir synchronisieren dein Konto mit PayPal. Das kann einen Moment dauern.",
|
||||
"status_body_completed": "Alles ist bereit. Dein Konto ist vollständig freigeschaltet.",
|
||||
"status_body_failed": "Wir konnten den Kauf noch nicht bestätigen. Bitte prüfe den Status erneut oder kontaktiere den Support.",
|
||||
"status_body_action_required": "PayPal benoetigt noch eine kurze Bestaetigung. Schliesse den Checkout ab, um dein Paket zu aktivieren.",
|
||||
"status_manual_hint": "Dauert es zu lange? Du kannst den Status erneut prüfen oder die Seite aktualisieren.",
|
||||
"status_retry": "Status prüfen",
|
||||
"status_refresh": "Seite aktualisieren",
|
||||
"status_action_hint": "Wir benoetigen noch deine PayPal-Bestaetigung, bevor wir dein Paket aktivieren koennen.",
|
||||
"status_action_button": "PayPal-Checkout fortsetzen",
|
||||
"status_action_back": "Zurueck zur Zahlung",
|
||||
"status_failed_hint": "Die Zahlung wurde nicht abgeschlossen. Du kannst den Checkout erneut starten.",
|
||||
"status_failed_back": "Zurueck zur Zahlung",
|
||||
"status_items": {
|
||||
"payment": {
|
||||
"title": "Zahlung bestätigt",
|
||||
"body": "Deine PayPal-Zahlung war erfolgreich."
|
||||
},
|
||||
"email": {
|
||||
"title": "Beleg versendet",
|
||||
"body": "Die Bestätigungsmail ist unterwegs."
|
||||
},
|
||||
"access": {
|
||||
"title": "Zugang freigeschaltet",
|
||||
"body": "Dashboard und PWA stehen bereit."
|
||||
}
|
||||
},
|
||||
"onboarding_title": "Vorschau auf deine Onboarding-Schritte",
|
||||
"onboarding_subtitle": "Diese Aufgaben erwarten dich direkt nach dem Login.",
|
||||
"onboarding_badge": "Nächste Schritte",
|
||||
"onboarding_items": {
|
||||
"event": {
|
||||
"title": "Erstes Event anlegen",
|
||||
"body": "Titel, Datum und Highlights festlegen – alles bleibt anpassbar."
|
||||
},
|
||||
"invites": {
|
||||
"title": "QR-Einladungen aktivieren",
|
||||
"body": "Teile deinen Event-QR-Code oder den Shortcut-Link mit Gästen."
|
||||
},
|
||||
"tasks": {
|
||||
"title": "Fotoaufgaben planen",
|
||||
"body": "Nutze Vorlagen oder füge eigene kreative Aufgaben hinzu."
|
||||
}
|
||||
},
|
||||
"control_center_title": "Event Control Center (PWA)",
|
||||
"control_center_body": "Alle Live-Aufgaben erledigst du später im Control Center – optimiert für Mobilgeräte und offlinefähig.",
|
||||
"control_center_hint": "Installiere die PWA direkt aus dem Dashboard.",
|
||||
"package_title": "Dein Paket",
|
||||
"package_body": "Dein Paket ist aktiviert und sofort einsatzbereit.",
|
||||
"package_label": "Aktiviertes Paket",
|
||||
"actions_title": "Nächste Schritte",
|
||||
"actions_body": "Zum Adminbereich wechseln oder Profildaten prüfen.",
|
||||
"package_activated": "Ihr Paket '{name}' ist aktiviert.",
|
||||
"email_sent": "Wir haben Ihnen eine Bestätigungs-E-Mail gesendet.",
|
||||
"open_profile": "Profil öffnen",
|
||||
"to_admin": "Zum Admin-Bereich"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +87,13 @@ return [
|
||||
'limits_title' => 'Your package details',
|
||||
'invoice_title' => 'Invoice',
|
||||
'invoice_link' => 'Open invoice',
|
||||
'invoice_seller_label' => 'Seller',
|
||||
'invoice_customer_label' => 'Customer',
|
||||
'invoice_item_label' => 'Item',
|
||||
'invoice_type_label' => 'Type',
|
||||
'invoice_tax_label' => 'Tax',
|
||||
'invoice_total_label' => 'Total',
|
||||
'invoice_footer' => 'This receipt was generated automatically by Fotospiel.',
|
||||
'cta' => 'Open Event Admin',
|
||||
'provider' => [
|
||||
'lemonsqueezy' => 'PayPal',
|
||||
|
||||
@@ -465,9 +465,9 @@
|
||||
"secure_payment_desc": "Secure payment with PayPal.",
|
||||
"lemonsqueezy_intro": "Start the PayPal checkout right here in the wizard - no page changes required.",
|
||||
"guided_title": "Secure checkout with PayPal",
|
||||
"guided_body": "Pay quickly and securely with PayPal. Your package unlocks immediately after confirmation.",
|
||||
"guided_body": "Pay quickly and securely with PayPal. A PayPal window opens; return here after approval to finish.",
|
||||
"lemonsqueezy_partner": "Powered by PayPal",
|
||||
"guided_cta_hint": "Securely processed via PayPal",
|
||||
"guided_cta_hint": "Complete the payment in PayPal and return here to finish.",
|
||||
"lemonsqueezy_preparing": "Preparing PayPal checkout...",
|
||||
"lemonsqueezy_overlay_ready": "PayPal checkout is running in a secure overlay. Complete the payment there and then continue here.",
|
||||
"lemonsqueezy_ready": "PayPal checkout opened in a new tab. Complete the payment and then continue here.",
|
||||
@@ -478,11 +478,88 @@
|
||||
"pay_with_lemonsqueezy": "Continue with PayPal",
|
||||
"paypal_partner": "Powered by PayPal",
|
||||
"paypal_preparing": "Preparing PayPal checkout...",
|
||||
"paypal_ready": "PayPal checkout is ready. Complete the payment to continue.",
|
||||
"paypal_ready": "PayPal checkout is ready. Complete the payment in PayPal and return here.",
|
||||
"paypal_error": "We could not start the PayPal checkout. Please try again.",
|
||||
"paypal_not_configured": "PayPal checkout is not configured yet. Please contact support.",
|
||||
"paypal_cancelled": "PayPal checkout was cancelled.",
|
||||
"paypal_disclaimer": "Payments are processed securely by PayPal. You will receive a receipt after purchase."
|
||||
"paypal_cancelled": "PayPal checkout was cancelled. You can try again below.",
|
||||
"paypal_disclaimer": "Payments are processed securely by PayPal. You will receive a receipt after purchase.",
|
||||
"resume_paypal": "Continue in PayPal",
|
||||
"resume_hint": "If PayPal did not open or you closed it, continue in a new tab."
|
||||
},
|
||||
"confirmation_step": {
|
||||
"title": "Confirmation",
|
||||
"subtitle": "All Done!",
|
||||
"description": "Your package is activated. Check your email for details.",
|
||||
"welcome": "Thank you for choosing the Fotospiel App!",
|
||||
"package_summary": "Your <strong>{name}</strong> package is now active. You're ready to get everything set up.",
|
||||
"email_followup": "We've just sent a confirmation email with your receipt and the next steps.",
|
||||
"hero_badge": "Checkout complete",
|
||||
"hero_title": "You're ready for the Marketing Dashboard",
|
||||
"hero_body": "We activated your access and are syncing PayPal. Follow the checklist below to launch your first event.",
|
||||
"hero_next": "Use the button below whenever you're ready to jump into your customer area—this summary is always available.",
|
||||
"status_title": "Purchase status",
|
||||
"status_subtitle": "We are finishing the handoff and syncing your account.",
|
||||
"status_state": {
|
||||
"processing": "Finalising",
|
||||
"completed": "Confirmed",
|
||||
"failed": "Needs attention",
|
||||
"action_required": "Action required"
|
||||
},
|
||||
"status_body_processing": "We are syncing your account with PayPal. This can take a minute.",
|
||||
"status_body_completed": "Everything is ready. Your account is fully unlocked.",
|
||||
"status_body_failed": "We could not confirm the purchase yet. Please try again or contact support.",
|
||||
"status_body_action_required": "PayPal still needs a quick confirmation. Complete the checkout to activate your package.",
|
||||
"status_manual_hint": "Still waiting? You can re-check the status or refresh the page.",
|
||||
"status_retry": "Check status",
|
||||
"status_refresh": "Refresh page",
|
||||
"status_action_hint": "We still need PayPal approval before we can activate your package.",
|
||||
"status_action_button": "Continue PayPal checkout",
|
||||
"status_action_back": "Back to payment",
|
||||
"status_failed_hint": "The payment did not complete. You can start the checkout again.",
|
||||
"status_failed_back": "Back to payment",
|
||||
"status_items": {
|
||||
"payment": {
|
||||
"title": "Payment confirmed",
|
||||
"body": "Your PayPal payment was successful."
|
||||
},
|
||||
"email": {
|
||||
"title": "Receipt sent",
|
||||
"body": "A confirmation email is on its way."
|
||||
},
|
||||
"access": {
|
||||
"title": "Access unlocked",
|
||||
"body": "Your dashboard and PWA access are active."
|
||||
}
|
||||
},
|
||||
"onboarding_title": "Preview your onboarding steps",
|
||||
"onboarding_subtitle": "These are the first tasks you'll see after logging in.",
|
||||
"onboarding_badge": "Next steps",
|
||||
"onboarding_items": {
|
||||
"event": {
|
||||
"title": "Create your first event",
|
||||
"body": "Set title, date, and highlights. You can adjust everything later."
|
||||
},
|
||||
"invites": {
|
||||
"title": "Activate QR invites",
|
||||
"body": "Share your event QR code or shortcut link with guests."
|
||||
},
|
||||
"tasks": {
|
||||
"title": "Plan photo tasks",
|
||||
"body": "Pick from the library or add your own creative prompts."
|
||||
}
|
||||
},
|
||||
"control_center_title": "Event Control Center (PWA)",
|
||||
"control_center_body": "You handle live moderation and uploads in the Control Center — mobile-first and offline-ready.",
|
||||
"control_center_hint": "Install the PWA directly from the dashboard.",
|
||||
"package_title": "Your package",
|
||||
"package_body": "Your plan is active and ready to use.",
|
||||
"package_label": "Activated package",
|
||||
"actions_title": "Next actions",
|
||||
"actions_body": "Jump into your admin area or update profile details.",
|
||||
"package_activated": "Your package '{name}' is activated.",
|
||||
"email_sent": "We have sent you a confirmation email.",
|
||||
"open_profile": "Open Profile",
|
||||
"to_admin": "To Admin Area"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
151
resources/views/billing/receipt.blade.php
Normal file
151
resources/views/billing/receipt.blade.php
Normal file
@@ -0,0 +1,151 @@
|
||||
<!doctype html>
|
||||
<html lang="{{ app()->getLocale() }}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{{ __('emails.purchase.invoice_title') }}</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
font-family: Helvetica, Arial, sans-serif;
|
||||
color: #0f172a;
|
||||
margin: 0;
|
||||
padding: 32px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.header h1 {
|
||||
font-size: 22px;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.muted {
|
||||
color: #64748b;
|
||||
}
|
||||
.section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.grid {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
}
|
||||
.card {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
flex: 1;
|
||||
}
|
||||
.label {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
th, td {
|
||||
text-align: left;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
th {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.totals {
|
||||
margin-top: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
.totals td {
|
||||
border-bottom: none;
|
||||
padding: 6px 0;
|
||||
}
|
||||
.totals .total {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.footer {
|
||||
margin-top: 24px;
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div>
|
||||
<h1>{{ __('emails.purchase.invoice_title') }}</h1>
|
||||
<div class="muted">{{ __('emails.purchase.order_label') }}: {{ $orderId }}</div>
|
||||
<div class="muted">{{ __('emails.purchase.date_label') }}: {{ $purchaseDate }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="label">{{ __('emails.purchase.provider_label') }}</div>
|
||||
<div>{{ $providerLabel }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section grid">
|
||||
<div class="card">
|
||||
<div class="label">{{ __('emails.purchase.invoice_seller_label') }}</div>
|
||||
<div>{{ $companyName }}</div>
|
||||
<div class="muted">{{ $companyEmail }}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="label">{{ __('emails.purchase.invoice_customer_label') }}</div>
|
||||
<div>{{ $buyerName }}</div>
|
||||
@if ($buyerEmail)
|
||||
<div class="muted">{{ $buyerEmail }}</div>
|
||||
@endif
|
||||
@if ($buyerAddress)
|
||||
<div class="muted">{{ $buyerAddress }}</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ __('emails.purchase.invoice_item_label') }}</th>
|
||||
<th>{{ __('emails.purchase.invoice_type_label') }}</th>
|
||||
<th style="text-align:right;">{{ __('emails.purchase.price_label') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>{{ $packageName }}</td>
|
||||
<td>{{ $packageTypeLabel }}</td>
|
||||
<td style="text-align:right;">{{ $amountFormatted }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<table class="totals">
|
||||
<tbody>
|
||||
@if ($taxFormatted)
|
||||
<tr>
|
||||
<td class="muted">{{ __('emails.purchase.invoice_tax_label') }}</td>
|
||||
<td style="text-align:right;">{{ $taxFormatted }}</td>
|
||||
</tr>
|
||||
@endif
|
||||
<tr>
|
||||
<td class="total">{{ __('emails.purchase.invoice_total_label') }}</td>
|
||||
<td class="total" style="text-align:right;">{{ $totalFormatted }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
{{ __('emails.purchase.invoice_footer') }}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -461,6 +461,8 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
|
||||
Route::prefix('billing')->middleware('tenant.admin')->group(function () {
|
||||
Route::get('transactions', [TenantBillingController::class, 'transactions'])
|
||||
->name('tenant.billing.transactions');
|
||||
Route::get('transactions/{purchase}/receipt', [TenantBillingController::class, 'receipt'])
|
||||
->name('tenant.billing.transactions.receipt');
|
||||
Route::get('addons', [TenantBillingController::class, 'addons'])
|
||||
->name('tenant.billing.addons');
|
||||
Route::post('portal', [TenantBillingController::class, 'portal'])
|
||||
@@ -469,6 +471,7 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
|
||||
|
||||
Route::prefix('tenant/billing')->middleware('tenant.admin')->group(function () {
|
||||
Route::get('transactions', [TenantBillingController::class, 'transactions']);
|
||||
Route::get('transactions/{purchase}/receipt', [TenantBillingController::class, 'receipt']);
|
||||
Route::get('addons', [TenantBillingController::class, 'addons']);
|
||||
Route::post('portal', [TenantBillingController::class, 'portal']);
|
||||
});
|
||||
|
||||
27
tests/Feature/Api/Tenant/BillingReceiptTest.php
Normal file
27
tests/Feature/Api/Tenant/BillingReceiptTest.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Api\Tenant;
|
||||
|
||||
use App\Models\Package;
|
||||
use App\Models\PackagePurchase;
|
||||
use Tests\Feature\Tenant\TenantTestCase;
|
||||
|
||||
class BillingReceiptTest extends TenantTestCase
|
||||
{
|
||||
public function test_receipt_endpoint_returns_pdf(): void
|
||||
{
|
||||
$package = Package::factory()->create(['name' => 'Starter']);
|
||||
$purchase = PackagePurchase::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'package_id' => $package->id,
|
||||
'provider' => 'paypal',
|
||||
'provider_id' => 'ORDER-123',
|
||||
'price' => 49.0,
|
||||
]);
|
||||
|
||||
$response = $this->authenticatedRequest('GET', '/api/v1/tenant/billing/transactions/'.$purchase->id.'/receipt');
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertHeader('Content-Type', 'application/pdf');
|
||||
}
|
||||
}
|
||||
@@ -2,49 +2,30 @@
|
||||
|
||||
namespace Tests\Feature\Api\Tenant;
|
||||
|
||||
use Illuminate\Http\Client\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use App\Models\Package;
|
||||
use App\Models\PackagePurchase;
|
||||
use Tests\Feature\Tenant\TenantTestCase;
|
||||
|
||||
class BillingTransactionsTest extends TenantTestCase
|
||||
{
|
||||
public function test_transactions_endpoint_creates_missing_lemonsqueezy_customer_id(): void
|
||||
public function test_transactions_endpoint_returns_package_purchases(): void
|
||||
{
|
||||
Http::fake(function (Request $request) {
|
||||
$path = parse_url($request->url(), PHP_URL_PATH);
|
||||
|
||||
if ($path === '/customers' && $request->method() === 'POST') {
|
||||
return Http::response([
|
||||
'data' => ['id' => 'cus_456'],
|
||||
], 200);
|
||||
}
|
||||
|
||||
if ($path === '/transactions' && $request->method() === 'GET') {
|
||||
return Http::response([
|
||||
'data' => [],
|
||||
'meta' => [
|
||||
'pagination' => [
|
||||
'next' => null,
|
||||
'previous' => null,
|
||||
'has_more' => false,
|
||||
],
|
||||
],
|
||||
], 200);
|
||||
}
|
||||
|
||||
return Http::response([], 404);
|
||||
});
|
||||
|
||||
$this->tenant->forceFill(['lemonsqueezy_customer_id' => null])->save();
|
||||
$package = Package::factory()->create(['name' => 'Starter']);
|
||||
$purchase = PackagePurchase::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'package_id' => $package->id,
|
||||
'provider' => 'paypal',
|
||||
'provider_id' => 'ORDER-123',
|
||||
'price' => 49.0,
|
||||
]);
|
||||
|
||||
$response = $this->authenticatedRequest('GET', '/api/v1/tenant/billing/transactions');
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonPath('data', []);
|
||||
|
||||
$this->assertDatabaseHas('tenants', [
|
||||
'id' => $this->tenant->id,
|
||||
'lemonsqueezy_customer_id' => 'cus_456',
|
||||
$response->assertJsonFragment([
|
||||
'id' => $purchase->id,
|
||||
'provider' => 'paypal',
|
||||
'provider_id' => 'ORDER-123',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,5 +93,6 @@ class TenantPackageOverviewTest extends TenantTestCase
|
||||
|
||||
$this->assertSame($package->id, $payload['active_package']['package_id']);
|
||||
$this->assertSame(['custom_branding'], $payload['active_package']['package_limits']['features']);
|
||||
$this->assertSame(1, $payload['active_package']['remaining_events']);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user