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 |
|
| overflow-ellipsis | text-ellipsis |
|
||||||
| decoration-slice | box-decoration-slice |
|
| decoration-slice | box-decoration-slice |
|
||||||
| decoration-clone | box-decoration-clone |
|
| 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>
|
</laravel-boost-guidelines>
|
||||||
|
|
||||||
## Issue Tracking
|
## Issue Tracking
|
||||||
|
|||||||
@@ -24,12 +24,6 @@ class CouponPreviewController extends Controller
|
|||||||
|
|
||||||
$package = Package::findOrFail($data['package_id']);
|
$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;
|
$tenant = Auth::user()?->tenant;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -4,18 +4,21 @@ namespace App\Http\Controllers\Api;
|
|||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\EventPackageAddon;
|
use App\Models\EventPackageAddon;
|
||||||
|
use App\Models\PackagePurchase;
|
||||||
use App\Services\LemonSqueezy\Exceptions\LemonSqueezyException;
|
use App\Services\LemonSqueezy\Exceptions\LemonSqueezyException;
|
||||||
use App\Services\LemonSqueezy\LemonSqueezyOrderService;
|
|
||||||
use App\Services\LemonSqueezy\LemonSqueezySubscriptionService;
|
use App\Services\LemonSqueezy\LemonSqueezySubscriptionService;
|
||||||
|
use Dompdf\Dompdf;
|
||||||
|
use Dompdf\Options;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
class TenantBillingController extends Controller
|
class TenantBillingController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly LemonSqueezyOrderService $orders,
|
|
||||||
private readonly LemonSqueezySubscriptionService $subscriptions,
|
private readonly LemonSqueezySubscriptionService $subscriptions,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -30,45 +33,45 @@ class TenantBillingController extends Controller
|
|||||||
], 404);
|
], 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $tenant->lemonsqueezy_customer_id) {
|
$perPage = max(1, min((int) $request->query('per_page', 25), 100));
|
||||||
return response()->json([
|
$page = max(1, (int) $request->query('page', 1));
|
||||||
'data' => [],
|
$locale = $request->user()?->preferred_locale ?? app()->getLocale();
|
||||||
'meta' => [
|
|
||||||
'next' => null,
|
|
||||||
'previous' => null,
|
|
||||||
'has_more' => false,
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$cursor = $request->query('cursor');
|
$paginator = PackagePurchase::query()
|
||||||
$perPage = (int) $request->query('per_page', 25);
|
->where('tenant_id', $tenant->id)
|
||||||
|
->with(['package'])
|
||||||
|
->orderByDesc('purchased_at')
|
||||||
|
->orderByDesc('id')
|
||||||
|
->paginate($perPage, ['*'], 'page', $page);
|
||||||
|
|
||||||
$query = [
|
$data = $paginator->getCollection()->map(function (PackagePurchase $purchase) use ($locale) {
|
||||||
'per_page' => max(1, min($perPage, 100)),
|
$totals = $this->resolvePurchaseTotals($purchase);
|
||||||
];
|
$transactionId = $purchase->provider_id ? (string) $purchase->provider_id : (string) $purchase->getKey();
|
||||||
|
|
||||||
if ($cursor) {
|
return [
|
||||||
$query['after'] = $cursor;
|
'id' => $purchase->getKey(),
|
||||||
}
|
'status' => $purchase->refunded ? 'refunded' : 'completed',
|
||||||
|
'amount' => $totals['total'],
|
||||||
try {
|
'currency' => $totals['currency'],
|
||||||
$result = $this->orders->listForCustomer($tenant->lemonsqueezy_customer_id, $query);
|
'tax' => $totals['tax'],
|
||||||
} catch (\Throwable $exception) {
|
'provider' => $purchase->provider ?? 'paypal',
|
||||||
Log::warning('Failed to load Lemon Squeezy transactions', [
|
'provider_id' => $transactionId,
|
||||||
'tenant_id' => $tenant->id,
|
'package_name' => $this->resolvePackageName($purchase, $locale),
|
||||||
'error' => $exception->getMessage(),
|
'purchased_at' => $purchase->purchased_at?->toIso8601String(),
|
||||||
]);
|
'receipt_url' => route('api.v1.tenant.billing.transactions.receipt', [
|
||||||
|
'purchase' => $purchase->getKey(),
|
||||||
return response()->json([
|
], absolute: false),
|
||||||
'data' => [],
|
];
|
||||||
'message' => 'Failed to load Lemon Squeezy transactions.',
|
})->values();
|
||||||
], 502);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'data' => $result['data'],
|
'data' => $data,
|
||||||
'meta' => $result['meta'],
|
'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,
|
'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:',
|
'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) {
|
if ($matomoOrigin) {
|
||||||
$scriptSources[] = $matomoOrigin;
|
$scriptSources[] = $matomoOrigin;
|
||||||
$connectSources[] = $matomoOrigin;
|
$connectSources[] = $matomoOrigin;
|
||||||
@@ -90,6 +102,18 @@ class ContentSecurityPolicy
|
|||||||
$isDev = app()->environment(['local', 'development']) || config('app.debug');
|
$isDev = app()->environment(['local', 'development']) || config('app.debug');
|
||||||
|
|
||||||
if ($isDev) {
|
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 = [
|
$devHosts = [
|
||||||
'http://fotospiel-app.test:5173',
|
'http://fotospiel-app.test:5173',
|
||||||
'http://127.0.0.1:5173',
|
'http://127.0.0.1:5173',
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ class TenantPackage extends Model
|
|||||||
public function getRemainingEventsAttribute(): ?int
|
public function getRemainingEventsAttribute(): ?int
|
||||||
{
|
{
|
||||||
if (! $this->package->isReseller()) {
|
if (! $this->package->isReseller()) {
|
||||||
return 0;
|
return max(0, 1 - (int) $this->used_events);
|
||||||
}
|
}
|
||||||
|
|
||||||
$max = $this->package->max_events_per_year;
|
$max = $this->package->max_events_per_year;
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ namespace App\Services\Coupons;
|
|||||||
|
|
||||||
use App\Enums\CouponStatus;
|
use App\Enums\CouponStatus;
|
||||||
use App\Enums\CouponType;
|
use App\Enums\CouponType;
|
||||||
use App\Models\CheckoutSession;
|
|
||||||
use App\Models\Coupon;
|
use App\Models\Coupon;
|
||||||
use App\Models\CouponRedemption;
|
use App\Models\CouponRedemption;
|
||||||
use App\Models\Package;
|
use App\Models\Package;
|
||||||
@@ -36,12 +35,6 @@ class CouponService
|
|||||||
|
|
||||||
public function ensureCouponCanBeApplied(Coupon $coupon, Package $package, ?Tenant $tenant = null, ?string $provider = null): void
|
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) {
|
if (! $coupon->enabled_for_checkout) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'code' => __('marketing.coupon.errors.disabled'),
|
'code' => __('marketing.coupon.errors.disabled'),
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ class PayPalOrderService
|
|||||||
$headers['PayPal-Request-Id'] = $requestId;
|
$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
|
public function resolveApproveUrl(array $payload): ?string
|
||||||
|
|||||||
@@ -3,7 +3,10 @@
|
|||||||
"codex"
|
"codex"
|
||||||
],
|
],
|
||||||
"editors": [
|
"editors": [
|
||||||
|
"codex",
|
||||||
"vscode"
|
"vscode"
|
||||||
],
|
],
|
||||||
"guidelines": []
|
"guidelines": [
|
||||||
|
"filament/filament"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -687,12 +687,12 @@
|
|||||||
"secure_payment_desc": "Sichere Zahlung ueber PayPal.",
|
"secure_payment_desc": "Sichere Zahlung ueber PayPal.",
|
||||||
"lemonsqueezy_intro": "Starte den PayPal-Checkout direkt hier im Wizard – ganz ohne Seitenwechsel.",
|
"lemonsqueezy_intro": "Starte den PayPal-Checkout direkt hier im Wizard – ganz ohne Seitenwechsel.",
|
||||||
"guided_title": "Sichere Zahlung mit PayPal",
|
"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",
|
"lemonsqueezy_partner": "Powered by PayPal",
|
||||||
"trust_secure": "Verschlüsselte Zahlung",
|
"trust_secure": "Verschlüsselte Zahlung",
|
||||||
"trust_tax": "Automatische Steuerberechnung",
|
"trust_tax": "Automatische Steuerberechnung",
|
||||||
"trust_support": "Support in Minuten",
|
"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.",
|
"toast_success": "Zahlung erfolgreich – wir bereiten alles vor.",
|
||||||
"lemonsqueezy_preparing": "PayPal-Checkout wird vorbereitet...",
|
"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_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...",
|
"processing_confirmation": "Zahlung eingegangen. Wir schliessen deine Bestellung ab...",
|
||||||
"paypal_partner": "Powered by PayPal",
|
"paypal_partner": "Powered by PayPal",
|
||||||
"paypal_preparing": "PayPal-Checkout wird vorbereitet...",
|
"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_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_not_configured": "PayPal ist noch nicht konfiguriert. Bitte kontaktiere den Support.",
|
||||||
"paypal_cancelled": "PayPal-Checkout wurde abgebrochen.",
|
"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."
|
"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": {
|
"confirmation_step": {
|
||||||
"title": "Bestätigung",
|
"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.",
|
"email_followup": "Wir haben dir gerade alle Details per E-Mail geschickt – inklusive Rechnung und den nächsten Schritten.",
|
||||||
"hero_badge": "Checkout abgeschlossen",
|
"hero_badge": "Checkout abgeschlossen",
|
||||||
"hero_title": "Weiter geht's im Marketing-Dashboard",
|
"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.",
|
"hero_next": "Nutze den Button unten, um in deinen Kundenbereich zu wechseln – diese Übersicht kannst du jederzeit erneut öffnen.",
|
||||||
"status_title": "Bestellstatus",
|
"status_title": "Bestellstatus",
|
||||||
"status_subtitle": "Wir schließen die Aktivierung ab und synchronisieren dein Konto.",
|
"status_subtitle": "Wir schließen die Aktivierung ab und synchronisieren dein Konto.",
|
||||||
"status_state": {
|
"status_state": {
|
||||||
"processing": "Wird bestätigt",
|
"processing": "Wird bestätigt",
|
||||||
"completed": "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_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_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_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_manual_hint": "Dauert es zu lange? Du kannst den Status erneut prüfen oder die Seite aktualisieren.",
|
||||||
"status_retry": "Status prüfen",
|
"status_retry": "Status prüfen",
|
||||||
"status_refresh": "Seite aktualisieren",
|
"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": {
|
"status_items": {
|
||||||
"payment": {
|
"payment": {
|
||||||
"title": "Zahlung bestätigt",
|
"title": "Zahlung bestätigt",
|
||||||
|
|||||||
@@ -685,12 +685,12 @@
|
|||||||
"secure_payment_desc": "Secure payment with PayPal.",
|
"secure_payment_desc": "Secure payment with PayPal.",
|
||||||
"lemonsqueezy_intro": "Start the PayPal checkout right here in the wizard - no page changes required.",
|
"lemonsqueezy_intro": "Start the PayPal checkout right here in the wizard - no page changes required.",
|
||||||
"guided_title": "Secure checkout with PayPal",
|
"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",
|
"lemonsqueezy_partner": "Powered by PayPal",
|
||||||
"trust_secure": "Encrypted payment",
|
"trust_secure": "Encrypted payment",
|
||||||
"trust_tax": "Automatic tax handling",
|
"trust_tax": "Automatic tax handling",
|
||||||
"trust_support": "Live support within minutes",
|
"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.",
|
"toast_success": "Payment received – setting everything up for you.",
|
||||||
"lemonsqueezy_preparing": "Preparing PayPal checkout...",
|
"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_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...",
|
"processing_confirmation": "Payment received. Finalising your order...",
|
||||||
"paypal_partner": "Powered by PayPal",
|
"paypal_partner": "Powered by PayPal",
|
||||||
"paypal_preparing": "Preparing PayPal checkout...",
|
"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_error": "We could not start the PayPal checkout. Please try again.",
|
||||||
"paypal_not_configured": "PayPal checkout is not configured yet. Please contact support.",
|
"paypal_not_configured": "PayPal checkout is not configured yet. Please contact support.",
|
||||||
"paypal_cancelled": "PayPal checkout was cancelled.",
|
"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."
|
"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": {
|
"confirmation_step": {
|
||||||
"title": "Confirmation",
|
"title": "Confirmation",
|
||||||
@@ -751,21 +753,28 @@
|
|||||||
"email_followup": "We've just sent a confirmation email with your receipt and the next steps.",
|
"email_followup": "We've just sent a confirmation email with your receipt and the next steps.",
|
||||||
"hero_badge": "Checkout complete",
|
"hero_badge": "Checkout complete",
|
||||||
"hero_title": "You're ready for the Marketing Dashboard",
|
"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.",
|
"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_title": "Purchase status",
|
||||||
"status_subtitle": "We are finishing the handoff and syncing your account.",
|
"status_subtitle": "We are finishing the handoff and syncing your account.",
|
||||||
"status_state": {
|
"status_state": {
|
||||||
"processing": "Finalising",
|
"processing": "Finalising",
|
||||||
"completed": "Confirmed",
|
"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_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_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_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_manual_hint": "Still waiting? You can re-check the status or refresh the page.",
|
||||||
"status_retry": "Check status",
|
"status_retry": "Check status",
|
||||||
"status_refresh": "Refresh page",
|
"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": {
|
"status_items": {
|
||||||
"payment": {
|
"payment": {
|
||||||
"title": "Payment confirmed",
|
"title": "Payment confirmed",
|
||||||
|
|||||||
@@ -870,6 +870,11 @@ html.guest-theme.dark {
|
|||||||
background-color: #000;
|
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 {
|
@theme inline {
|
||||||
--animate-spin:
|
--animate-spin:
|
||||||
spin 2s linear infinite;
|
spin 2s linear infinite;
|
||||||
|
|||||||
@@ -571,6 +571,19 @@ export type LemonSqueezyOrderSummary = {
|
|||||||
tax?: number | null;
|
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 = {
|
export type TenantAddonEventSummary = {
|
||||||
id: number;
|
id: number;
|
||||||
slug: string;
|
slug: string;
|
||||||
@@ -1125,21 +1138,21 @@ export function normalizeTenantPackage(pkg: JsonValue): TenantPackageSummary {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeLemonSqueezyOrder(entry: JsonValue): LemonSqueezyOrderSummary {
|
function normalizeTenantBillingTransaction(entry: JsonValue): TenantBillingTransactionSummary {
|
||||||
const amountValue = entry.amount ?? entry.grand_total ?? (entry.totals && entry.totals.grand_total);
|
const idValue = (entry as { id?: unknown }).id;
|
||||||
const taxValue = entry.tax ?? (entry.totals && entry.totals.tax_total);
|
const amountValue = (entry as { amount?: unknown }).amount;
|
||||||
|
const taxValue = (entry as { tax?: unknown }).tax;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: typeof entry.id === 'string' ? entry.id : entry.id ? String(entry.id) : null,
|
id: typeof idValue === 'string' || typeof idValue === 'number' ? idValue : idValue ? String(idValue) : null,
|
||||||
status: entry.status ?? null,
|
status: typeof (entry as { status?: unknown }).status === 'string' ? (entry as { status?: unknown }).status : null,
|
||||||
amount: amountValue !== undefined && amountValue !== null ? Number(amountValue) : null,
|
amount: amountValue !== undefined && amountValue !== null ? Number(amountValue) : null,
|
||||||
currency: entry.currency ?? entry.currency_code ?? 'EUR',
|
currency: typeof (entry as { currency?: unknown }).currency === 'string' ? (entry as { currency?: unknown }).currency : 'EUR',
|
||||||
origin: entry.origin ?? null,
|
provider: typeof (entry as { provider?: unknown }).provider === 'string' ? (entry as { provider?: unknown }).provider : null,
|
||||||
checkout_id: entry.checkout_id ?? (entry.details?.checkout_id ?? null),
|
provider_id: typeof (entry as { provider_id?: unknown }).provider_id === 'string' ? (entry as { provider_id?: unknown }).provider_id : null,
|
||||||
created_at: entry.created_at ?? null,
|
package_name: typeof (entry as { package_name?: unknown }).package_name === 'string' ? (entry as { package_name?: unknown }).package_name : null,
|
||||||
updated_at: entry.updated_at ?? null,
|
purchased_at: typeof (entry as { purchased_at?: unknown }).purchased_at === 'string' ? (entry as { purchased_at?: unknown }).purchased_at : null,
|
||||||
receipt_url: entry.receipt_url ?? entry.invoice_url ?? null,
|
receipt_url: typeof (entry as { receipt_url?: unknown }).receipt_url === 'string' ? (entry as { receipt_url?: unknown }).receipt_url : null,
|
||||||
grand_total: entry.grand_total !== undefined && entry.grand_total !== null ? Number(entry.grand_total) : null,
|
|
||||||
tax: taxValue !== undefined && taxValue !== null ? Number(taxValue) : null,
|
tax: taxValue !== undefined && taxValue !== null ? Number(taxValue) : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -2731,32 +2744,43 @@ export async function downloadTenantDataExport(downloadUrl: string): Promise<Blo
|
|||||||
return response.blob();
|
return response.blob();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getTenantLemonSqueezyTransactions(cursor?: string): Promise<{
|
export async function downloadTenantBillingReceipt(receiptUrl: string): Promise<Blob> {
|
||||||
data: LemonSqueezyOrderSummary[];
|
const response = await authorizedFetch(receiptUrl, {
|
||||||
nextCursor: string | null;
|
headers: { 'Accept': 'application/pdf' },
|
||||||
hasMore: boolean;
|
});
|
||||||
|
|
||||||
|
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 params = new URLSearchParams({
|
||||||
const response = await authorizedFetch(`/api/v1/tenant/billing/transactions${query}`);
|
page: String(Math.max(1, page)),
|
||||||
|
});
|
||||||
|
const response = await authorizedFetch(`/api/v1/tenant/billing/transactions?${params.toString()}`);
|
||||||
|
|
||||||
if (response.status === 404) {
|
if (response.status === 404) {
|
||||||
return { data: [], nextCursor: null, hasMore: false };
|
return { data: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const payload = await safeJson(response);
|
const payload = await safeJson(response);
|
||||||
console.error('[API] Failed to load Lemon Squeezy transactions', response.status, payload);
|
console.error('[API] Failed to load billing transactions', response.status, payload);
|
||||||
throw new Error('Failed to load Lemon Squeezy transactions');
|
throw new Error('Failed to load billing transactions');
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = await safeJson(response) ?? {};
|
const payload = await safeJson(response) ?? {};
|
||||||
const entries = Array.isArray(payload.data) ? payload.data : [];
|
const entries = Array.isArray(payload.data) ? payload.data : [];
|
||||||
const meta = payload.meta ?? {};
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: entries.map(normalizeLemonSqueezyOrder),
|
data: entries.map(normalizeTenantBillingTransaction),
|
||||||
nextCursor: typeof meta.next === 'string' ? meta.next : null,
|
|
||||||
hasMore: Boolean(meta.has_more),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,10 +6,12 @@
|
|||||||
"language": "Sprache",
|
"language": "Sprache",
|
||||||
"languageDe": "Deutsch",
|
"languageDe": "Deutsch",
|
||||||
"languageEn": "Englisch",
|
"languageEn": "Englisch",
|
||||||
"theme": "Theme",
|
"theme": "Erscheinungsbild",
|
||||||
"themeLight": "Hell",
|
"themeLight": "Hell",
|
||||||
"themeDark": "Dunkel",
|
"themeDark": "Dunkel",
|
||||||
"themeSystem": "System",
|
"themeSystem": "System",
|
||||||
|
"themeSystemLabel": "System ({{mode}})",
|
||||||
|
"themeSystemHint": "Folgt der Geräteeinstellung: {{mode}}",
|
||||||
"logout": "Abmelden",
|
"logout": "Abmelden",
|
||||||
"logoutTitle": "Ausloggen",
|
"logoutTitle": "Ausloggen",
|
||||||
"logoutHint": "Aus der App ausloggen"
|
"logoutHint": "Aus der App ausloggen"
|
||||||
@@ -27,8 +29,9 @@
|
|||||||
"actions": {
|
"actions": {
|
||||||
"refresh": "Aktualisieren",
|
"refresh": "Aktualisieren",
|
||||||
"exportCsv": "Export als CSV",
|
"exportCsv": "Export als CSV",
|
||||||
"portal": "Im PayPal-Portal verwalten",
|
"portal": "Abrechnungsdetails",
|
||||||
"portalBusy": "Portal wird geöffnet...",
|
"portalBusy": "Details werden geöffnet...",
|
||||||
|
"receiptDownloaded": "Beleg heruntergeladen.",
|
||||||
"openPackages": "Pakete öffnen",
|
"openPackages": "Pakete öffnen",
|
||||||
"contactSupport": "Support kontaktieren"
|
"contactSupport": "Support kontaktieren"
|
||||||
},
|
},
|
||||||
@@ -54,7 +57,8 @@
|
|||||||
"errors": {
|
"errors": {
|
||||||
"load": "Paketdaten konnten nicht geladen werden.",
|
"load": "Paketdaten konnten nicht geladen werden.",
|
||||||
"more": "Weitere Einträge konnten nicht geladen werden.",
|
"more": "Weitere Einträge konnten nicht geladen werden.",
|
||||||
"portal": "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.",
|
"checkoutSuccess": "Checkout abgeschlossen. Dein Paket wird in Kürze aktiviert.",
|
||||||
"checkoutCancelled": "Checkout wurde abgebrochen.",
|
"checkoutCancelled": "Checkout wurde abgebrochen.",
|
||||||
@@ -140,14 +144,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"transactions": {
|
"transactions": {
|
||||||
"title": "PayPal-Transaktionen",
|
"title": "Transaktionen",
|
||||||
"description": "Neueste PayPal-Transaktionen für dieses Kundenkonto.",
|
"description": "Neueste Paketkäufe für dieses Kundenkonto.",
|
||||||
"empty": "Noch keine PayPal-Transaktionen.",
|
"empty": "Noch keine Transaktionen.",
|
||||||
"labels": {
|
"labels": {
|
||||||
"transactionId": "Transaktion {{id}}",
|
"transactionId": "Transaktion {{id}}",
|
||||||
"checkoutId": "Checkout-ID: {{id}}",
|
"provider": "Anbieter: {{provider}}",
|
||||||
"origin": "Herkunft: {{origin}}",
|
"receipt": "Beleg herunterladen",
|
||||||
"receipt": "Beleg ansehen",
|
"packageFallback": "Paket",
|
||||||
"tax": "Steuer: {{value}}"
|
"tax": "Steuer: {{value}}"
|
||||||
},
|
},
|
||||||
"table": {
|
"table": {
|
||||||
@@ -162,6 +166,7 @@
|
|||||||
"processing": "Verarbeitung",
|
"processing": "Verarbeitung",
|
||||||
"failed": "Fehlgeschlagen",
|
"failed": "Fehlgeschlagen",
|
||||||
"cancelled": "Storniert",
|
"cancelled": "Storniert",
|
||||||
|
"refunded": "Erstattet",
|
||||||
"unknown": "Unbekannt"
|
"unknown": "Unbekannt"
|
||||||
},
|
},
|
||||||
"loadMore": "Weitere Transaktionen laden",
|
"loadMore": "Weitere Transaktionen laden",
|
||||||
|
|||||||
@@ -6,10 +6,12 @@
|
|||||||
"language": "Language",
|
"language": "Language",
|
||||||
"languageDe": "Deutsch",
|
"languageDe": "Deutsch",
|
||||||
"languageEn": "English",
|
"languageEn": "English",
|
||||||
"theme": "Theme",
|
"theme": "Appearance",
|
||||||
"themeLight": "Light",
|
"themeLight": "Light",
|
||||||
"themeDark": "Dark",
|
"themeDark": "Dark",
|
||||||
"themeSystem": "System",
|
"themeSystem": "System",
|
||||||
|
"themeSystemLabel": "System ({{mode}})",
|
||||||
|
"themeSystemHint": "Following device setting: {{mode}}",
|
||||||
"logout": "Log out",
|
"logout": "Log out",
|
||||||
"logoutTitle": "Sign out",
|
"logoutTitle": "Sign out",
|
||||||
"logoutHint": "Sign out from this app."
|
"logoutHint": "Sign out from this app."
|
||||||
@@ -27,8 +29,9 @@
|
|||||||
"actions": {
|
"actions": {
|
||||||
"refresh": "Refresh",
|
"refresh": "Refresh",
|
||||||
"exportCsv": "Export CSV",
|
"exportCsv": "Export CSV",
|
||||||
"portal": "Manage in PayPal",
|
"portal": "Billing details",
|
||||||
"portalBusy": "Opening portal...",
|
"portalBusy": "Opening billing details...",
|
||||||
|
"receiptDownloaded": "Receipt downloaded.",
|
||||||
"openPackages": "Open packages",
|
"openPackages": "Open packages",
|
||||||
"contactSupport": "Contact support"
|
"contactSupport": "Contact support"
|
||||||
},
|
},
|
||||||
@@ -54,7 +57,8 @@
|
|||||||
"errors": {
|
"errors": {
|
||||||
"load": "Unable to load package data.",
|
"load": "Unable to load package data.",
|
||||||
"more": "Unable to load more entries.",
|
"more": "Unable to load more entries.",
|
||||||
"portal": "Unable to open the PayPal portal."
|
"portal": "Unable to open billing details.",
|
||||||
|
"receipt": "Receipt download failed."
|
||||||
},
|
},
|
||||||
"checkoutSuccess": "Checkout completed. Your package will activate shortly.",
|
"checkoutSuccess": "Checkout completed. Your package will activate shortly.",
|
||||||
"checkoutCancelled": "Checkout was cancelled.",
|
"checkoutCancelled": "Checkout was cancelled.",
|
||||||
@@ -140,14 +144,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"transactions": {
|
"transactions": {
|
||||||
"title": "PayPal transactions",
|
"title": "Transactions",
|
||||||
"description": "Recent PayPal transactions for this customer account.",
|
"description": "Recent package purchases for this account.",
|
||||||
"empty": "No PayPal transactions yet.",
|
"empty": "No transactions yet.",
|
||||||
"labels": {
|
"labels": {
|
||||||
"transactionId": "Transaction {{id}}",
|
"transactionId": "Transaction {{id}}",
|
||||||
"checkoutId": "Checkout ID: {{id}}",
|
"provider": "Provider: {{provider}}",
|
||||||
"origin": "Origin: {{origin}}",
|
"receipt": "Download receipt",
|
||||||
"receipt": "View receipt",
|
"packageFallback": "Package",
|
||||||
"tax": "Tax: {{value}}"
|
"tax": "Tax: {{value}}"
|
||||||
},
|
},
|
||||||
"table": {
|
"table": {
|
||||||
@@ -162,6 +166,7 @@
|
|||||||
"processing": "Processing",
|
"processing": "Processing",
|
||||||
"failed": "Failed",
|
"failed": "Failed",
|
||||||
"cancelled": "Cancelled",
|
"cancelled": "Cancelled",
|
||||||
|
"refunded": "Refunded",
|
||||||
"unknown": "Unknown"
|
"unknown": "Unknown"
|
||||||
},
|
},
|
||||||
"loadMore": "Load more transactions",
|
"loadMore": "Load more transactions",
|
||||||
|
|||||||
@@ -10,12 +10,12 @@ import { MobileShell, HeaderActionButton } from './components/MobileShell';
|
|||||||
import { MobileCard, CTAButton, PillBadge } from './components/Primitives';
|
import { MobileCard, CTAButton, PillBadge } from './components/Primitives';
|
||||||
import { ContextHelpLink } from './components/ContextHelpLink';
|
import { ContextHelpLink } from './components/ContextHelpLink';
|
||||||
import {
|
import {
|
||||||
createTenantBillingPortalSession,
|
getTenantBillingTransactions,
|
||||||
getTenantPackagesOverview,
|
getTenantPackagesOverview,
|
||||||
getTenantLemonSqueezyTransactions,
|
|
||||||
getTenantPackageCheckoutStatus,
|
getTenantPackageCheckoutStatus,
|
||||||
TenantPackageSummary,
|
TenantPackageSummary,
|
||||||
LemonSqueezyOrderSummary,
|
TenantBillingTransactionSummary,
|
||||||
|
downloadTenantBillingReceipt,
|
||||||
} from '../api';
|
} from '../api';
|
||||||
import { TenantAddonHistoryEntry, getTenantAddonHistory } from '../api';
|
import { TenantAddonHistoryEntry, getTenantAddonHistory } from '../api';
|
||||||
import { getApiErrorMessage } from '../lib/apiError';
|
import { getApiErrorMessage } from '../lib/apiError';
|
||||||
@@ -42,6 +42,7 @@ import {
|
|||||||
shouldClearPendingCheckout,
|
shouldClearPendingCheckout,
|
||||||
storePendingCheckout,
|
storePendingCheckout,
|
||||||
} from './lib/billingCheckout';
|
} from './lib/billingCheckout';
|
||||||
|
import { triggerDownloadFromBlob } from './invite-layout/export-utils';
|
||||||
|
|
||||||
const CHECKOUT_POLL_INTERVAL_MS = 10000;
|
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 { textStrong, text, muted, subtle, danger, border, primary, accentSoft } = useAdminTheme();
|
||||||
const [packages, setPackages] = React.useState<TenantPackageSummary[]>([]);
|
const [packages, setPackages] = React.useState<TenantPackageSummary[]>([]);
|
||||||
const [activePackage, setActivePackage] = React.useState<TenantPackageSummary | null>(null);
|
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 [addons, setAddons] = React.useState<TenantAddonHistoryEntry[]>([]);
|
||||||
const [loading, setLoading] = React.useState(true);
|
const [loading, setLoading] = React.useState(true);
|
||||||
const [error, setError] = React.useState<string | null>(null);
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
const [portalBusy, setPortalBusy] = React.useState(false);
|
|
||||||
const [pendingCheckout, setPendingCheckout] = React.useState<PendingCheckout | null>(() => loadPendingCheckout());
|
const [pendingCheckout, setPendingCheckout] = React.useState<PendingCheckout | null>(() => loadPendingCheckout());
|
||||||
const [checkoutStatus, setCheckoutStatus] = React.useState<string | null>(null);
|
const [checkoutStatus, setCheckoutStatus] = React.useState<string | null>(null);
|
||||||
const [checkoutStatusReason, setCheckoutStatusReason] = React.useState<string | null>(null);
|
const [checkoutStatusReason, setCheckoutStatusReason] = React.useState<string | null>(null);
|
||||||
@@ -78,7 +78,7 @@ export default function MobileBillingPage() {
|
|||||||
try {
|
try {
|
||||||
const [pkg, trx, addonHistory] = await Promise.all([
|
const [pkg, trx, addonHistory] = await Promise.all([
|
||||||
getTenantPackagesOverview({ force: true }),
|
getTenantPackagesOverview({ force: true }),
|
||||||
getTenantLemonSqueezyTransactions().catch(() => ({ data: [] as LemonSqueezyOrderSummary[] })),
|
getTenantBillingTransactions().catch(() => ({ data: [] as TenantBillingTransactionSummary[] })),
|
||||||
getTenantAddonHistory().catch(() => ({ data: [] as TenantAddonHistoryEntry[] })),
|
getTenantAddonHistory().catch(() => ({ data: [] as TenantAddonHistoryEntry[] })),
|
||||||
]);
|
]);
|
||||||
setPackages(pkg.packages ?? []);
|
setPackages(pkg.packages ?? []);
|
||||||
@@ -104,30 +104,32 @@ export default function MobileBillingPage() {
|
|||||||
}
|
}
|
||||||
}, [supportEmail]);
|
}, [supportEmail]);
|
||||||
|
|
||||||
const openPortal = React.useCallback(async () => {
|
|
||||||
if (portalBusy) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setPortalBusy(true);
|
|
||||||
try {
|
|
||||||
const { url } = await createTenantBillingPortalSession();
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
window.open(url, '_blank', 'noopener');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
const message = getApiErrorMessage(err, t('billing.errors.portal', 'Konnte das Lemon Squeezy-Portal nicht öffnen.'));
|
|
||||||
toast.error(message);
|
|
||||||
} finally {
|
|
||||||
setPortalBusy(false);
|
|
||||||
}
|
|
||||||
}, [portalBusy, t]);
|
|
||||||
|
|
||||||
const persistPendingCheckout = React.useCallback((next: PendingCheckout | null) => {
|
const persistPendingCheckout = React.useCallback((next: PendingCheckout | null) => {
|
||||||
setPendingCheckout(next);
|
setPendingCheckout(next);
|
||||||
storePendingCheckout(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(() => {
|
React.useEffect(() => {
|
||||||
void load();
|
void load();
|
||||||
}, [load]);
|
}, [load]);
|
||||||
@@ -387,11 +389,6 @@ export default function MobileBillingPage() {
|
|||||||
<Text fontSize="$xs" color={muted}>
|
<Text fontSize="$xs" color={muted}>
|
||||||
{t('billing.sections.packages.hint', 'Active package, limits, and history at a glance.')}
|
{t('billing.sections.packages.hint', 'Active package, limits, and history at a glance.')}
|
||||||
</Text>
|
</Text>
|
||||||
<CTAButton
|
|
||||||
label={portalBusy ? t('billing.actions.portalBusy', 'Öffne Portal...') : t('billing.actions.portal', 'Manage in Lemon Squeezy')}
|
|
||||||
onPress={openPortal}
|
|
||||||
disabled={portalBusy}
|
|
||||||
/>
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<Text fontSize="$sm" color={muted}>
|
<Text fontSize="$sm" color={muted}>
|
||||||
{t('common.loading', 'Lädt...')}
|
{t('common.loading', 'Lädt...')}
|
||||||
@@ -439,38 +436,55 @@ export default function MobileBillingPage() {
|
|||||||
</YStack>
|
</YStack>
|
||||||
) : (
|
) : (
|
||||||
<YStack gap="$1.5">
|
<YStack gap="$1.5">
|
||||||
{transactions.slice(0, 8).map((trx) => (
|
{transactions.slice(0, 8).map((trx) => {
|
||||||
<XStack key={trx.id} alignItems="center" justifyContent="space-between" borderBottomWidth={1} borderColor={border} paddingVertical="$1.5">
|
const statusLabel = trx.status
|
||||||
<YStack>
|
? t(`billing.sections.transactions.status.${trx.status}`, trx.status)
|
||||||
<Text fontSize="$sm" color={textStrong} fontWeight="700">
|
: '—';
|
||||||
{trx.status ?? '—'}
|
const providerLabel = trx.provider
|
||||||
</Text>
|
? t(`billing.providers.${trx.provider}`, trx.provider)
|
||||||
<Text fontSize="$xs" color={muted}>
|
: t('billing.providers.unknown', 'Unknown');
|
||||||
{formatDate(trx.created_at)}
|
const transactionId = trx.provider_id ?? (trx.id !== null && trx.id !== undefined ? String(trx.id) : '—');
|
||||||
</Text>
|
const packageName = trx.package_name ?? t('billing.sections.transactions.labels.packageFallback', 'Package');
|
||||||
{trx.origin ? (
|
|
||||||
<Text fontSize="$xs" color={subtle}>
|
return (
|
||||||
{trx.origin}
|
<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>
|
</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}>
|
<Text fontSize="$xs" color={muted}>
|
||||||
{t('billing.sections.transactions.labels.tax', { value: formatAmount(trx.tax, trx.currency) })}
|
{statusLabel}
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
<Text fontSize="$xs" color={subtle}>
|
||||||
{trx.receipt_url ? (
|
{t('billing.sections.transactions.labels.provider', { provider: providerLabel })}
|
||||||
<a href={trx.receipt_url} target="_blank" rel="noreferrer" style={{ fontSize: 12, color: primary }}>
|
</Text>
|
||||||
{t('billing.sections.transactions.labels.receipt', 'Beleg')}
|
<Text fontSize="$xs" color={subtle}>
|
||||||
</a>
|
{t('billing.sections.transactions.labels.transactionId', { id: transactionId })}
|
||||||
) : null}
|
</Text>
|
||||||
</YStack>
|
</YStack>
|
||||||
</XStack>
|
<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>
|
</YStack>
|
||||||
)}
|
)}
|
||||||
</MobileCard>
|
</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 { t } = useTranslation('mobile');
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const theme = useAdminTheme();
|
const theme = useAdminTheme();
|
||||||
|
const navRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
// Modern Glass Background
|
// Modern Glass Background
|
||||||
const navSurface = theme.glassSurfaceStrong ?? theme.surfaceMuted ?? theme.surface;
|
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') },
|
{ 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 (
|
return (
|
||||||
<YStack
|
<YStack
|
||||||
|
ref={navRef as any}
|
||||||
position="fixed"
|
position="fixed"
|
||||||
bottom={0}
|
bottom={0}
|
||||||
left={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 { Pressable } from '@tamagui/react-native-web-lite';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useEventContext } from '../../context/EventContext';
|
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 { useMobileNav } from '../hooks/useMobileNav';
|
||||||
import { ADMIN_EVENTS_PATH, adminPath } from '../../constants';
|
import { ADMIN_EVENTS_PATH, adminPath } from '../../constants';
|
||||||
import { MobileCard, CTAButton } from './Primitives';
|
import { MobileCard, CTAButton } from './Primitives';
|
||||||
@@ -302,6 +302,9 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
|
|||||||
backgroundColor={backgroundColor}
|
backgroundColor={backgroundColor}
|
||||||
minHeight="100vh"
|
minHeight="100vh"
|
||||||
alignItems="center"
|
alignItems="center"
|
||||||
|
style={{
|
||||||
|
scrollPaddingBottom: 'var(--admin-bottom-nav-offset, calc(env(safe-area-inset-bottom, 0px) + 78px))',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<YStack
|
<YStack
|
||||||
backgroundColor={headerSurface}
|
backgroundColor={headerSurface}
|
||||||
@@ -331,13 +334,12 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
|
|||||||
</YStack>
|
</YStack>
|
||||||
|
|
||||||
<YStack
|
<YStack
|
||||||
flex={1}
|
|
||||||
padding="$4"
|
padding="$4"
|
||||||
gap="$3"
|
gap="$3"
|
||||||
width="100%"
|
width="100%"
|
||||||
maxWidth={800}
|
maxWidth={800}
|
||||||
style={{
|
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 ? (
|
{!online ? (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 { XStack, YStack, SizableText as Text, ListItem, YGroup, Separator } from 'tamagui';
|
||||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||||
import { ToggleGroup } from '@tamagui/toggle-group';
|
import { ToggleGroup } from '@tamagui/toggle-group';
|
||||||
@@ -32,6 +32,12 @@ export function UserMenuSheet({ open, onClose, user, isMember, navigate }: UserM
|
|||||||
}, [i18n.language]);
|
}, [i18n.language]);
|
||||||
|
|
||||||
const themeValue: 'light' | 'dark' = (appearance === 'system' ? resolved : appearance) ?? 'light';
|
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 activeToggleBg = theme.accentSoft ?? withAlpha(theme.primary, 0.18);
|
||||||
const activeToggleBorder = withAlpha(theme.primary, 0.45);
|
const activeToggleBorder = withAlpha(theme.primary, 0.45);
|
||||||
|
|
||||||
@@ -244,9 +250,9 @@ export function UserMenuSheet({ open, onClose, user, isMember, navigate }: UserM
|
|||||||
iconAfter={
|
iconAfter={
|
||||||
<ToggleGroup
|
<ToggleGroup
|
||||||
type="single"
|
type="single"
|
||||||
value={themeValue}
|
value={selectedAppearance}
|
||||||
onValueChange={(next: string) => {
|
onValueChange={(next: string) => {
|
||||||
if (next === 'light' || next === 'dark') {
|
if (next === 'light' || next === 'dark' || next === 'system') {
|
||||||
updateAppearance(next);
|
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: 'light', label: t('mobileProfile.themeLight', 'Light'), icon: Sun },
|
||||||
{ key: 'dark', label: t('mobileProfile.themeDark', 'Dark'), icon: Moon },
|
{ key: 'dark', label: t('mobileProfile.themeDark', 'Dark'), icon: Moon },
|
||||||
|
{ key: 'system', label: systemLabel, icon: Monitor },
|
||||||
] as const).map((option) => {
|
] as const).map((option) => {
|
||||||
const active = option.key === themeValue;
|
const active = option.key === selectedAppearance;
|
||||||
const Icon = option.icon;
|
const Icon = option.icon;
|
||||||
return (
|
return (
|
||||||
<ToggleGroup.Item
|
<ToggleGroup.Item
|
||||||
key={option.key}
|
key={option.key}
|
||||||
value={option.key}
|
value={option.key}
|
||||||
|
aria-label={option.label}
|
||||||
borderRadius="$pill"
|
borderRadius="$pill"
|
||||||
borderWidth={1}
|
borderWidth={1}
|
||||||
borderColor={active ? activeToggleBorder : theme.border}
|
borderColor={active ? activeToggleBorder : theme.border}
|
||||||
backgroundColor={active ? activeToggleBg : 'transparent'}
|
backgroundColor={active ? activeToggleBg : 'transparent'}
|
||||||
paddingHorizontal="$2.5"
|
paddingHorizontal="$2"
|
||||||
paddingVertical="$1.5"
|
paddingVertical="$1"
|
||||||
>
|
>
|
||||||
<XStack alignItems="center" gap="$1.5">
|
<XStack alignItems="center" justifyContent="center" minWidth={28} minHeight={24}>
|
||||||
<Icon size={14} color={active ? theme.textStrong : theme.muted} />
|
<Icon size={16} color={active ? theme.textStrong : theme.muted} />
|
||||||
<Text fontSize="$xs" fontWeight={active ? '700' : '600'} color={active ? theme.textStrong : theme.muted}>
|
|
||||||
{option.label}
|
|
||||||
</Text>
|
|
||||||
</XStack>
|
</XStack>
|
||||||
</ToggleGroup.Item>
|
</ToggleGroup.Item>
|
||||||
);
|
);
|
||||||
@@ -288,7 +293,7 @@ export function UserMenuSheet({ open, onClose, user, isMember, navigate }: UserM
|
|||||||
</YGroup>
|
</YGroup>
|
||||||
{appearance === 'system' ? (
|
{appearance === 'system' ? (
|
||||||
<Text fontSize="$xs" color={theme.muted}>
|
<Text fontSize="$xs" color={theme.muted}>
|
||||||
{t('mobileProfile.themeSystem', 'System')}
|
{t('mobileProfile.themeSystemHint', 'Following device setting: {{mode}}', { mode: resolvedLabel })}
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
</YStack>
|
</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,
|
key,
|
||||||
label: t(labelKey, fallback),
|
label: t(labelKey, fallback),
|
||||||
value: formatLimitWithRemaining(
|
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),
|
resolveRemainingForKey(limits, key, usageOverrides),
|
||||||
t
|
t
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export default function BottomNav() {
|
|||||||
const { event, status } = useEventData();
|
const { event, status } = useEventData();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { branding } = useEventBranding();
|
const { branding } = useEventBranding();
|
||||||
|
const navRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
const radius = branding.buttons?.radius ?? 12;
|
const radius = branding.buttons?.radius ?? 12;
|
||||||
const buttonStyle = branding.buttons?.style ?? 'filled';
|
const buttonStyle = branding.buttons?.style ?? 'filled';
|
||||||
const linkColor = branding.buttons?.linkColor ?? branding.secondaryColor;
|
const linkColor = branding.buttons?.linkColor ?? branding.secondaryColor;
|
||||||
@@ -83,9 +84,46 @@ export default function BottomNav() {
|
|||||||
|
|
||||||
const compact = isUploadActive;
|
const compact = isUploadActive;
|
||||||
const navPaddingBottom = `calc(env(safe-area-inset-bottom, 0px) + ${compact ? 12 : 18}px)`;
|
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 (
|
return (
|
||||||
<div
|
<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 ${
|
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'
|
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) {
|
if (!token) {
|
||||||
return (
|
return (
|
||||||
<div className="pb-16">
|
<div className="guest-layout">
|
||||||
<Header title="Event" />
|
<Header title="Event" />
|
||||||
<div className="px-4 py-3">
|
<div className="px-4 py-3">
|
||||||
<RouteTransition />
|
<RouteTransition />
|
||||||
@@ -132,7 +132,7 @@ function EventBoundary({ token }: { token: string }) {
|
|||||||
<EventBrandingProvider branding={branding}>
|
<EventBrandingProvider branding={branding}>
|
||||||
<EventStatsProvider eventKey={token}>
|
<EventStatsProvider eventKey={token}>
|
||||||
<NotificationCenterProvider eventToken={token}>
|
<NotificationCenterProvider eventToken={token}>
|
||||||
<div className="pb-16">
|
<div className="guest-layout">
|
||||||
<Header eventToken={token} />
|
<Header eventToken={token} />
|
||||||
<div className={contentPaddingClass}>
|
<div className={contentPaddingClass}>
|
||||||
<RouteTransition />
|
<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);
|
const matomoEnabled = typeof window !== 'undefined' && Boolean((window as any).__MATOMO_GUEST__?.enabled);
|
||||||
return (
|
return (
|
||||||
<EventBrandingProvider>
|
<EventBrandingProvider>
|
||||||
<div className="pb-16">
|
<div className="guest-layout">
|
||||||
<Header title={title} />
|
<Header title={title} />
|
||||||
<div className="px-4 py-3">
|
<div className="px-4 py-3">
|
||||||
<RouteTransition>{children}</RouteTransition>
|
<RouteTransition>{children}</RouteTransition>
|
||||||
|
|||||||
@@ -168,7 +168,10 @@ const WizardBody: React.FC<{
|
|||||||
return true;
|
return true;
|
||||||
}, [atLastStep, authUser, currentStep, isAuthenticated, paymentCompleted, selectedPackage]);
|
}, [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 highlightNextCta = currentStep === 'payment' && paymentCompleted;
|
||||||
|
|
||||||
const handleNext = useCallback(() => {
|
const handleNext = useCallback(() => {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ interface CheckoutState {
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
paymentCompleted: boolean;
|
paymentCompleted: boolean;
|
||||||
checkoutSessionId: string | null;
|
checkoutSessionId: string | null;
|
||||||
|
checkoutActionUrl: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CheckoutWizardContextType {
|
interface CheckoutWizardContextType {
|
||||||
@@ -29,6 +30,7 @@ interface CheckoutWizardContextType {
|
|||||||
} | null;
|
} | null;
|
||||||
paymentCompleted: boolean;
|
paymentCompleted: boolean;
|
||||||
checkoutSessionId: string | null;
|
checkoutSessionId: string | null;
|
||||||
|
checkoutActionUrl: string | null;
|
||||||
selectPackage: (pkg: CheckoutPackage) => void;
|
selectPackage: (pkg: CheckoutPackage) => void;
|
||||||
setSelectedPackage: (pkg: CheckoutPackage) => void;
|
setSelectedPackage: (pkg: CheckoutPackage) => void;
|
||||||
setAuthUser: (user: unknown) => void;
|
setAuthUser: (user: unknown) => void;
|
||||||
@@ -44,6 +46,8 @@ interface CheckoutWizardContextType {
|
|||||||
setPaymentCompleted: (completed: boolean) => void;
|
setPaymentCompleted: (completed: boolean) => void;
|
||||||
setCheckoutSessionId: (sessionId: string | null) => void;
|
setCheckoutSessionId: (sessionId: string | null) => void;
|
||||||
clearCheckoutSessionId: () => void;
|
clearCheckoutSessionId: () => void;
|
||||||
|
setCheckoutActionUrl: (url: string | null) => void;
|
||||||
|
clearCheckoutActionUrl: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CheckoutWizardContext = createContext<CheckoutWizardContextType | null>(null);
|
const CheckoutWizardContext = createContext<CheckoutWizardContextType | null>(null);
|
||||||
@@ -59,6 +63,7 @@ const initialState: CheckoutState = {
|
|||||||
error: null,
|
error: null,
|
||||||
paymentCompleted: false,
|
paymentCompleted: false,
|
||||||
checkoutSessionId: null,
|
checkoutSessionId: null,
|
||||||
|
checkoutActionUrl: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
type CheckoutAction =
|
type CheckoutAction =
|
||||||
@@ -71,7 +76,8 @@ type CheckoutAction =
|
|||||||
| { type: 'SET_LOADING'; payload: boolean }
|
| { type: 'SET_LOADING'; payload: boolean }
|
||||||
| { type: 'SET_ERROR'; payload: string | null }
|
| { type: 'SET_ERROR'; payload: string | null }
|
||||||
| { type: 'SET_PAYMENT_COMPLETED'; payload: boolean }
|
| { 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 {
|
function checkoutReducer(state: CheckoutState, action: CheckoutAction): CheckoutState {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
@@ -109,6 +115,8 @@ function checkoutReducer(state: CheckoutState, action: CheckoutAction): Checkout
|
|||||||
return { ...state, paymentCompleted: action.payload };
|
return { ...state, paymentCompleted: action.payload };
|
||||||
case 'SET_CHECKOUT_SESSION_ID':
|
case 'SET_CHECKOUT_SESSION_ID':
|
||||||
return { ...state, checkoutSessionId: action.payload };
|
return { ...state, checkoutSessionId: action.payload };
|
||||||
|
case 'SET_CHECKOUT_ACTION_URL':
|
||||||
|
return { ...state, checkoutActionUrl: action.payload };
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
@@ -148,6 +156,7 @@ export function CheckoutWizardProvider({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const checkoutSessionStorageKey = 'checkout-session-id';
|
const checkoutSessionStorageKey = 'checkout-session-id';
|
||||||
|
const checkoutActionStorageKey = 'checkout-action-url';
|
||||||
|
|
||||||
const [state, dispatch] = useReducer(checkoutReducer, customInitialState);
|
const [state, dispatch] = useReducer(checkoutReducer, customInitialState);
|
||||||
|
|
||||||
@@ -174,6 +183,11 @@ export function CheckoutWizardProvider({
|
|||||||
if (storedSession) {
|
if (storedSession) {
|
||||||
dispatch({ type: 'SET_CHECKOUT_SESSION_ID', payload: 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]);
|
}, [initialPackage]);
|
||||||
|
|
||||||
// Save state to localStorage whenever it changes
|
// Save state to localStorage whenever it changes
|
||||||
@@ -199,6 +213,14 @@ export function CheckoutWizardProvider({
|
|||||||
}
|
}
|
||||||
}, [state.checkoutSessionId]);
|
}, [state.checkoutSessionId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (state.checkoutActionUrl) {
|
||||||
|
localStorage.setItem(checkoutActionStorageKey, state.checkoutActionUrl);
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(checkoutActionStorageKey);
|
||||||
|
}
|
||||||
|
}, [state.checkoutActionUrl]);
|
||||||
|
|
||||||
const selectPackage = useCallback((pkg: CheckoutPackage) => {
|
const selectPackage = useCallback((pkg: CheckoutPackage) => {
|
||||||
dispatch({ type: 'SELECT_PACKAGE', payload: pkg });
|
dispatch({ type: 'SELECT_PACKAGE', payload: pkg });
|
||||||
}, []);
|
}, []);
|
||||||
@@ -241,6 +263,7 @@ export function CheckoutWizardProvider({
|
|||||||
dispatch({ type: 'SET_ERROR', payload: null });
|
dispatch({ type: 'SET_ERROR', payload: null });
|
||||||
dispatch({ type: 'SET_PAYMENT_COMPLETED', payload: false });
|
dispatch({ type: 'SET_PAYMENT_COMPLETED', payload: false });
|
||||||
dispatch({ type: 'SET_CHECKOUT_SESSION_ID', payload: null });
|
dispatch({ type: 'SET_CHECKOUT_SESSION_ID', payload: null });
|
||||||
|
dispatch({ type: 'SET_CHECKOUT_ACTION_URL', payload: null });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const setPaymentCompleted = useCallback((completed: boolean) => {
|
const setPaymentCompleted = useCallback((completed: boolean) => {
|
||||||
@@ -253,6 +276,15 @@ export function CheckoutWizardProvider({
|
|||||||
|
|
||||||
const clearCheckoutSessionId = useCallback(() => {
|
const clearCheckoutSessionId = useCallback(() => {
|
||||||
dispatch({ type: 'SET_CHECKOUT_SESSION_ID', payload: null });
|
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(() => {
|
const cancelCheckout = useCallback(() => {
|
||||||
@@ -277,9 +309,10 @@ export function CheckoutWizardProvider({
|
|||||||
// State aus localStorage entfernen
|
// State aus localStorage entfernen
|
||||||
localStorage.removeItem('checkout-wizard-state');
|
localStorage.removeItem('checkout-wizard-state');
|
||||||
localStorage.removeItem(checkoutSessionStorageKey);
|
localStorage.removeItem(checkoutSessionStorageKey);
|
||||||
|
localStorage.removeItem(checkoutActionStorageKey);
|
||||||
// Zur Package-Übersicht zurückleiten
|
// Zur Package-Übersicht zurückleiten
|
||||||
window.location.href = '/packages';
|
window.location.href = '/packages';
|
||||||
}, [state, checkoutSessionStorageKey]);
|
}, [state, checkoutActionStorageKey, checkoutSessionStorageKey]);
|
||||||
|
|
||||||
const value: CheckoutWizardContextType = {
|
const value: CheckoutWizardContextType = {
|
||||||
state,
|
state,
|
||||||
@@ -291,6 +324,7 @@ export function CheckoutWizardProvider({
|
|||||||
paypalConfig: paypal ?? null,
|
paypalConfig: paypal ?? null,
|
||||||
paymentCompleted: state.paymentCompleted,
|
paymentCompleted: state.paymentCompleted,
|
||||||
checkoutSessionId: state.checkoutSessionId,
|
checkoutSessionId: state.checkoutSessionId,
|
||||||
|
checkoutActionUrl: state.checkoutActionUrl,
|
||||||
selectPackage,
|
selectPackage,
|
||||||
setSelectedPackage,
|
setSelectedPackage,
|
||||||
setAuthUser,
|
setAuthUser,
|
||||||
@@ -306,6 +340,8 @@ export function CheckoutWizardProvider({
|
|||||||
setPaymentCompleted,
|
setPaymentCompleted,
|
||||||
setCheckoutSessionId,
|
setCheckoutSessionId,
|
||||||
clearCheckoutSessionId,
|
clearCheckoutSessionId,
|
||||||
|
setCheckoutActionUrl,
|
||||||
|
clearCheckoutActionUrl,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ describe('CheckoutWizard auth step navigation guard', () => {
|
|||||||
expect(screen.queryByTestId('payment-step')).not.toBeInTheDocument();
|
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 };
|
const paidPackage = { ...basePackage, id: 2, price: 99 };
|
||||||
|
|
||||||
render(
|
render(
|
||||||
@@ -113,12 +113,10 @@ describe('CheckoutWizard auth step navigation guard', () => {
|
|||||||
|
|
||||||
await screen.findByTestId('payment-step');
|
await screen.findByTestId('payment-step');
|
||||||
|
|
||||||
const nextButtons = screen.getAllByRole('button', { name: 'checkout.next' });
|
expect(screen.queryByRole('button', { name: 'checkout.next' })).not.toBeInTheDocument();
|
||||||
nextButtons.forEach((button) => expect(button).toBeDisabled());
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'mark-complete' }));
|
fireEvent.click(screen.getByRole('button', { name: 'mark-complete' }));
|
||||||
|
|
||||||
const activatedButtons = await screen.findAllByRole('button', { name: 'checkout.next' });
|
expect(screen.queryByRole('button', { name: 'checkout.next' })).not.toBeInTheDocument();
|
||||||
activatedButtons.forEach((button) => expect(button).toBeEnabled());
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useCheckoutWizard } from "../WizardContext";
|
|||||||
import { Trans, useTranslation } from 'react-i18next';
|
import { Trans, useTranslation } from 'react-i18next';
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
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";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface ConfirmationStepProps {
|
interface ConfirmationStepProps {
|
||||||
@@ -25,8 +25,10 @@ export const ConfirmationStep: React.FC<ConfirmationStepProps> = ({ onViewProfil
|
|||||||
checkoutSessionId,
|
checkoutSessionId,
|
||||||
setPaymentCompleted,
|
setPaymentCompleted,
|
||||||
clearCheckoutSessionId,
|
clearCheckoutSessionId,
|
||||||
|
checkoutActionUrl,
|
||||||
|
goToStep,
|
||||||
} = useCheckoutWizard();
|
} = useCheckoutWizard();
|
||||||
const [status, setStatus] = useState<'processing' | 'completed' | 'failed'>(
|
const [status, setStatus] = useState<'processing' | 'completed' | 'failed' | 'action_required'>(
|
||||||
checkoutSessionId ? 'processing' : 'completed',
|
checkoutSessionId ? 'processing' : 'completed',
|
||||||
);
|
);
|
||||||
const [elapsedMs, setElapsedMs] = useState(0);
|
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',
|
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 {
|
return {
|
||||||
label: t('checkout.confirmation_step.status_state.processing'),
|
label: t('checkout.confirmation_step.status_state.processing'),
|
||||||
body: t('checkout.confirmation_step.status_body_processing'),
|
body: t('checkout.confirmation_step.status_body_processing'),
|
||||||
@@ -88,7 +99,7 @@ export const ConfirmationStep: React.FC<ConfirmationStepProps> = ({ onViewProfil
|
|||||||
};
|
};
|
||||||
}, [status, t]);
|
}, [status, t]);
|
||||||
|
|
||||||
const checkSessionStatus = useCallback(async (): Promise<'processing' | 'completed' | 'failed'> => {
|
const checkSessionStatus = useCallback(async (): Promise<'processing' | 'completed' | 'failed' | 'action_required'> => {
|
||||||
if (!checkoutSessionId) {
|
if (!checkoutSessionId) {
|
||||||
return 'completed';
|
return 'completed';
|
||||||
}
|
}
|
||||||
@@ -114,6 +125,10 @@ export const ConfirmationStep: React.FC<ConfirmationStepProps> = ({ onViewProfil
|
|||||||
return 'completed';
|
return 'completed';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (remoteStatus === 'requires_customer_action') {
|
||||||
|
return 'action_required';
|
||||||
|
}
|
||||||
|
|
||||||
if (remoteStatus === 'failed' || remoteStatus === 'cancelled') {
|
if (remoteStatus === 'failed' || remoteStatus === 'cancelled') {
|
||||||
clearCheckoutSessionId();
|
clearCheckoutSessionId();
|
||||||
return 'failed';
|
return 'failed';
|
||||||
@@ -204,10 +219,15 @@ export const ConfirmationStep: React.FC<ConfirmationStepProps> = ({ onViewProfil
|
|||||||
if (status === 'failed') {
|
if (status === 'failed') {
|
||||||
return { payment: false, email: false, access: false };
|
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 };
|
return { payment: true, email: false, access: false };
|
||||||
}, [status]);
|
}, [status]);
|
||||||
|
|
||||||
const showManualActions = status === 'processing' && elapsedMs >= 30000;
|
const showManualActions = status === 'processing' && elapsedMs >= 30000;
|
||||||
|
const showActionRequired = status === 'action_required';
|
||||||
|
const showFailedActions = status === 'failed';
|
||||||
const StatusIcon = statusCopy.icon;
|
const StatusIcon = statusCopy.icon;
|
||||||
|
|
||||||
const handleStatusRetry = useCallback(async () => {
|
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 (
|
return (
|
||||||
<div className="grid gap-6 lg:grid-cols-[2fr_1fr]">
|
<div className="grid gap-6 lg:grid-cols-[2fr_1fr]">
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -314,6 +346,29 @@ export const ConfirmationStep: React.FC<ConfirmationStepProps> = ({ onViewProfil
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -108,6 +108,34 @@ export function resolveCheckoutLocale(rawLocale?: string | null): string {
|
|||||||
return short || 'en';
|
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 = {
|
type PayPalSdkOptions = {
|
||||||
clientId: string;
|
clientId: string;
|
||||||
currency: string;
|
currency: string;
|
||||||
@@ -134,8 +162,9 @@ async function loadPayPalSdk(options: PayPalSdkOptions): Promise<typeof window.p
|
|||||||
components: 'buttons',
|
components: 'buttons',
|
||||||
});
|
});
|
||||||
|
|
||||||
if (options.locale) {
|
const paypalLocale = resolvePayPalLocale(options.locale);
|
||||||
params.set('locale', options.locale);
|
if (paypalLocale) {
|
||||||
|
params.set('locale', paypalLocale);
|
||||||
}
|
}
|
||||||
|
|
||||||
const src = `${PAYPAL_SDK_BASE}?${params.toString()}`;
|
const src = `${PAYPAL_SDK_BASE}?${params.toString()}`;
|
||||||
@@ -173,6 +202,9 @@ export const PaymentStep: React.FC = () => {
|
|||||||
setPaymentCompleted,
|
setPaymentCompleted,
|
||||||
checkoutSessionId,
|
checkoutSessionId,
|
||||||
setCheckoutSessionId,
|
setCheckoutSessionId,
|
||||||
|
checkoutActionUrl,
|
||||||
|
setCheckoutActionUrl,
|
||||||
|
clearCheckoutActionUrl,
|
||||||
paypalConfig,
|
paypalConfig,
|
||||||
} = useCheckoutWizard();
|
} = useCheckoutWizard();
|
||||||
const [status, setStatus] = useState<PaymentStatus>('idle');
|
const [status, setStatus] = useState<PaymentStatus>('idle');
|
||||||
@@ -292,18 +324,18 @@ export const PaymentStep: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [RateLimitHelper, selectedPackage, t, trackEvent]);
|
}, [RateLimitHelper, selectedPackage, t, trackEvent]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (couponCode && selectedPackage) {
|
|
||||||
applyCoupon(couponCode);
|
|
||||||
}
|
|
||||||
}, [applyCoupon, couponCode, selectedPackage]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCouponPreview(null);
|
setCouponPreview(null);
|
||||||
setCouponNotice(null);
|
setCouponNotice(null);
|
||||||
setCouponError(null);
|
setCouponError(null);
|
||||||
}, [selectedPackage?.id]);
|
}, [selectedPackage?.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedPackage) {
|
||||||
|
clearCheckoutActionUrl();
|
||||||
|
}
|
||||||
|
}, [clearCheckoutActionUrl, selectedPackage?.id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return;
|
return;
|
||||||
@@ -343,6 +375,7 @@ export const PaymentStep: React.FC = () => {
|
|||||||
|
|
||||||
setConsentError(null);
|
setConsentError(null);
|
||||||
setFreeActivationBusy(true);
|
setFreeActivationBusy(true);
|
||||||
|
clearCheckoutActionUrl();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await refreshCheckoutCsrfToken();
|
await refreshCheckoutCsrfToken();
|
||||||
@@ -469,6 +502,7 @@ export const PaymentStep: React.FC = () => {
|
|||||||
setMessage(t('checkout.payment_step.paypal_preparing'));
|
setMessage(t('checkout.payment_step.paypal_preparing'));
|
||||||
setPaymentCompleted(false);
|
setPaymentCompleted(false);
|
||||||
setCheckoutSessionId(null);
|
setCheckoutSessionId(null);
|
||||||
|
setCheckoutActionUrl(null);
|
||||||
|
|
||||||
await refreshCheckoutCsrfToken();
|
await refreshCheckoutCsrfToken();
|
||||||
|
|
||||||
@@ -498,6 +532,11 @@ export const PaymentStep: React.FC = () => {
|
|||||||
checkoutSessionRef.current = payload.checkout_session_id;
|
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;
|
const orderId = payload?.order_id;
|
||||||
if (!orderId) {
|
if (!orderId) {
|
||||||
throw new Error('PayPal order ID missing.');
|
throw new Error('PayPal order ID missing.');
|
||||||
@@ -524,6 +563,11 @@ export const PaymentStep: React.FC = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (payload?.status === 'requires_customer_action') {
|
||||||
|
nextStep();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
setMessage(t('checkout.payment_step.paypal_error'));
|
setMessage(t('checkout.payment_step.paypal_error'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -554,7 +598,7 @@ export const PaymentStep: React.FC = () => {
|
|||||||
paypalButtonsRef.current.close();
|
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(() => {
|
useEffect(() => {
|
||||||
if (paypalActionsRef.current) {
|
if (paypalActionsRef.current) {
|
||||||
@@ -593,6 +637,12 @@ export const PaymentStep: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [couponPreview, trackEvent]);
|
}, [couponPreview, trackEvent]);
|
||||||
|
|
||||||
|
const handleOpenPayPal = useCallback(() => {
|
||||||
|
if (checkoutActionUrl && typeof window !== 'undefined') {
|
||||||
|
window.open(checkoutActionUrl, '_blank', 'noopener');
|
||||||
|
}
|
||||||
|
}, [checkoutActionUrl]);
|
||||||
|
|
||||||
const openWithdrawalModal = useCallback(async () => {
|
const openWithdrawalModal = useCallback(async () => {
|
||||||
setShowWithdrawalModal(true);
|
setShowWithdrawalModal(true);
|
||||||
|
|
||||||
@@ -771,6 +821,21 @@ export const PaymentStep: React.FC = () => {
|
|||||||
<p className="text-xs text-white/70 text-center">
|
<p className="text-xs text-white/70 text-center">
|
||||||
{t('checkout.payment_step.guided_cta_hint')}
|
{t('checkout.payment_step.guided_cta_hint')}
|
||||||
</p>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -88,6 +88,13 @@ return [
|
|||||||
'limits_title' => 'Ihre Paketdetails',
|
'limits_title' => 'Ihre Paketdetails',
|
||||||
'invoice_title' => 'Rechnung',
|
'invoice_title' => 'Rechnung',
|
||||||
'invoice_link' => 'Rechnung öffnen',
|
'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',
|
'cta' => 'Zum Event-Admin',
|
||||||
'provider' => [
|
'provider' => [
|
||||||
'lemonsqueezy' => 'PayPal',
|
'lemonsqueezy' => 'PayPal',
|
||||||
|
|||||||
@@ -469,9 +469,9 @@
|
|||||||
"secure_payment_desc": "Sichere Zahlung ueber PayPal.",
|
"secure_payment_desc": "Sichere Zahlung ueber PayPal.",
|
||||||
"lemonsqueezy_intro": "Starte den PayPal-Checkout direkt hier im Wizard – ganz ohne Seitenwechsel.",
|
"lemonsqueezy_intro": "Starte den PayPal-Checkout direkt hier im Wizard – ganz ohne Seitenwechsel.",
|
||||||
"guided_title": "Sichere Zahlung mit PayPal",
|
"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",
|
"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_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_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.",
|
"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",
|
"pay_with_lemonsqueezy": "Weiter mit PayPal",
|
||||||
"paypal_partner": "Powered by PayPal",
|
"paypal_partner": "Powered by PayPal",
|
||||||
"paypal_preparing": "PayPal-Checkout wird vorbereitet...",
|
"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_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_not_configured": "PayPal ist noch nicht konfiguriert. Bitte kontaktiere den Support.",
|
||||||
"paypal_cancelled": "PayPal-Checkout wurde abgebrochen.",
|
"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."
|
"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',
|
'limits_title' => 'Your package details',
|
||||||
'invoice_title' => 'Invoice',
|
'invoice_title' => 'Invoice',
|
||||||
'invoice_link' => 'Open 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',
|
'cta' => 'Open Event Admin',
|
||||||
'provider' => [
|
'provider' => [
|
||||||
'lemonsqueezy' => 'PayPal',
|
'lemonsqueezy' => 'PayPal',
|
||||||
|
|||||||
@@ -465,9 +465,9 @@
|
|||||||
"secure_payment_desc": "Secure payment with PayPal.",
|
"secure_payment_desc": "Secure payment with PayPal.",
|
||||||
"lemonsqueezy_intro": "Start the PayPal checkout right here in the wizard - no page changes required.",
|
"lemonsqueezy_intro": "Start the PayPal checkout right here in the wizard - no page changes required.",
|
||||||
"guided_title": "Secure checkout with PayPal",
|
"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",
|
"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_preparing": "Preparing PayPal checkout...",
|
||||||
"lemonsqueezy_overlay_ready": "PayPal checkout is running in a secure overlay. Complete the payment there and then continue here.",
|
"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.",
|
"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",
|
"pay_with_lemonsqueezy": "Continue with PayPal",
|
||||||
"paypal_partner": "Powered by PayPal",
|
"paypal_partner": "Powered by PayPal",
|
||||||
"paypal_preparing": "Preparing PayPal checkout...",
|
"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_error": "We could not start the PayPal checkout. Please try again.",
|
||||||
"paypal_not_configured": "PayPal checkout is not configured yet. Please contact support.",
|
"paypal_not_configured": "PayPal checkout is not configured yet. Please contact support.",
|
||||||
"paypal_cancelled": "PayPal checkout was cancelled.",
|
"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."
|
"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::prefix('billing')->middleware('tenant.admin')->group(function () {
|
||||||
Route::get('transactions', [TenantBillingController::class, 'transactions'])
|
Route::get('transactions', [TenantBillingController::class, 'transactions'])
|
||||||
->name('tenant.billing.transactions');
|
->name('tenant.billing.transactions');
|
||||||
|
Route::get('transactions/{purchase}/receipt', [TenantBillingController::class, 'receipt'])
|
||||||
|
->name('tenant.billing.transactions.receipt');
|
||||||
Route::get('addons', [TenantBillingController::class, 'addons'])
|
Route::get('addons', [TenantBillingController::class, 'addons'])
|
||||||
->name('tenant.billing.addons');
|
->name('tenant.billing.addons');
|
||||||
Route::post('portal', [TenantBillingController::class, 'portal'])
|
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::prefix('tenant/billing')->middleware('tenant.admin')->group(function () {
|
||||||
Route::get('transactions', [TenantBillingController::class, 'transactions']);
|
Route::get('transactions', [TenantBillingController::class, 'transactions']);
|
||||||
|
Route::get('transactions/{purchase}/receipt', [TenantBillingController::class, 'receipt']);
|
||||||
Route::get('addons', [TenantBillingController::class, 'addons']);
|
Route::get('addons', [TenantBillingController::class, 'addons']);
|
||||||
Route::post('portal', [TenantBillingController::class, 'portal']);
|
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;
|
namespace Tests\Feature\Api\Tenant;
|
||||||
|
|
||||||
use Illuminate\Http\Client\Request;
|
use App\Models\Package;
|
||||||
use Illuminate\Support\Facades\Http;
|
use App\Models\PackagePurchase;
|
||||||
use Tests\Feature\Tenant\TenantTestCase;
|
use Tests\Feature\Tenant\TenantTestCase;
|
||||||
|
|
||||||
class BillingTransactionsTest extends 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) {
|
$package = Package::factory()->create(['name' => 'Starter']);
|
||||||
$path = parse_url($request->url(), PHP_URL_PATH);
|
$purchase = PackagePurchase::factory()->create([
|
||||||
|
'tenant_id' => $this->tenant->id,
|
||||||
if ($path === '/customers' && $request->method() === 'POST') {
|
'package_id' => $package->id,
|
||||||
return Http::response([
|
'provider' => 'paypal',
|
||||||
'data' => ['id' => 'cus_456'],
|
'provider_id' => 'ORDER-123',
|
||||||
], 200);
|
'price' => 49.0,
|
||||||
}
|
]);
|
||||||
|
|
||||||
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();
|
|
||||||
|
|
||||||
$response = $this->authenticatedRequest('GET', '/api/v1/tenant/billing/transactions');
|
$response = $this->authenticatedRequest('GET', '/api/v1/tenant/billing/transactions');
|
||||||
|
|
||||||
$response->assertOk();
|
$response->assertOk();
|
||||||
$response->assertJsonPath('data', []);
|
$response->assertJsonFragment([
|
||||||
|
'id' => $purchase->id,
|
||||||
$this->assertDatabaseHas('tenants', [
|
'provider' => 'paypal',
|
||||||
'id' => $this->tenant->id,
|
'provider_id' => 'ORDER-123',
|
||||||
'lemonsqueezy_customer_id' => 'cus_456',
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,5 +93,6 @@ class TenantPackageOverviewTest extends TenantTestCase
|
|||||||
|
|
||||||
$this->assertSame($package->id, $payload['active_package']['package_id']);
|
$this->assertSame($package->id, $payload['active_package']['package_id']);
|
||||||
$this->assertSame(['custom_branding'], $payload['active_package']['package_limits']['features']);
|
$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