feat(addons): finalize event addon catalog and ai styling upgrade flow
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

This commit is contained in:
Codex Agent
2026-02-07 12:35:07 +01:00
parent 8cc0918881
commit d2808ffa4f
36 changed files with 1372 additions and 457 deletions

View File

@@ -5,14 +5,20 @@ namespace App\Filament\Resources;
use App\Filament\Clusters\WeeklyOps\WeeklyOpsCluster;
use App\Filament\Resources\PackageAddonResource\Pages;
use App\Jobs\SyncPackageAddonToLemonSqueezy;
use App\Models\CheckoutSession;
use App\Models\PackageAddon;
use App\Services\Audit\SuperAdminAuditLogger;
use Filament\Actions;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TagsInput;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Columns\BadgeColumn;
@@ -53,7 +59,15 @@ class PackageAddonResource extends Resource
TextInput::make('variant_id')
->label('Lemon Squeezy Variant-ID')
->helperText('Variant-ID aus Lemon Squeezy für dieses Add-on')
->required(fn (Get $get): bool => (bool) $get('active') && ! is_numeric($get('metadata.price_eur')))
->maxLength(191),
TextInput::make('metadata.price_eur')
->label('PayPal Preis (EUR)')
->helperText('Für PayPal-Checkout erforderlich (z. B. 9.90).')
->numeric()
->step(0.01)
->minValue(0.01)
->required(fn (Get $get): bool => (bool) $get('active') && blank($get('variant_id'))),
TextInput::make('sort')
->label('Sortierung')
->numeric()
@@ -61,6 +75,23 @@ class PackageAddonResource extends Resource
Toggle::make('active')
->label('Aktiv')
->default(true),
Placeholder::make('sellable_state')
->label('Verfügbarkeits-Check')
->content(function (Get $get): string {
$isActive = (bool) $get('active');
$hasVariant = filled($get('variant_id'));
$hasPayPalPrice = is_numeric($get('metadata.price_eur'));
if (! $isActive) {
return 'Inaktiv';
}
if (! $hasVariant && ! $hasPayPalPrice) {
return 'Nicht verkäuflich: Variant-ID oder PayPal Preis fehlt.';
}
return 'Verkäuflich';
}),
]),
Section::make('Limits-Inkremente')
->columns(3)
@@ -81,6 +112,30 @@ class PackageAddonResource extends Resource
->minValue(0)
->default(0),
]),
Section::make('Feature-Entitlements')
->columns(2)
->schema([
Select::make('metadata.scope')
->label('Scope')
->options([
'photos' => 'Fotos',
'guests' => 'Gäste',
'gallery' => 'Galerie',
'feature' => 'Feature',
'bundle' => 'Bundle',
])
->native(false)
->searchable(),
TagsInput::make('metadata.entitlements.features')
->label('Freigeschaltete Features')
->helperText('Feature-Keys für Freischaltungen, z. B. ai_styling')
->placeholder('z. B. ai_styling')
->columnSpanFull(),
DateTimePicker::make('metadata.entitlements.expires_at')
->label('Entitlement gültig bis')
->seconds(false)
->nullable(),
]),
]);
}
@@ -100,6 +155,29 @@ class PackageAddonResource extends Resource
->label('Lemon Squeezy Variant-ID')
->toggleable()
->copyable(),
TextColumn::make('metadata.price_eur')
->label('PayPal Preis (EUR)')
->formatStateUsing(fn (mixed $state): string => is_numeric($state) ? number_format((float) $state, 2, ',', '.').' €' : '—')
->toggleable(),
TextColumn::make('metadata.scope')
->label('Scope')
->badge()
->toggleable(),
TextColumn::make('metadata.entitlements.features')
->label('Features')
->formatStateUsing(function (mixed $state): string {
if (! is_array($state)) {
return '—';
}
$features = array_values(array_filter(array_map(
static fn (mixed $feature): string => trim((string) $feature),
$state,
)));
return $features === [] ? '—' : implode(', ', $features);
})
->toggleable(),
TextColumn::make('extra_photos')->label('Fotos +'),
TextColumn::make('extra_guests')->label('Gäste +'),
TextColumn::make('extra_gallery_days')->label('Galerietage +'),
@@ -110,6 +188,14 @@ class PackageAddonResource extends Resource
'danger' => false,
])
->formatStateUsing(fn (bool $state) => $state ? 'Aktiv' : 'Inaktiv'),
BadgeColumn::make('sellability')
->label('Checkout')
->state(fn (PackageAddon $record): string => static::sellabilityLabel($record))
->colors([
'success' => fn (string $state): bool => $state === 'Verkäuflich',
'warning' => fn (string $state): bool => $state === 'Unvollständig',
'gray' => fn (string $state): bool => $state === 'Inaktiv',
]),
TextColumn::make('sort')
->label('Sort')
->sortable()
@@ -166,4 +252,21 @@ class PackageAddonResource extends Resource
'edit' => Pages\EditPackageAddon::route('/{record}/edit'),
];
}
protected static function sellabilityLabel(PackageAddon $record): string
{
if (! $record->active) {
return 'Inaktiv';
}
return $record->isSellableForProvider(static::addonProvider()) ? 'Verkäuflich' : 'Unvollständig';
}
protected static function addonProvider(): string
{
return (string) (
config('package-addons.provider')
?? config('checkout.default_provider', CheckoutSession::PROVIDER_PAYPAL)
);
}
}

View File

@@ -4,12 +4,9 @@ namespace App\Filament\Resources;
use App\Filament\Clusters\WeeklyOps\WeeklyOpsCluster;
use App\Filament\Resources\PackageResource\Pages;
use App\Jobs\PullPackageFromLemonSqueezy;
use App\Jobs\SyncPackageToLemonSqueezy;
use App\Models\Package;
use App\Services\Audit\SuperAdminAuditLogger;
use BackedEnum;
use Filament\Actions;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteAction;
use Filament\Actions\DeleteBulkAction;
@@ -26,7 +23,6 @@ use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Tabs as SchemaTabs;
@@ -301,71 +297,6 @@ class PackageResource extends Resource
TrashedFilter::make(),
])
->actions([
Actions\Action::make('syncLemonSqueezy')
->label('Mit Lemon Squeezy abgleichen')
->icon('heroicon-o-cloud-arrow-up')
->color('success')
->requiresConfirmation()
->disabled(fn (Package $record) => $record->lemonsqueezy_sync_status === 'syncing')
->action(function (Package $record) {
SyncPackageToLemonSqueezy::dispatch($record->id);
Notification::make()
->success()
->title('Lemon Squeezy-Sync gestartet')
->body('Das Paket wird im Hintergrund mit Lemon Squeezy abgeglichen.')
->send();
}),
Actions\Action::make('linkLemonSqueezy')
->label('Lemon Squeezy verknüpfen')
->icon('heroicon-o-link')
->color('info')
->form([
TextInput::make('lemonsqueezy_product_id')
->label('Lemon Squeezy Produkt-ID')
->required()
->maxLength(191),
TextInput::make('lemonsqueezy_variant_id')
->label('Lemon Squeezy Variant-ID')
->required()
->maxLength(191),
])
->fillForm(fn (Package $record) => [
'lemonsqueezy_product_id' => $record->lemonsqueezy_product_id,
'lemonsqueezy_variant_id' => $record->lemonsqueezy_variant_id,
])
->action(function (Package $record, array $data): void {
$record->linkLemonSqueezyIds($data['lemonsqueezy_product_id'], $data['lemonsqueezy_variant_id']);
PullPackageFromLemonSqueezy::dispatch($record->id);
app(SuperAdminAuditLogger::class)->recordModelMutation(
'linked',
$record,
SuperAdminAuditLogger::fieldsMetadata($data),
static::class
);
Notification::make()
->success()
->title('Lemon Squeezy-Verknüpfung gespeichert')
->body('Die IDs wurden gespeichert und ein Pull wurde angestoßen.')
->send();
}),
Actions\Action::make('pullLemonSqueezy')
->label('Status von Lemon Squeezy holen')
->icon('heroicon-o-cloud-arrow-down')
->disabled(fn (Package $record) => ! $record->lemonsqueezy_product_id && ! $record->lemonsqueezy_variant_id)
->requiresConfirmation()
->action(function (Package $record) {
PullPackageFromLemonSqueezy::dispatch($record->id);
Notification::make()
->info()
->title('Lemon Squeezy-Abgleich angefordert')
->body('Der aktuelle Stand aus Lemon Squeezy wird geladen und hier hinterlegt.')
->send();
}),
ViewAction::make(),
EditAction::make()
->after(fn (array $data, Package $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(

View File

@@ -27,21 +27,22 @@ class PackageController extends Controller
public function index(Request $request): JsonResponse
{
$type = $request->query('type', 'endcustomer');
$provider = strtolower((string) config('checkout.default_provider', CheckoutSession::PROVIDER_PAYPAL));
$packages = Package::where('type', $type)
->orderBy('price')
->get();
$packages->each(function ($package) {
$packages->each(function ($package) use ($provider) {
if (is_string($package->features)) {
$decoded = json_decode($package->features, true);
$package->features = is_array($decoded) ? $decoded : [];
return;
}
if (! is_array($package->features)) {
} elseif (! is_array($package->features)) {
$package->features = [];
}
$package->setAttribute('checkout_provider', $provider);
$package->setAttribute('can_checkout', $this->canCheckoutPackage($package, $provider));
});
return response()->json([
@@ -365,4 +366,17 @@ class PackageController extends Controller
'cancel_url' => $cancelUrl,
]);
}
private function canCheckoutPackage(Package $package, string $provider): bool
{
if ((float) $package->price <= 0) {
return true;
}
if ($provider === CheckoutSession::PROVIDER_LEMONSQUEEZY) {
return filled($package->lemonsqueezy_variant_id);
}
return true;
}
}

View File

@@ -4,13 +4,10 @@ namespace App\Http\Controllers\Api\Tenant;
use App\Http\Controllers\Controller;
use App\Http\Requests\Tenant\EventAddonCheckoutRequest;
use App\Http\Requests\Tenant\EventAddonRequest;
use App\Http\Resources\Tenant\EventResource;
use App\Models\Event;
use App\Services\Addons\EventAddonCheckoutService;
use App\Support\ApiError;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Carbon;
class EventAddonController extends Controller
{
@@ -51,64 +48,4 @@ class EventAddonController extends Controller
'expires_at' => $checkout['expires_at'] ?? null,
]);
}
public function apply(EventAddonRequest $request, Event $event): JsonResponse
{
$tenantId = $request->attributes->get('tenant_id');
if ($event->tenant_id !== $tenantId) {
return ApiError::response(
'event_not_found',
'Event not accessible',
__('Das Event konnte nicht gefunden werden.'),
404,
['event_slug' => $event->slug ?? null]
);
}
$eventPackage = $event->eventPackage;
if (! $eventPackage && method_exists($event, 'eventPackages')) {
$eventPackage = $event->eventPackages()
->with('package')
->orderByDesc('purchased_at')
->orderByDesc('created_at')
->first();
}
if (! $eventPackage) {
return ApiError::response(
'event_package_missing',
'Event package missing',
__('Kein Paket ist diesem Event zugeordnet.'),
409,
['event_slug' => $event->slug ?? null]
);
}
$data = $request->validated();
$eventPackage->fill([
'extra_photos' => ($eventPackage->extra_photos ?? 0) + (int) ($data['extra_photos'] ?? 0),
'extra_guests' => ($eventPackage->extra_guests ?? 0) + (int) ($data['extra_guests'] ?? 0),
'extra_gallery_days' => ($eventPackage->extra_gallery_days ?? 0) + (int) ($data['extend_gallery_days'] ?? 0),
]);
if (isset($data['extend_gallery_days'])) {
$base = $eventPackage->gallery_expires_at ?? Carbon::now();
$eventPackage->gallery_expires_at = $base->copy()->addDays((int) $data['extend_gallery_days']);
}
$eventPackage->save();
$event->load([
'eventPackage.package',
'eventPackages.package',
]);
return response()->json([
'message' => __('Add-ons applied successfully.'),
'data' => new EventResource($event),
]);
}
}

View File

@@ -7,6 +7,7 @@ use App\Http\Requests\Tenant\BillingAddonHistoryRequest;
use App\Models\Event;
use App\Models\EventPackageAddon;
use App\Models\PackagePurchase;
use App\Services\Addons\EventAddonCatalog;
use App\Services\LemonSqueezy\Exceptions\LemonSqueezyException;
use App\Services\LemonSqueezy\LemonSqueezySubscriptionService;
use Dompdf\Dompdf;
@@ -22,6 +23,7 @@ class TenantBillingController extends Controller
{
public function __construct(
private readonly LemonSqueezySubscriptionService $subscriptions,
private readonly EventAddonCatalog $addonCatalog,
) {}
public function transactions(Request $request): JsonResponse
@@ -132,11 +134,19 @@ class TenantBillingController extends Controller
->orderByDesc('created_at')
->paginate($perPage, ['*'], 'page', $page);
$data = $paginator->getCollection()->map(function (EventPackageAddon $addon) {
$addonLabels = collect($this->addonCatalog->all())
->mapWithKeys(fn (array $addon, string $key): array => [$key => $addon['label'] ?? null])
->all();
$data = $paginator->getCollection()->map(function (EventPackageAddon $addon) use ($addonLabels) {
$label = $addon->metadata['label']
?? ($addonLabels[$addon->addon_key] ?? null)
?? $addon->addon_key;
return [
'id' => $addon->id,
'addon_key' => $addon->addon_key,
'label' => $addon->metadata['label'] ?? null,
'label' => $label,
'quantity' => (int) ($addon->quantity ?? 1),
'status' => $addon->status,
'amount' => $addon->amount !== null ? (float) $addon->amount : null,

View File

@@ -1,40 +0,0 @@
<?php
namespace App\Http\Requests\Tenant;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\ValidationException;
class EventAddonRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'extra_photos' => ['nullable', 'integer', 'min:1'],
'extra_guests' => ['nullable', 'integer', 'min:1'],
'extend_gallery_days' => ['nullable', 'integer', 'min:1'],
'reason' => ['nullable', 'string', 'max:255'],
];
}
protected function passedValidation(): void
{
if (
$this->input('extra_photos') === null
&& $this->input('extra_guests') === null
&& $this->input('extend_gallery_days') === null
) {
throw ValidationException::withMessages([
'addons' => __('Please provide at least one add-on to apply.'),
]);
}
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Http\Resources\Tenant;
use App\Models\WatermarkSetting;
use App\Services\Addons\EventAddonCatalog;
use App\Services\AiEditing\AiStylingEntitlementService;
use App\Services\AiEditing\EventAiEditingPolicyService;
use App\Services\Packages\PackageLimitEvaluator;
@@ -222,11 +223,17 @@ class EventResource extends JsonResource
? $eventPackage->addons
: $eventPackage->addons()->latest()->take(10)->get();
return $addons->map(function ($addon) {
$addonLabels = collect(app(EventAddonCatalog::class)->all())
->mapWithKeys(fn (array $addon, string $key): array => [$key => $addon['label'] ?? null])
->all();
return $addons->map(function ($addon) use ($addonLabels) {
return [
'id' => $addon->id,
'key' => $addon->addon_key,
'label' => $addon->metadata['label'] ?? null,
'label' => $addon->metadata['label']
?? ($addonLabels[$addon->addon_key] ?? null)
?? $addon->addon_key,
'status' => $addon->status,
'variant_id' => $addon->variant_id,
'transaction_id' => $addon->transaction_id,

View File

@@ -41,4 +41,27 @@ class PackageAddon extends Model
],
);
}
public function resolvePaypalPrice(): ?float
{
$metadata = is_array($this->metadata) ? $this->metadata : [];
$price = $metadata['price_eur'] ?? null;
return is_numeric($price) ? (float) $price : null;
}
public function isSellableForProvider(?string $provider = null): bool
{
$provider = strtolower((string) ($provider ?? config('package-addons.provider') ?? config('checkout.default_provider', 'paypal')));
if (! $this->active) {
return false;
}
if ($provider === 'lemonsqueezy') {
return filled($this->variant_id);
}
return $this->resolvePaypalPrice() !== null;
}
}

View File

@@ -17,18 +17,43 @@ class EventAddonCatalog
->orderBy('sort')
->get()
->mapWithKeys(function (PackageAddon $addon) {
$metadata = $this->normalizeMetadata($addon->metadata);
return [$addon->key => [
'label' => $addon->label,
'variant_id' => $addon->variant_id,
'increments' => $addon->increments,
'price' => $this->resolveMetadataPrice($addon->metadata ?? []),
'price' => $addon->resolvePaypalPrice(),
'currency' => 'EUR',
'metadata' => $metadata,
]];
})
->all();
// Fallback to config and merge (DB wins)
$configAddons = config('package-addons', []);
$configAddons = collect((array) config('package-addons', []))
->filter(static fn (mixed $addon): bool => is_array($addon))
->map(function (array $addon): array {
$metadata = $this->normalizeMetadata($addon['metadata'] ?? []);
if (! isset($metadata['scope']) && is_string($addon['scope'] ?? null) && trim((string) $addon['scope']) !== '') {
$metadata['scope'] = trim((string) $addon['scope']);
}
if (! isset($metadata['entitlements']) && is_array($addon['entitlements'] ?? null)) {
$metadata['entitlements'] = $addon['entitlements'];
}
return [
'label' => $addon['label'] ?? null,
'variant_id' => $addon['variant_id'] ?? null,
'increments' => Arr::get($addon, 'increments', []),
'price' => $this->resolveConfiguredPrice($addon),
'currency' => $addon['currency'] ?? 'EUR',
'metadata' => $metadata,
];
})
->all();
return array_merge($configAddons, $dbAddons);
}
@@ -61,13 +86,29 @@ class EventAddonCatalog
return is_numeric($price) ? (float) $price : null;
}
protected function resolveMetadataPrice(array $metadata): ?float
protected function resolveConfiguredPrice(array $addon): ?float
{
$price = $metadata['price_eur'] ?? null;
$price = $addon['price'] ?? $addon['price_eur'] ?? null;
return is_numeric($price) ? (float) $price : null;
}
/**
* @return array<string, mixed>
*/
protected function normalizeMetadata(mixed $metadata): array
{
if (is_string($metadata)) {
$decoded = json_decode($metadata, true);
if (json_last_error() === JSON_ERROR_NONE) {
$metadata = $decoded;
}
}
return is_array($metadata) ? $metadata : [];
}
/**
* @return array<string, int>
*/

View File

@@ -9,6 +9,7 @@ use App\Models\Tenant;
use App\Services\LemonSqueezy\LemonSqueezyCheckoutService;
use App\Services\PayPal\Exceptions\PayPalException;
use App\Services\PayPal\PayPalOrderService;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
@@ -31,8 +32,9 @@ class EventAddonCheckoutService
$quantity = max(1, (int) ($payload['quantity'] ?? 1));
$acceptedWaiver = (bool) ($payload['accepted_waiver'] ?? false);
$acceptedTerms = (bool) ($payload['accepted_terms'] ?? false);
$addon = is_string($addonKey) ? $this->catalog->find($addonKey) : null;
if (! $addonKey || ! $this->catalog->find($addonKey)) {
if (! is_string($addonKey) || $addon === null) {
throw ValidationException::withMessages([
'addon_key' => __('Unbekanntes Add-on.'),
]);
@@ -42,27 +44,29 @@ class EventAddonCheckoutService
if (! $event->eventPackage) {
throw ValidationException::withMessages([
'event' => __('Kein Paket für dieses Event hinterlegt.'),
'event' => __('Kein Paket fuer dieses Event hinterlegt.'),
]);
}
$provider = $this->resolveProvider();
if ($provider === CheckoutSession::PROVIDER_PAYPAL) {
return $this->createPayPalCheckout($tenant, $event, $addonKey, $quantity, $acceptedTerms, $acceptedWaiver, $payload);
return $this->createPayPalCheckout($tenant, $event, $addonKey, $addon, $quantity, $acceptedTerms, $acceptedWaiver, $payload);
}
return $this->createLemonSqueezyCheckout($tenant, $event, $addonKey, $quantity, $acceptedTerms, $acceptedWaiver, $payload);
return $this->createLemonSqueezyCheckout($tenant, $event, $addonKey, $addon, $quantity, $acceptedTerms, $acceptedWaiver, $payload);
}
/**
* @param array{addon_key: string, quantity?: int, success_url?: string|null, cancel_url?: string|null} $payload
* @param array<string, mixed> $addon
* @return array{checkout_url: string|null, id: string|null, expires_at: string|null}
*/
protected function createLemonSqueezyCheckout(
Tenant $tenant,
Event $event,
string $addonKey,
array $addon,
int $quantity,
bool $acceptedTerms,
bool $acceptedWaiver,
@@ -72,14 +76,18 @@ class EventAddonCheckoutService
if (! $variantId) {
throw ValidationException::withMessages([
'addon_key' => __('Für dieses Add-on ist kein Lemon Squeezy Variant hinterlegt.'),
'addon_key' => __('Fuer dieses Add-on ist keine Lemon Squeezy Variant hinterlegt.'),
]);
}
$addonIntent = (string) Str::uuid();
$increments = $this->catalog->resolveIncrements($addonKey);
$addonLabel = $this->resolveAddonLabel($addonKey, $addon);
$addonMetadata = $this->resolveAddonMetadata($addon);
$entitlements = $this->resolveAddonEntitlements($addonKey, $addonMetadata);
$price = $this->catalog->resolvePrice($addonKey);
$metadata = array_filter([
$providerMetadata = array_filter([
'tenant_id' => (string) $tenant->id,
'event_id' => (string) $event->id,
'event_package_id' => (string) $event->eventPackage->id,
@@ -94,7 +102,7 @@ class EventAddonCheckoutService
'cancel_url' => $payload['cancel_url'] ?? null,
], static fn ($value) => $value !== null && $value !== '');
$response = $this->checkout->createVariantCheckout($variantId, $metadata, [
$response = $this->checkout->createVariantCheckout($variantId, $providerMetadata, [
'success_url' => $payload['success_url'] ?? null,
'return_url' => $payload['cancel_url'] ?? null,
'customer_email' => $tenant->contact_email ?? $tenant->user?->email,
@@ -117,15 +125,19 @@ class EventAddonCheckoutService
'checkout_id' => $checkoutId,
'transaction_id' => null,
'status' => 'pending',
'metadata' => array_merge($metadata, [
'metadata' => array_filter(array_merge($providerMetadata, [
'label' => $addonLabel,
'scope' => Arr::get($addonMetadata, 'scope'),
'entitlements' => $entitlements === [] ? null : $entitlements,
'price_eur' => $price,
'increments' => $increments,
'provider_payload' => $response,
'consents' => [
'legal_version' => $metadata['legal_version'],
'legal_version' => $providerMetadata['legal_version'],
'accepted_terms_at' => $acceptedTerms ? now()->toIso8601String() : null,
'digital_content_waiver_at' => $acceptedWaiver ? now()->toIso8601String() : null,
],
]),
]), static fn (mixed $value): bool => $value !== null),
'extra_photos' => ($increments['extra_photos'] ?? 0) * $quantity,
'extra_guests' => ($increments['extra_guests'] ?? 0) * $quantity,
'extra_gallery_days' => ($increments['extra_gallery_days'] ?? 0) * $quantity,
@@ -140,23 +152,27 @@ class EventAddonCheckoutService
/**
* @param array{addon_key: string, quantity?: int, success_url?: string|null, cancel_url?: string|null} $payload
* @param array<string, mixed> $addon
* @return array{checkout_url: string|null, id: string|null, expires_at: string|null}
*/
protected function createPayPalCheckout(
Tenant $tenant,
Event $event,
string $addonKey,
array $addon,
int $quantity,
bool $acceptedTerms,
bool $acceptedWaiver,
array $payload,
): array {
$price = $this->catalog->resolvePrice($addonKey);
$addonLabel = $this->catalog->find($addonKey)['label'] ?? $addonKey;
$addonLabel = $this->resolveAddonLabel($addonKey, $addon);
$addonMetadata = $this->resolveAddonMetadata($addon);
$entitlements = $this->resolveAddonEntitlements($addonKey, $addonMetadata);
if (! $price) {
throw ValidationException::withMessages([
'addon_key' => __('Dieses Add-on ist aktuell nicht verfügbar.'),
'addon_key' => __('Dieses Add-on ist aktuell nicht verfuegbar.'),
]);
}
@@ -181,7 +197,7 @@ class EventAddonCheckoutService
'cancel_url' => $payload['cancel_url'] ?? null,
], static fn ($value) => $value !== null && $value !== '');
$addon = EventPackageAddon::create([
$addonPurchase = EventPackageAddon::create([
'event_package_id' => $event->eventPackage->id,
'event_id' => $event->id,
'tenant_id' => $tenant->id,
@@ -193,14 +209,18 @@ class EventAddonCheckoutService
'status' => 'pending',
'amount' => $amount,
'currency' => $currency,
'metadata' => array_merge($metadata, [
'metadata' => array_filter(array_merge($metadata, [
'label' => $addonLabel,
'scope' => Arr::get($addonMetadata, 'scope'),
'entitlements' => $entitlements === [] ? null : $entitlements,
'price_eur' => $price,
'increments' => $increments,
'consents' => [
'legal_version' => $metadata['legal_version'],
'accepted_terms_at' => $acceptedTerms ? now()->toIso8601String() : null,
'digital_content_waiver_at' => $acceptedWaiver ? now()->toIso8601String() : null,
],
]),
]), static fn (mixed $value): bool => $value !== null),
'extra_photos' => ($increments['extra_photos'] ?? 0) * $quantity,
'extra_guests' => ($increments['extra_guests'] ?? 0) * $quantity,
'extra_gallery_days' => ($increments['extra_gallery_days'] ?? 0) * $quantity,
@@ -212,26 +232,26 @@ class EventAddonCheckoutService
try {
$order = $this->paypalOrders->createSimpleOrder(
referenceId: 'addon-'.$addon->id,
referenceId: 'addon-'.$addonPurchase->id,
description: $addonLabel,
amount: $amount,
currency: $currency,
options: [
'custom_id' => 'addon_'.$addon->id,
'custom_id' => 'addon_'.$addonPurchase->id,
'return_url' => $paypalReturnUrl,
'cancel_url' => $paypalReturnUrl,
'locale' => $tenant->user?->preferred_locale ?? app()->getLocale(),
'request_id' => 'addon-'.$addon->id,
'request_id' => 'addon-'.$addonPurchase->id,
],
);
} catch (PayPalException $exception) {
Log::warning('PayPal addon checkout creation failed', [
'addon_id' => $addon->id,
'addon_id' => $addonPurchase->id,
'message' => $exception->getMessage(),
'status' => $exception->status(),
]);
$addon->forceFill([
$addonPurchase->forceFill([
'status' => 'failed',
'error' => $exception->getMessage(),
])->save();
@@ -244,7 +264,7 @@ class EventAddonCheckoutService
$orderId = $order['id'] ?? null;
if (! is_string($orderId) || $orderId === '') {
$addon->forceFill([
$addonPurchase->forceFill([
'status' => 'failed',
'error' => 'PayPal order ID missing.',
])->save();
@@ -256,9 +276,9 @@ class EventAddonCheckoutService
$approveUrl = $this->paypalOrders->resolveApproveUrl($order);
$addon->forceFill([
$addonPurchase->forceFill([
'checkout_id' => $orderId,
'metadata' => array_merge($addon->metadata ?? [], array_filter([
'metadata' => array_merge($addonPurchase->metadata ?? [], array_filter([
'paypal_order_id' => $orderId,
'paypal_approve_url' => $approveUrl,
'paypal_success_url' => $successUrl,
@@ -291,4 +311,61 @@ class EventAddonCheckoutService
{
return config('app.legal_version', now()->toDateString());
}
/**
* @param array<string, mixed> $addon
*/
protected function resolveAddonLabel(string $addonKey, array $addon): string
{
$label = trim((string) ($addon['label'] ?? ''));
return $label !== '' ? $label : $addonKey;
}
/**
* @param array<string, mixed> $addon
* @return array<string, mixed>
*/
protected function resolveAddonMetadata(array $addon): array
{
$metadata = $addon['metadata'] ?? [];
return is_array($metadata) ? $metadata : [];
}
/**
* @param array<string, mixed> $addonMetadata
* @return array<string, mixed>
*/
protected function resolveAddonEntitlements(string $addonKey, array $addonMetadata): array
{
$entitlements = Arr::get($addonMetadata, 'entitlements', []);
$entitlements = is_array($entitlements) ? $entitlements : [];
$features = Arr::get($entitlements, 'features', []);
$features = is_array($features) ? $features : [];
if ($features === [] && in_array($addonKey, (array) config('ai-editing.entitlements.addon_keys', []), true)) {
$requiredFeature = trim((string) config('ai-editing.entitlements.package_feature', 'ai_styling'));
if ($requiredFeature !== '') {
$features[] = $requiredFeature;
}
}
$normalizedFeatures = array_values(array_unique(array_filter(array_map(
static fn (mixed $feature): string => trim((string) $feature),
$features,
))));
if ($normalizedFeatures !== []) {
$entitlements['features'] = $normalizedFeatures;
}
if (is_string(Arr::get($entitlements, 'expires_at'))) {
$entitlements['expires_at'] = trim((string) $entitlements['expires_at']);
}
return $entitlements;
}
}

View File

@@ -0,0 +1,174 @@
<?php
namespace App\Services\Addons;
use App\Models\Event;
use App\Models\EventPackage;
use App\Models\EventPackageAddon;
use App\Models\Package;
use Carbon\CarbonImmutable;
use Illuminate\Support\Arr;
class EventFeatureEntitlementService
{
/**
* @param array<int, string> $addonKeys
* @return array{
* allowed: bool,
* granted_by: 'package'|'addon'|null,
* required_feature: string,
* addon_keys: array<int, string>
* }
*/
public function resolveForEvent(Event $event, string $requiredFeature, array $addonKeys = []): array
{
$eventPackage = $this->resolveEventPackage($event);
if (! $eventPackage) {
return [
'allowed' => false,
'granted_by' => null,
'required_feature' => $requiredFeature,
'addon_keys' => $addonKeys,
];
}
if ($this->packageGrantsFeature($eventPackage->package, $requiredFeature)) {
return [
'allowed' => true,
'granted_by' => 'package',
'required_feature' => $requiredFeature,
'addon_keys' => $addonKeys,
];
}
if ($this->addonGrantsFeature($eventPackage, $addonKeys, $requiredFeature)) {
return [
'allowed' => true,
'granted_by' => 'addon',
'required_feature' => $requiredFeature,
'addon_keys' => $addonKeys,
];
}
return [
'allowed' => false,
'granted_by' => null,
'required_feature' => $requiredFeature,
'addon_keys' => $addonKeys,
];
}
private function resolveEventPackage(Event $event): ?EventPackage
{
$event->loadMissing('eventPackage.package', 'eventPackage.addons');
if ($event->eventPackage) {
return $event->eventPackage;
}
return $event->eventPackages()
->with(['package', 'addons'])
->orderByDesc('purchased_at')
->orderByDesc('id')
->first();
}
private function packageGrantsFeature(?Package $package, string $requiredFeature): bool
{
if (! $package) {
return false;
}
return in_array($requiredFeature, $this->normalizeFeatureList($package->features), true);
}
/**
* @param array<int, string> $addonKeys
*/
private function addonGrantsFeature(EventPackage $eventPackage, array $addonKeys, string $requiredFeature): bool
{
$addons = $eventPackage->relationLoaded('addons')
? $eventPackage->addons
: $eventPackage->addons()
->where('status', 'completed')
->get();
return $addons->contains(function (EventPackageAddon $addon) use ($addonKeys, $requiredFeature): bool {
if (! $this->addonIsActive($addon)) {
return false;
}
if ($addonKeys !== [] && in_array((string) $addon->addon_key, $addonKeys, true)) {
return true;
}
$metadataFeatures = $this->normalizeFeatureList(
Arr::get($addon->metadata ?? [], 'entitlements.features', Arr::get($addon->metadata ?? [], 'features', []))
);
return in_array($requiredFeature, $metadataFeatures, true);
});
}
private function addonIsActive(EventPackageAddon $addon): bool
{
if ($addon->status !== 'completed') {
return false;
}
$expiryCandidates = [
Arr::get($addon->metadata ?? [], 'entitlements.expires_at'),
Arr::get($addon->metadata ?? [], 'expires_at'),
Arr::get($addon->metadata ?? [], 'valid_until'),
];
foreach ($expiryCandidates as $candidate) {
if (! is_string($candidate) || trim($candidate) === '') {
continue;
}
try {
$expiresAt = CarbonImmutable::parse($candidate);
} catch (\Throwable) {
continue;
}
if ($expiresAt->isPast()) {
return false;
}
}
return true;
}
/**
* @return array<int, string>
*/
private function normalizeFeatureList(mixed $value): array
{
if (is_string($value)) {
$decoded = json_decode($value, true);
if (json_last_error() === JSON_ERROR_NONE) {
$value = $decoded;
}
}
if (! is_array($value)) {
return [];
}
if (array_is_list($value)) {
return array_values(array_filter(array_map(
static fn (mixed $feature): string => trim((string) $feature),
$value
)));
}
return array_values(array_filter(array_map(
static fn (mixed $feature): string => trim((string) $feature),
array_keys(array_filter($value, static fn (mixed $enabled): bool => (bool) $enabled))
)));
}
}

View File

@@ -3,14 +3,12 @@
namespace App\Services\AiEditing;
use App\Models\Event;
use App\Models\EventPackage;
use App\Models\EventPackageAddon;
use App\Models\Package;
use Carbon\CarbonImmutable;
use Illuminate\Support\Arr;
use App\Services\Addons\EventFeatureEntitlementService;
class AiStylingEntitlementService
{
public function __construct(private readonly EventFeatureEntitlementService $featureEntitlements) {}
public function packageFeatureKey(): string
{
$featureKey = trim((string) config('ai-editing.entitlements.package_feature', 'ai_styling'));
@@ -50,160 +48,15 @@ class AiStylingEntitlementService
*/
public function resolveForEvent(Event $event): array
{
$requiredFeature = $this->packageFeatureKey();
$addonKeys = $this->addonKeys();
$eventPackage = $this->resolveEventPackage($event);
if (! $eventPackage) {
return [
'allowed' => false,
'granted_by' => null,
'required_feature' => $requiredFeature,
'addon_keys' => $addonKeys,
];
}
if ($this->packageGrantsAccess($eventPackage->package, $requiredFeature)) {
return [
'allowed' => true,
'granted_by' => 'package',
'required_feature' => $requiredFeature,
'addon_keys' => $addonKeys,
];
}
if ($this->addonGrantsAccess($eventPackage, $addonKeys, $requiredFeature)) {
return [
'allowed' => true,
'granted_by' => 'addon',
'required_feature' => $requiredFeature,
'addon_keys' => $addonKeys,
];
}
return [
'allowed' => false,
'granted_by' => null,
'required_feature' => $requiredFeature,
'addon_keys' => $addonKeys,
];
return $this->featureEntitlements->resolveForEvent(
$event,
$this->packageFeatureKey(),
$this->addonKeys(),
);
}
public function hasAccessForEvent(Event $event): bool
{
return (bool) $this->resolveForEvent($event)['allowed'];
}
private function resolveEventPackage(Event $event): ?EventPackage
{
$event->loadMissing('eventPackage.package', 'eventPackage.addons');
if ($event->eventPackage) {
return $event->eventPackage;
}
return $event->eventPackages()
->with(['package', 'addons'])
->orderByDesc('purchased_at')
->orderByDesc('id')
->first();
}
private function packageGrantsAccess(?Package $package, string $requiredFeature): bool
{
if (! $package) {
return false;
}
return in_array($requiredFeature, $this->normalizeFeatureList($package->features), true);
}
/**
* @param array<int, string> $addonKeys
*/
private function addonGrantsAccess(EventPackage $eventPackage, array $addonKeys, string $requiredFeature): bool
{
$addons = $eventPackage->relationLoaded('addons')
? $eventPackage->addons
: $eventPackage->addons()
->where('status', 'completed')
->get();
return $addons->contains(function (EventPackageAddon $addon) use ($addonKeys, $requiredFeature): bool {
if (! $this->addonIsActive($addon)) {
return false;
}
if ($addonKeys !== [] && in_array((string) $addon->addon_key, $addonKeys, true)) {
return true;
}
$metadataFeatures = $this->normalizeFeatureList(
Arr::get($addon->metadata ?? [], 'entitlements.features', Arr::get($addon->metadata ?? [], 'features', []))
);
return in_array($requiredFeature, $metadataFeatures, true);
});
}
private function addonIsActive(EventPackageAddon $addon): bool
{
if ($addon->status !== 'completed') {
return false;
}
$expiryCandidates = [
Arr::get($addon->metadata ?? [], 'entitlements.expires_at'),
Arr::get($addon->metadata ?? [], 'expires_at'),
Arr::get($addon->metadata ?? [], 'valid_until'),
];
foreach ($expiryCandidates as $candidate) {
if (! is_string($candidate) || trim($candidate) === '') {
continue;
}
try {
$expiresAt = CarbonImmutable::parse($candidate);
} catch (\Throwable) {
continue;
}
if ($expiresAt->isPast()) {
return false;
}
}
return true;
}
/**
* @return array<int, string>
*/
private function normalizeFeatureList(mixed $value): array
{
if (is_string($value)) {
$decoded = json_decode($value, true);
if (json_last_error() === JSON_ERROR_NONE) {
$value = $decoded;
}
}
if (! is_array($value)) {
return [];
}
if (array_is_list($value)) {
return array_values(array_filter(array_map(
static fn (mixed $feature): string => trim((string) $feature),
$value
)));
}
return array_values(array_filter(array_map(
static fn (mixed $feature): string => trim((string) $feature),
array_keys(array_filter($value, static fn (mixed $enabled): bool => (bool) $enabled))
)));
}
}

View File

@@ -11,7 +11,7 @@
"filament/filament": "~4.0",
"firebase/php-jwt": "^6.11",
"gboquizosanchez/filament-log-viewer": "*",
"guava/filament-knowledge-base": "^2.1",
"guava/filament-knowledge-base": "2",
"inertiajs/inertia-laravel": "^2.0",
"laravel/framework": "^12.0",
"laravel/horizon": "^5.37",

29
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "710c9b2af62e3768484530129f86a63d",
"content-hash": "81bcef3212aac95b1ef253c5fd14b8aa",
"packages": [
{
"name": "anourvalar/eloquent-serialize",
@@ -1965,16 +1965,16 @@
},
{
"name": "guava/filament-knowledge-base",
"version": "2.1.2",
"version": "2.0.0",
"source": {
"type": "git",
"url": "https://github.com/GuavaCZ/filament-knowledge-base.git",
"reference": "cf62a0e526407b80c4fbcd0be6a6af508460d53d"
"reference": "88affb759afd739a640c9629dbf828b7557d3c8c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/GuavaCZ/filament-knowledge-base/zipball/cf62a0e526407b80c4fbcd0be6a6af508460d53d",
"reference": "cf62a0e526407b80c4fbcd0be6a6af508460d53d",
"url": "https://api.github.com/repos/GuavaCZ/filament-knowledge-base/zipball/88affb759afd739a640c9629dbf828b7557d3c8c",
"reference": "88affb759afd739a640c9629dbf828b7557d3c8c",
"shasum": ""
},
"require": {
@@ -1990,15 +1990,16 @@
"symfony/yaml": "^7.0"
},
"require-dev": {
"larastan/larastan": "^3.0",
"laravel/pint": "^1.0",
"nunomaduro/collision": "^8.0",
"nunomaduro/larastan": "^2.0.1",
"orchestra/testbench": "^9.0|^10.0",
"pestphp/pest": "^3.0",
"pestphp/pest-plugin-arch": "^3.0",
"pestphp/pest-plugin-laravel": "^3.0",
"phpstan/phpstan-deprecation-rules": "^2.0",
"phpstan/phpstan-phpunit": "^2.0"
"pestphp/pest": "^2.0",
"pestphp/pest-plugin-arch": "^2.0",
"pestphp/pest-plugin-laravel": "^2.0",
"phpstan/extension-installer": "^1.1",
"phpstan/phpstan-deprecation-rules": "^1.0",
"phpstan/phpstan-phpunit": "^1.0"
},
"type": "library",
"extra": {
@@ -2037,7 +2038,7 @@
],
"support": {
"issues": "https://github.com/GuavaCZ/filament-knowledge-base/issues",
"source": "https://github.com/GuavaCZ/filament-knowledge-base/tree/2.1.2"
"source": "https://github.com/GuavaCZ/filament-knowledge-base/tree/2.0.0"
},
"funding": [
{
@@ -2045,7 +2046,7 @@
"type": "github"
}
],
"time": "2026-01-25T07:48:36+00:00"
"time": "2025-10-02T20:15:48+00:00"
},
{
"name": "guzzlehttp/guzzle",
@@ -13399,5 +13400,5 @@
"php": "^8.2"
},
"platform-dev": {},
"plugin-api-version": "2.6.0"
"plugin-api-version": "2.9.0"
}

View File

@@ -38,5 +38,11 @@ return [
'price' => (float) env('ADDON_AI_STYLING_PRICE', 9.00),
'currency' => 'EUR',
'increments' => [],
'metadata' => [
'scope' => 'feature',
'entitlements' => [
'features' => ['ai_styling'],
],
],
],
];

View File

@@ -0,0 +1,94 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Arr;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('package_addons')) {
return;
}
$now = Carbon::now();
$existing = DB::table('package_addons')
->where('key', 'ai_styling_unlock')
->first();
$metadata = $this->mergeMetadata($existing?->metadata ?? null);
if (! $existing) {
DB::table('package_addons')->insert([
'key' => 'ai_styling_unlock',
'label' => 'AI Styling Add-on',
'variant_id' => null,
'extra_photos' => 0,
'extra_guests' => 0,
'extra_gallery_days' => 0,
'active' => true,
'sort' => 42,
'metadata' => json_encode($metadata, JSON_UNESCAPED_UNICODE),
'created_at' => $now,
'updated_at' => $now,
]);
return;
}
DB::table('package_addons')
->where('id', $existing->id)
->update([
'label' => $existing->label ?: 'AI Styling Add-on',
'sort' => $existing->sort ?: 42,
'metadata' => json_encode($metadata, JSON_UNESCAPED_UNICODE),
'updated_at' => $now,
]);
}
public function down(): void
{
//
}
/**
* @return array<string, mixed>
*/
private function mergeMetadata(mixed $metadata): array
{
if (is_string($metadata)) {
$decoded = json_decode($metadata, true);
if (json_last_error() === JSON_ERROR_NONE) {
$metadata = $decoded;
}
}
$metadata = is_array($metadata) ? $metadata : [];
$entitlements = Arr::get($metadata, 'entitlements');
$entitlements = is_array($entitlements) ? $entitlements : [];
$features = Arr::get($entitlements, 'features', []);
$features = is_array($features) ? $features : [];
$features[] = 'ai_styling';
$entitlements['features'] = array_values(array_unique(array_filter(array_map(
static fn (mixed $feature): string => trim((string) $feature),
$features,
))));
if (! is_numeric(Arr::get($metadata, 'price_eur'))) {
$metadata['price_eur'] = 9;
}
if (! is_string(Arr::get($metadata, 'scope')) || trim((string) Arr::get($metadata, 'scope')) === '') {
$metadata['scope'] = 'feature';
}
$metadata['entitlements'] = $entitlements;
return $metadata;
}
};

View File

@@ -142,6 +142,7 @@ class PackageAddonSeeder extends Seeder
'sort' => 42,
'metadata' => [
'price_eur' => 9,
'scope' => 'feature',
'entitlements' => [
'features' => ['ai_styling'],
],

View File

@@ -0,0 +1,37 @@
// resources/js/anchors-component.js
function anchorsComponent() {
return {
init: async function() {
let anchors = document.querySelectorAll(".gu-kb-anchor");
let settings = {
root: null,
rootMargin: "-15% 0px -65% 0px",
threshold: 0.1
};
let observer = new IntersectionObserver(this.callback, settings);
anchors.forEach((anchor) => observer.observe(anchor));
},
callback: function(entries, observer) {
let classes = [
"transition",
"duration-300",
"ease-out",
"text-primary-600",
"dark:text-primary-400",
"translate-x-1"
];
entries.forEach((entry) => {
if (entry.isIntersecting) {
let section = "#" + entry.target.id;
document.querySelectorAll(".fi-sidebar-item-button .fi-sidebar-item-label").forEach((el2) => el2.classList.remove(...classes));
let el = document.querySelector(".fi-sidebar-item-button[href='" + section + "'] .fi-sidebar-item-label");
el.classList.add(...classes);
}
});
}
};
}
export {
anchorsComponent as default
};
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsiLi4vLi4vcmVzb3VyY2VzL2pzL2FuY2hvcnMtY29tcG9uZW50LmpzIl0sCiAgInNvdXJjZXNDb250ZW50IjogWyJleHBvcnQgZGVmYXVsdCBmdW5jdGlvbiBhbmNob3JzQ29tcG9uZW50KCkge1xuICAgIHJldHVybiB7XG5cbiAgICAgICAgaW5pdDogYXN5bmMgZnVuY3Rpb24gKCkge1xuICAgICAgICAgICAgbGV0IGFuY2hvcnMgPSBkb2N1bWVudC5xdWVyeVNlbGVjdG9yQWxsKCcuZ3Uta2ItYW5jaG9yJyk7XG5cbiAgICAgICAgICAgIGxldCBzZXR0aW5ncyA9IHtcbiAgICAgICAgICAgICAgICByb290OiBudWxsLFxuICAgICAgICAgICAgICAgIHJvb3RNYXJnaW46ICctMTUlIDBweCAtNjUlIDBweCcsXG4gICAgICAgICAgICAgICAgdGhyZXNob2xkOiAwLjEsXG4gICAgICAgICAgICB9O1xuXG4gICAgICAgICAgICBsZXQgb2JzZXJ2ZXIgPSBuZXcgSW50ZXJzZWN0aW9uT2JzZXJ2ZXIodGhpcy5jYWxsYmFjaywgc2V0dGluZ3MpO1xuXG4gICAgICAgICAgICBhbmNob3JzLmZvckVhY2goYW5jaG9yID0+IG9ic2VydmVyLm9ic2VydmUoYW5jaG9yKSk7XG4gICAgICAgIH0sXG5cbiAgICAgICAgY2FsbGJhY2s6IGZ1bmN0aW9uIChlbnRyaWVzLCBvYnNlcnZlcikge1xuICAgICAgICAgICAgbGV0IGNsYXNzZXMgPSBbXG4gICAgICAgICAgICAgICAgJ3RyYW5zaXRpb24nLCAnZHVyYXRpb24tMzAwJywgJ2Vhc2Utb3V0JywgJ3RleHQtcHJpbWFyeS02MDAnLCAnZGFyazp0ZXh0LXByaW1hcnktNDAwJywgJ3RyYW5zbGF0ZS14LTEnXG4gICAgICAgICAgICBdO1xuXG4gICAgICAgICAgICBlbnRyaWVzLmZvckVhY2goZW50cnkgPT4ge1xuICAgICAgICAgICAgICAgIGlmIChlbnRyeS5pc0ludGVyc2VjdGluZykge1xuICAgICAgICAgICAgICAgICAgICBsZXQgc2VjdGlvbiA9ICcjJyArIGVudHJ5LnRhcmdldC5pZDtcbiAgICAgICAgICAgICAgICAgICAgZG9jdW1lbnQucXVlcnlTZWxlY3RvckFsbCgnLmZpLXNpZGViYXItaXRlbS1idXR0b24gLmZpLXNpZGViYXItaXRlbS1sYWJlbCcpXG4gICAgICAgICAgICAgICAgICAgICAgICAuZm9yRWFjaCgoZWwpID0+IGVsLmNsYXNzTGlzdC5yZW1vdmUoLi4uY2xhc3NlcykpO1xuICAgICAgICAgICAgICAgICAgICBsZXQgZWwgPSBkb2N1bWVudC5xdWVyeVNlbGVjdG9yKCcuZmktc2lkZWJhci1pdGVtLWJ1dHRvbltocmVmPVxcJycgKyBzZWN0aW9uICsgJ1xcJ10gLmZpLXNpZGViYXItaXRlbS1sYWJlbCcpO1xuICAgICAgICAgICAgICAgICAgICBlbC5jbGFzc0xpc3QuYWRkKC4uLmNsYXNzZXMpO1xuICAgICAgICAgICAgICAgIH1cbiAgICAgICAgICAgIH0pO1xuICAgICAgICB9XG4gICAgfVxuXG59O1xuIl0sCiAgIm1hcHBpbmdzIjogIjtBQUFlLFNBQVIsbUJBQW9DO0FBQ3ZDLFNBQU87QUFBQSxJQUVILE1BQU0saUJBQWtCO0FBQ3BCLFVBQUksVUFBVSxTQUFTLGlCQUFpQixlQUFlO0FBRXZELFVBQUksV0FBVztBQUFBLFFBQ1gsTUFBTTtBQUFBLFFBQ04sWUFBWTtBQUFBLFFBQ1osV0FBVztBQUFBLE1BQ2Y7QUFFQSxVQUFJLFdBQVcsSUFBSSxxQkFBcUIsS0FBSyxVQUFVLFFBQVE7QUFFL0QsY0FBUSxRQUFRLFlBQVUsU0FBUyxRQUFRLE1BQU0sQ0FBQztBQUFBLElBQ3REO0FBQUEsSUFFQSxVQUFVLFNBQVUsU0FBUyxVQUFVO0FBQ25DLFVBQUksVUFBVTtBQUFBLFFBQ1Y7QUFBQSxRQUFjO0FBQUEsUUFBZ0I7QUFBQSxRQUFZO0FBQUEsUUFBb0I7QUFBQSxRQUF5QjtBQUFBLE1BQzNGO0FBRUEsY0FBUSxRQUFRLFdBQVM7QUFDckIsWUFBSSxNQUFNLGdCQUFnQjtBQUN0QixjQUFJLFVBQVUsTUFBTSxNQUFNLE9BQU87QUFDakMsbUJBQVMsaUJBQWlCLGdEQUFnRCxFQUNyRSxRQUFRLENBQUNBLFFBQU9BLElBQUcsVUFBVSxPQUFPLEdBQUcsT0FBTyxDQUFDO0FBQ3BELGNBQUksS0FBSyxTQUFTLGNBQWMsbUNBQW9DLFVBQVUsMkJBQTRCO0FBQzFHLGFBQUcsVUFBVSxJQUFJLEdBQUcsT0FBTztBQUFBLFFBQy9CO0FBQUEsTUFDSixDQUFDO0FBQUEsSUFDTDtBQUFBLEVBQ0o7QUFFSjsiLAogICJuYW1lcyI6IFsiZWwiXQp9Cg==

View File

@@ -0,0 +1,23 @@
// resources/js/modals-component.js
function modalsComponent() {
return {
init: async function() {
this.hashChanged();
window.addEventListener("hashchange", () => this.hashChanged());
},
hashChanged: function() {
const fragment = location.hash.substring(1);
const prefix = "modal-";
if (fragment.startsWith(prefix)) {
const modal = fragment.substring(fragment.indexOf(prefix) + prefix.length);
console.log("Open modal via wire: ", modal);
this.$wire.showDocumentation(modal);
history.replaceState(null, null, " ");
}
}
};
}
export {
modalsComponent as default
};
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsiLi4vLi4vcmVzb3VyY2VzL2pzL21vZGFscy1jb21wb25lbnQuanMiXSwKICAic291cmNlc0NvbnRlbnQiOiBbImV4cG9ydCBkZWZhdWx0IGZ1bmN0aW9uIG1vZGFsc0NvbXBvbmVudCgpIHtcbiAgICByZXR1cm4ge1xuXG4gICAgICAgIGluaXQ6IGFzeW5jIGZ1bmN0aW9uICgpIHtcbiAgICAgICAgICAgIHRoaXMuaGFzaENoYW5nZWQoKTtcbiAgICAgICAgICAgIHdpbmRvdy5hZGRFdmVudExpc3RlbmVyKCdoYXNoY2hhbmdlJywgKCk9PnRoaXMuaGFzaENoYW5nZWQoKSk7XG4gICAgICAgIH0sXG5cbiAgICAgICAgaGFzaENoYW5nZWQ6IGZ1bmN0aW9uKCkge1xuICAgICAgICAgICAgY29uc3QgZnJhZ21lbnQgPSBsb2NhdGlvbi5oYXNoLnN1YnN0cmluZygxKTtcbiAgICAgICAgICAgIGNvbnN0IHByZWZpeCA9ICdtb2RhbC0nO1xuXG4gICAgICAgICAgICBpZiAoZnJhZ21lbnQuc3RhcnRzV2l0aChwcmVmaXgpKSB7XG4gICAgICAgICAgICAgICAgY29uc3QgbW9kYWwgPSBmcmFnbWVudC5zdWJzdHJpbmcoZnJhZ21lbnQuaW5kZXhPZihwcmVmaXgpICsgcHJlZml4Lmxlbmd0aCk7XG5cbiAgICAgICAgICAgICAgICBjb25zb2xlLmxvZygnT3BlbiBtb2RhbCB2aWEgd2lyZTogJywgbW9kYWwpO1xuICAgICAgICAgICAgICAgIHRoaXMuJHdpcmUuc2hvd0RvY3VtZW50YXRpb24obW9kYWwpO1xuICAgICAgICAgICAgICAgIC8vIHdpbmRvdy5kaXNwYXRjaEV2ZW50KG5ldyBDdXN0b21FdmVudCgnb3Blbi1tb2RhbCcsIHtkZXRhaWw6IHtpZDogbW9kYWx9fSkpO1xuICAgICAgICAgICAgICAgIGhpc3RvcnkucmVwbGFjZVN0YXRlKG51bGwsIG51bGwsICcgJyk7XG4gICAgICAgICAgICB9XG4gICAgICAgIH0sXG5cbiAgICB9XG5cbn07XG4iXSwKICAibWFwcGluZ3MiOiAiO0FBQWUsU0FBUixrQkFBbUM7QUFDdEMsU0FBTztBQUFBLElBRUgsTUFBTSxpQkFBa0I7QUFDcEIsV0FBSyxZQUFZO0FBQ2pCLGFBQU8saUJBQWlCLGNBQWMsTUFBSSxLQUFLLFlBQVksQ0FBQztBQUFBLElBQ2hFO0FBQUEsSUFFQSxhQUFhLFdBQVc7QUFDcEIsWUFBTSxXQUFXLFNBQVMsS0FBSyxVQUFVLENBQUM7QUFDMUMsWUFBTSxTQUFTO0FBRWYsVUFBSSxTQUFTLFdBQVcsTUFBTSxHQUFHO0FBQzdCLGNBQU0sUUFBUSxTQUFTLFVBQVUsU0FBUyxRQUFRLE1BQU0sSUFBSSxPQUFPLE1BQU07QUFFekUsZ0JBQVEsSUFBSSx5QkFBeUIsS0FBSztBQUMxQyxhQUFLLE1BQU0sa0JBQWtCLEtBQUs7QUFFbEMsZ0JBQVEsYUFBYSxNQUFNLE1BQU0sR0FBRztBQUFBLE1BQ3hDO0FBQUEsSUFDSjtBQUFBLEVBRUo7QUFFSjsiLAogICJuYW1lcyI6IFtdCn0K

View File

@@ -1990,7 +1990,14 @@ export async function getEventAiEditSummary(slug: string): Promise<AiEditUsageSu
export async function createEventAddonCheckout(
eventSlug: string,
params: { addon_key: string; quantity?: number; success_url?: string; cancel_url?: string }
params: {
addon_key: string;
quantity?: number;
success_url?: string;
cancel_url?: string;
accepted_terms?: boolean;
accepted_waiver?: boolean;
}
): Promise<{ checkout_url: string | null; checkout_id: string | null; expires_at: string | null }> {
const response = await authorizedFetch(`${eventEndpoint(eventSlug)}/addons/checkout`, {
method: 'POST',
@@ -2656,6 +2663,8 @@ export type Package = {
included_package_slug?: string | null;
lemonsqueezy_variant_id?: string | null;
lemonsqueezy_product_id?: string | null;
can_checkout?: boolean;
checkout_provider?: 'paypal' | 'lemonsqueezy' | string;
branding_allowed?: boolean | null;
watermark_allowed?: boolean | null;
features: string[] | Record<string, boolean> | null;

View File

@@ -14,10 +14,12 @@ import {
getTenantPackagesOverview,
getTenantPackageCheckoutStatus,
getEvent,
getAddonCatalog,
TenantPackageSummary,
TenantEvent,
TenantBillingTransactionSummary,
EventAddonSummary,
EventAddonCatalogItem,
PaginationMeta,
downloadTenantBillingReceipt,
} from '../api';
@@ -76,6 +78,7 @@ export default function MobileBillingPage() {
);
const [transactionsLoadingMore, setTransactionsLoadingMore] = React.useState(false);
const [addons, setAddons] = React.useState<TenantAddonHistoryEntry[]>([]);
const [catalogAddons, setCatalogAddons] = React.useState<EventAddonCatalogItem[]>([]);
const [addonsMeta, setAddonsMeta] = React.useState<PaginationMeta>(() =>
createInitialPaginationMeta(BILLING_HISTORY_PAGE_SIZE)
);
@@ -109,7 +112,7 @@ export default function MobileBillingPage() {
const load = React.useCallback(async () => {
setLoading(true);
try {
const [pkg, trx, addonHistory] = await Promise.all([
const [pkg, trx, addonHistory, addonCatalog] = await Promise.all([
getTenantPackagesOverview({ force: true }),
getTenantBillingTransactions(1, BILLING_HISTORY_PAGE_SIZE).catch(() => ({
data: [] as TenantBillingTransactionSummary[],
@@ -119,6 +122,7 @@ export default function MobileBillingPage() {
data: [] as TenantAddonHistoryEntry[],
meta: createInitialPaginationMeta(BILLING_HISTORY_PAGE_SIZE),
})),
getAddonCatalog().catch(() => [] as EventAddonCatalogItem[]),
]);
let scopedEvent: TenantEvent | null = null;
@@ -153,6 +157,7 @@ export default function MobileBillingPage() {
setTransactions(trx.data ?? []);
setTransactionsMeta(trx.meta ?? createInitialPaginationMeta(BILLING_HISTORY_PAGE_SIZE));
setAddons(addonHistory.data ?? []);
setCatalogAddons(addonCatalog);
setAddonsMeta(addonHistory.meta ?? createInitialPaginationMeta(BILLING_HISTORY_PAGE_SIZE));
setScopeEvent(scopedEvent);
setScopeAddons(scopedAddons);
@@ -195,6 +200,9 @@ export default function MobileBillingPage() {
}, [scopeEvent, t]);
const scopedEventPath = scopeEvent?.slug ? ADMIN_EVENT_VIEW_PATH(scopeEvent.slug) : null;
const activeEventId = scopeEvent?.id ?? activeEvent?.id ?? null;
const hasSellableAddons = React.useMemo(() => catalogAddons.some((addon) => Boolean(addon.price_id)), [catalogAddons]);
const eventRecapPath = scopeEvent?.slug ? adminPath(`/mobile/events/${scopeEvent.slug}/recap`) : null;
const eventControlRoomPath = scopeEvent?.slug ? adminPath(`/mobile/events/${scopeEvent.slug}/control-room`) : null;
const scopedEventPackage = scopeEvent?.package ?? null;
const scopedEventAddons = React.useMemo<EventAddonSummary[]>(() => {
const rows = scopeEvent?.addons;
@@ -689,6 +697,25 @@ export default function MobileBillingPage() {
{t('billing.sections.currentEvent.noAddons', 'No add-ons purchased for this event.')}
</Text>
)}
{scopeEvent?.slug ? (
hasSellableAddons ? (
<YStack gap="$2" marginTop="$1">
<CTAButton
label={t('billing.sections.currentEvent.openAddonShop', 'Buy add-ons for this event')}
onPress={() => navigate(eventRecapPath ?? adminPath(`/mobile/events/${scopeEvent.slug}/recap`))}
/>
<CTAButton
label={t('billing.sections.currentEvent.openAddonUpsell', 'Open control-room upgrades')}
tone="ghost"
onPress={() => navigate(eventControlRoomPath ?? adminPath(`/mobile/events/${scopeEvent.slug}/control-room`))}
/>
</YStack>
) : (
<Text fontSize="$sm" color={muted}>
{t('billing.sections.currentEvent.noAddonCatalog', 'No add-ons are currently available for purchase.')}
</Text>
)
) : null}
</YStack>
</YStack>
)}
@@ -862,9 +889,17 @@ export default function MobileBillingPage() {
{t('common.loading', 'Lädt...')}
</Text>
) : addons.length === 0 ? (
<Text fontSize="$sm" color={text}>
{t('billing.sections.addOns.empty', 'Keine Add-ons gebucht.')}
</Text>
<YStack gap="$2">
<Text fontSize="$sm" color={text}>
{t('billing.sections.addOns.empty', 'Keine Add-ons gebucht.')}
</Text>
{scopeEvent?.slug && hasSellableAddons ? (
<CTAButton
label={t('billing.sections.addOns.openShop', 'Buy add-ons now')}
onPress={() => navigate(eventRecapPath ?? adminPath(`/mobile/events/${scopeEvent.slug}/recap`))}
/>
) : null}
</YStack>
) : (
<YStack gap="$1.5">
{addons.map((addon) => (

View File

@@ -434,6 +434,24 @@ export default function MobileEventControlRoomPage() {
const aiStylingAddon = React.useMemo(() => {
return catalogAddons.find((addon) => addon.price_id && aiStylingAddonKeys.includes(addon.key)) ?? null;
}, [aiStylingAddonKeys, catalogAddons]);
const aiStylingAddonCta = React.useMemo(() => {
if (!aiStylingAddon) {
return t('controlRoom.aiUpsell.cta', 'Unlock AI Magic Edit');
}
if (typeof aiStylingAddon.price !== 'number' || !Number.isFinite(aiStylingAddon.price)) {
return t('controlRoom.aiUpsell.cta', 'Unlock AI Magic Edit');
}
const currency = aiStylingAddon.currency || 'EUR';
try {
const formattedPrice = new Intl.NumberFormat(undefined, { style: 'currency', currency }).format(aiStylingAddon.price);
return t('controlRoom.aiUpsell.ctaWithPrice', 'Unlock AI Magic Edit ({{price}})', { price: formattedPrice });
} catch {
return t('controlRoom.aiUpsell.cta', 'Unlock AI Magic Edit');
}
}, [aiStylingAddon, t]);
const aiSettingsDirty = React.useMemo(
() => isAiSettingsDirty(aiSettingsDraft, initialAiSettingsDraft),
[aiSettingsDraft, initialAiSettingsDraft],
@@ -907,12 +925,13 @@ export default function MobileEventControlRoomPage() {
const params = new URLSearchParams(location.search);
if (params.get('addon_success')) {
toast.success(t('mobileBilling.addonApplied', 'Add-on applied. Limits update shortly.'));
void refetch();
setModerationPage(1);
void loadModeration();
params.delete('addon_success');
navigate({ pathname: location.pathname, search: params.toString() ? `?${params.toString()}` : '' }, { replace: true });
}
}, [location.search, slug, loadModeration, navigate, t, location.pathname]);
}, [location.search, slug, loadModeration, navigate, t, location.pathname, refetch]);
const updateQueueState = React.useCallback((queue: PhotoModerationAction[]) => {
replacePhotoQueue(queue);
@@ -1161,7 +1180,7 @@ export default function MobileEventControlRoomPage() {
cancel_url: currentUrl,
accepted_terms: consents.acceptedTerms,
accepted_waiver: consents.acceptedWaiver,
} as any);
});
if (checkout.checkout_url) {
window.location.href = checkout.checkout_url;
} else {
@@ -1832,6 +1851,27 @@ export default function MobileEventControlRoomPage() {
/>
</XStack>
</MobileCard>
) : aiStylingAddon ? (
<MobileCard gap="$3" borderColor={border} backgroundColor={surface}>
<XStack alignItems="center" gap="$2">
<Sparkles size={18} color={primary} />
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('controlRoom.aiUpsell.title', 'Unlock AI Magic Edit')}
</Text>
</XStack>
<Text fontSize="$sm" color={muted}>
{t(
'controlRoom.aiUpsell.body',
'Buy the AI Styling add-on for this event without upgrading your full package.'
)}
</Text>
<CTAButton
label={aiStylingAddonCta}
onPress={() => startAddonCheckout('ai')}
loading={busyScope === 'ai'}
disabled={consentBusy}
/>
</MobileCard>
) : null
) : null}

View File

@@ -130,25 +130,16 @@ export default function MobileEventRecapPage() {
};
}, [event?.status, i18n.language, invites, t]);
const handleCheckout = async (addonKey: string) => {
if (!slug || busyScope) return;
setBusyScope(addonKey);
try {
const { checkout_url } = await createEventAddonCheckout(slug, {
addon_key: addonKey,
success_url: window.location.href,
cancel_url: window.location.href,
});
if (checkout_url) {
window.location.href = checkout_url;
}
} catch (err) {
toast.error(getApiErrorMessage(err, t('events.errors.checkoutFailed', 'Bezahlvorgang konnte nicht gestartet werden.')));
setBusyScope(null);
const handleCheckout = (addonKey: string) => {
if (!slug || busyScope) {
return;
}
setBusyScope(addonKey);
setConsentOpen(true);
};
const handleConsentConfirm = async (consents: { acceptedTerms: boolean }) => {
const handleConsentConfirm = async (consents: { acceptedTerms: boolean; acceptedWaiver: boolean }) => {
if (!slug || !busyScope) return;
try {
const { checkout_url } = await createEventAddonCheckout(slug, {
@@ -156,10 +147,16 @@ export default function MobileEventRecapPage() {
success_url: window.location.href,
cancel_url: window.location.href,
accepted_terms: consents.acceptedTerms,
} as any);
accepted_waiver: consents.acceptedWaiver,
});
if (checkout_url) {
window.location.href = checkout_url;
return;
}
toast.error(t('events.errors.checkoutMissing', 'Checkout konnte nicht gestartet werden.'));
setBusyScope(null);
setConsentOpen(false);
} catch (err) {
toast.error(getApiErrorMessage(err, t('events.errors.checkoutFailed', 'Bezahlvorgang konnte nicht gestartet werden.')));
setBusyScope(null);
@@ -200,6 +197,9 @@ export default function MobileEventRecapPage() {
const activeInvite = invites.find((i) => i.is_active) ?? invites[0] ?? null;
const guestLink = activeInvite?.url ?? '';
const galleryExtensionAddons = addons
.filter((addon) => addon.key.startsWith('extend_gallery_') || Number(addon.increments?.extra_gallery_days ?? 0) > 0)
.sort((left, right) => Number(left.increments?.extra_gallery_days ?? 0) - Number(right.increments?.extra_gallery_days ?? 0));
return (
<MobileShell
@@ -397,16 +397,19 @@ export default function MobileEventRecapPage() {
</Text>
<YStack gap="$2">
{addons
.filter((a) => a.key === 'gallery_extension')
.map((addon) => (
<CTAButton
key={addon.key}
label={t('events.recap.buyExtension', 'Galerie um 30 Tage verlängern')}
onPress={() => handleCheckout(addon.key)}
loading={busyScope === addon.key}
/>
))}
{galleryExtensionAddons.map((addon) => (
<CTAButton
key={addon.key}
label={addon.label || t('events.recap.buyExtension', 'Galerie um 30 Tage verlängern')}
onPress={() => handleCheckout(addon.key)}
loading={busyScope === addon.key}
/>
))}
{galleryExtensionAddons.length === 0 ? (
<Text fontSize="$sm" color={muted}>
{t('events.recap.noAddonAvailable', 'Aktuell sind keine Galerie-Add-ons verfügbar.')}
</Text>
) : null}
</YStack>
</MobileCard>
</YStack>

View File

@@ -224,7 +224,7 @@ function PackageShopCard({
const isResellerCatalog = catalogType === 'reseller';
const statusLabel = getPackageStatusLabel({ t, isActive, owned });
const isSubdued = Boolean(!isResellerCatalog && (isDowngrade || !isUpgrade) && !isActive);
const canSelect = isResellerCatalog ? Boolean(pkg.lemonsqueezy_variant_id) : canSelectPackage(isUpgrade, isActive);
const canSelect = isResellerCatalog ? isPackageCheckoutAvailable(pkg) : canSelectPackage(isUpgrade, isActive);
const hasManageAction = Boolean(isActive && onManage);
const includedTierLabel = resolveIncludedTierLabel(t, pkg.included_package_slug ?? null);
const handlePress = isActive ? onManage : canSelect ? onSelect : undefined;
@@ -524,7 +524,7 @@ function PackageShopCompareView({
<YStack width={labelWidth} />
{entries.map((entry) => {
const isResellerCatalog = catalogType === 'reseller';
const canSelect = isResellerCatalog ? Boolean(entry.pkg.lemonsqueezy_variant_id) : canSelectPackage(entry.isUpgrade, entry.isActive);
const canSelect = isResellerCatalog ? isPackageCheckoutAvailable(entry.pkg) : canSelectPackage(entry.isUpgrade, entry.isActive);
const label = isResellerCatalog
? canSelect
? t('shop.partner.buy', 'Kaufen')
@@ -586,6 +586,14 @@ function canSelectPackage(isUpgrade?: boolean, isActive?: boolean): boolean {
return Boolean(isActive || isUpgrade);
}
function isPackageCheckoutAvailable(pkg: Package): boolean {
if (typeof pkg.can_checkout === 'boolean') {
return pkg.can_checkout;
}
return true;
}
function CheckoutConfirmation({ pkg, onCancel }: { pkg: Package; onCancel: () => void }) {
const { t } = useTranslation('management');
const { textStrong, muted, border, primary } = useAdminTheme();

View File

@@ -153,6 +153,7 @@ vi.mock('../invite-layout/export-utils', () => ({
vi.mock('../../api', () => ({
getEvent: vi.fn(),
getAddonCatalog: vi.fn().mockResolvedValue([]),
getTenantPackagesOverview: vi.fn().mockResolvedValue({ packages: [], activePackage: null }),
getTenantBillingTransactions: vi.fn().mockResolvedValue({
data: [
@@ -199,6 +200,7 @@ describe('MobileBillingPage', () => {
} as any);
vi.mocked(api.getTenantAddonHistory).mockResolvedValue({ data: [] } as any);
vi.mocked(api.getEvent).mockResolvedValue(null as any);
vi.mocked(api.getAddonCatalog).mockResolvedValue([]);
});
it('shows current event scoped entitlements separately from tenant history', async () => {
@@ -480,4 +482,39 @@ describe('MobileBillingPage', () => {
expect(triggerDownloadMock).toHaveBeenCalled();
});
});
it('offers a direct add-on purchase entry in billing for the selected event', async () => {
eventContext.activeEvent = {
id: 99,
slug: 'fruehlingsfest',
name: { de: 'Frühlingsfest' },
};
vi.mocked(api.getEvent).mockResolvedValueOnce({
id: 99,
slug: 'fruehlingsfest',
name: { de: 'Frühlingsfest' },
event_date: null,
event_type_id: null,
event_type: null,
status: 'published',
settings: {},
package: null,
addons: [],
limits: null,
member_permissions: [],
} as any);
vi.mocked(api.getAddonCatalog).mockResolvedValueOnce([
{
key: 'extend_gallery_30d',
label: 'Galerie +30 Tage',
price_id: 'paypal',
increments: { extra_gallery_days: 30 },
},
] as any);
render(<MobileBillingPage />);
expect(await screen.findByText('Buy add-ons for this event')).toBeInTheDocument();
});
});

View File

@@ -219,6 +219,7 @@ vi.mock('../../api', () => ({
}));
import MobileEventControlRoomPage from '../EventControlRoomPage';
import * as api from '../../api';
describe('MobileEventControlRoomPage', () => {
it('renders compact grid actions for moderation photos', async () => {
@@ -228,4 +229,22 @@ describe('MobileEventControlRoomPage', () => {
expect(screen.getByLabelText('Hide')).toBeInTheDocument();
expect(screen.getByLabelText('Set highlight')).toBeInTheDocument();
});
it('shows AI addon upsell when AI is locked but addon is purchasable', async () => {
(api.getAddonCatalog as any).mockResolvedValueOnce([
{
key: 'ai_styling_unlock',
label: 'AI Styling Add-on',
price_id: 'paypal',
increments: {},
price: 9,
currency: 'EUR',
},
]);
render(<MobileEventControlRoomPage />);
expect(await screen.findByText('Unlock AI Magic Edit')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Unlock AI Magic Edit/i })).toBeInTheDocument();
});
});

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
const fixtures = vi.hoisted(() => ({
event: {
@@ -23,7 +23,14 @@ const fixtures = vi.hoisted(() => ({
is_active: true,
},
invites: [],
addons: [],
addons: [
{
key: 'extend_gallery_30d',
label: 'Galerie um 30 Tage verlängern',
price_id: 'price_30d',
increments: { extra_gallery_days: 30 },
},
],
}));
vi.mock('react-router-dom', () => ({
@@ -60,7 +67,11 @@ vi.mock('../../api', () => ({
getEventQrInvites: vi.fn().mockResolvedValue(fixtures.invites),
getAddonCatalog: vi.fn().mockResolvedValue(fixtures.addons),
updateEvent: vi.fn().mockResolvedValue(fixtures.event),
createEventAddonCheckout: vi.fn(),
createEventAddonCheckout: vi.fn().mockResolvedValue({
checkout_url: null,
checkout_id: 'chk_123',
expires_at: null,
}),
getEventEngagement: vi.fn().mockResolvedValue({
summary: { totalPhotos: 0, uniqueGuests: 0, tasksSolved: 0, likesTotal: 0 },
leaderboards: { uploads: [], likes: [] },
@@ -87,7 +98,11 @@ vi.mock('../components/MobileShell', () => ({
vi.mock('../components/Primitives', () => ({
MobileCard: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
CTAButton: ({ label }: { label: string }) => <button type="button">{label}</button>,
CTAButton: ({ label, onPress }: { label: string; onPress?: () => void }) => (
<button type="button" onClick={onPress}>
{label}
</button>
),
PillBadge: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SkeletonCard: () => <div>Loading...</div>,
}));
@@ -101,7 +116,13 @@ vi.mock('../components/FormControls', () => ({
}));
vi.mock('../components/LegalConsentSheet', () => ({
LegalConsentSheet: () => <div />,
LegalConsentSheet: ({
open,
onConfirm,
}: {
open: boolean;
onConfirm: (consents: { acceptedTerms: boolean; acceptedWaiver: boolean }) => void;
}) => (open ? <button type="button" onClick={() => onConfirm({ acceptedTerms: true, acceptedWaiver: true })}>Confirm legal</button> : null),
}));
vi.mock('@tamagui/stacks', () => ({
@@ -164,8 +185,21 @@ vi.mock('../theme', () => ({
}));
import MobileEventRecapPage from '../EventRecapPage';
import { createEventAddonCheckout } from '../../api';
describe('MobileEventRecapPage', () => {
beforeEach(() => {
fixtures.addons = [
{
key: 'extend_gallery_30d',
label: 'Galerie um 30 Tage verlängern',
price_id: 'price_30d',
increments: { extra_gallery_days: 30 },
},
];
vi.clearAllMocks();
});
it('renders recap settings toggles', async () => {
render(<MobileEventRecapPage />);
@@ -173,4 +207,26 @@ describe('MobileEventRecapPage', () => {
expect(screen.getByLabelText('Gäste dürfen Fotos laden')).toBeInTheDocument();
expect(screen.getByLabelText('Gäste dürfen Fotos teilen')).toBeInTheDocument();
});
it('requires consent and sends both legal acceptance flags for recap addon checkout', async () => {
const checkoutMock = vi.mocked(createEventAddonCheckout);
render(<MobileEventRecapPage />);
const checkoutButton = await screen.findByRole('button', { name: 'Galerie um 30 Tage verlängern' });
fireEvent.click(checkoutButton);
expect(checkoutMock).not.toHaveBeenCalled();
fireEvent.click(await screen.findByRole('button', { name: 'Confirm legal' }));
await waitFor(() => {
expect(checkoutMock).toHaveBeenCalledWith(fixtures.event.slug, {
addon_key: 'extend_gallery_30d',
success_url: window.location.href,
cancel_url: window.location.href,
accepted_terms: true,
accepted_waiver: true,
});
});
});
});

View File

@@ -301,7 +301,6 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
Route::get('toolkit', [EventController::class, 'toolkit'])->name('tenant.events.toolkit');
Route::get('guest-notifications', [EventGuestNotificationController::class, 'index'])->name('tenant.events.guest-notifications.index');
Route::post('guest-notifications', [EventGuestNotificationController::class, 'store'])->name('tenant.events.guest-notifications.store');
Route::post('addons/apply', [EventAddonController::class, 'apply'])->name('tenant.events.addons.apply');
Route::post('addons/checkout', [EventAddonController::class, 'checkout'])->name('tenant.events.addons.checkout');
Route::prefix('live-show')->group(function () {

View File

@@ -6,6 +6,7 @@ use App\Models\Event;
use App\Models\EventPackage;
use App\Models\EventPackageAddon;
use App\Models\Package;
use App\Models\PackageAddon;
use App\Models\Tenant;
use Tests\Feature\Tenant\TenantTestCase;
@@ -225,4 +226,49 @@ class BillingAddonHistoryTest extends TenantTestCase
$response->assertStatus(404);
$response->assertJsonPath('message', 'Event scope not found.');
}
public function test_tenant_addon_history_uses_catalog_label_when_metadata_label_missing(): void
{
$package = Package::factory()->endcustomer()->create();
$event = Event::factory()->for($this->tenant)->create([
'slug' => 'catalog-label-event',
]);
$eventPackage = EventPackage::create([
'event_id' => $event->id,
'package_id' => $package->id,
'purchased_price' => $package->price,
'purchased_at' => now()->subWeek(),
'used_photos' => 0,
'used_guests' => 0,
'gallery_expires_at' => now()->addDays(20),
]);
PackageAddon::create([
'key' => 'catalog_label_test',
'label' => 'Catalog label fallback',
'active' => true,
'sort' => 5,
'metadata' => ['price_eur' => 4],
]);
EventPackageAddon::create([
'event_package_id' => $eventPackage->id,
'event_id' => $event->id,
'tenant_id' => $this->tenant->id,
'addon_key' => 'catalog_label_test',
'quantity' => 1,
'status' => 'completed',
'amount' => 4.00,
'currency' => 'EUR',
'metadata' => [],
'purchased_at' => now(),
]);
$response = $this->authenticatedRequest('GET', '/api/v1/tenant/billing/addons');
$response->assertOk();
$response->assertJsonPath('data.0.label', 'Catalog label fallback');
}
}

View File

@@ -6,6 +6,7 @@ use App\Models\Event;
use App\Models\EventPackage;
use App\Models\EventPackageAddon;
use App\Models\Package;
use App\Models\PackageAddon;
use Tests\Feature\Tenant\TenantTestCase;
class EventAddonsSummaryTest extends TenantTestCase
@@ -87,4 +88,49 @@ class EventAddonsSummaryTest extends TenantTestCase
$response->assertJsonPath('data.capabilities.ai_styling', true);
$response->assertJsonPath('data.capabilities.ai_styling_granted_by', 'addon');
}
public function test_event_resource_uses_catalog_label_when_addon_metadata_label_missing(): void
{
$package = Package::factory()->endcustomer()->create([
'features' => ['basic_uploads'],
]);
$event = Event::factory()->for($this->tenant)->create([
'status' => 'published',
]);
$eventPackage = EventPackage::create([
'event_id' => $event->id,
'package_id' => $package->id,
'purchased_price' => $package->price,
'purchased_at' => now(),
'used_photos' => 0,
'used_guests' => 0,
'gallery_expires_at' => now()->addDays(30),
]);
PackageAddon::create([
'key' => 'catalog_label_summary_test',
'label' => 'Catalog Summary Label',
'active' => true,
'sort' => 3,
'metadata' => ['price_eur' => 3],
]);
EventPackageAddon::create([
'event_package_id' => $eventPackage->id,
'event_id' => $event->id,
'tenant_id' => $this->tenant->id,
'addon_key' => 'catalog_label_summary_test',
'quantity' => 1,
'status' => 'completed',
'purchased_at' => now(),
'metadata' => [],
]);
$response = $this->authenticatedRequest('GET', "/api/v1/tenant/events/{$event->slug}");
$response->assertOk();
$response->assertJsonPath('data.addons.0.label', 'Catalog Summary Label');
}
}

View File

@@ -0,0 +1,79 @@
<?php
namespace Tests\Feature\Tenant;
use App\Models\PackageAddon;
use Illuminate\Support\Facades\Config;
class EventAddonCatalogControllerTest extends TenantTestCase
{
public function test_paypal_catalog_only_returns_addons_with_paypal_price(): void
{
Config::set('package-addons.provider', 'paypal');
PackageAddon::create([
'key' => 'extend_gallery_30d',
'label' => 'Galerie +30 Tage',
'active' => true,
'sort' => 10,
'extra_gallery_days' => 30,
'metadata' => ['price_eur' => 4],
]);
PackageAddon::create([
'key' => 'extend_gallery_90d',
'label' => 'Galerie +90 Tage',
'active' => true,
'sort' => 20,
'extra_gallery_days' => 90,
'variant_id' => 'variant_only',
'metadata' => [],
]);
$response = $this->authenticatedRequest('GET', '/api/v1/tenant/addons/catalog');
$response->assertOk();
$response->assertJsonFragment([
'key' => 'extend_gallery_30d',
'price_id' => 'paypal',
]);
$response->assertJsonMissing([
'key' => 'extend_gallery_90d',
]);
}
public function test_lemonsqueezy_catalog_only_returns_addons_with_variant(): void
{
Config::set('package-addons.provider', 'lemonsqueezy');
PackageAddon::create([
'key' => 'extend_gallery_30d',
'label' => 'Galerie +30 Tage',
'active' => true,
'sort' => 10,
'extra_gallery_days' => 30,
'variant_id' => 'var_30d',
'metadata' => ['price_eur' => 4],
]);
PackageAddon::create([
'key' => 'extend_gallery_90d',
'label' => 'Galerie +90 Tage',
'active' => true,
'sort' => 20,
'extra_gallery_days' => 90,
'metadata' => ['price_eur' => 10],
]);
$response = $this->authenticatedRequest('GET', '/api/v1/tenant/addons/catalog');
$response->assertOk();
$response->assertJsonFragment([
'key' => 'extend_gallery_30d',
'price_id' => 'var_30d',
]);
$response->assertJsonMissing([
'key' => 'extend_gallery_90d',
]);
}
}

View File

@@ -84,6 +84,9 @@ class EventAddonCheckoutTest extends TenantTestCase
$addon = EventPackageAddon::where('event_package_id', $eventPackage->id)->latest()->first();
$this->assertSame(1000, $addon->extra_photos); // increments * quantity
$this->assertSame('Extra photos (500)', $addon->metadata['label'] ?? null);
$this->assertSame(500, $addon->metadata['increments']['extra_photos'] ?? null);
$this->assertNull($addon->metadata['price_eur'] ?? null);
}
public function test_paypal_checkout_creates_pending_addon_record(): void
@@ -149,5 +152,114 @@ class EventAddonCheckoutTest extends TenantTestCase
$addon = EventPackageAddon::where('event_package_id', $eventPackage->id)->latest()->first();
$this->assertSame(1000, $addon->extra_photos);
$this->assertSame('Extra photos (500)', $addon->metadata['label'] ?? null);
$this->assertSame(12.5, $addon->metadata['price_eur'] ?? null);
}
public function test_ai_styling_checkout_persists_feature_entitlement_metadata(): void
{
Config::set('package-addons.provider', CheckoutSession::PROVIDER_PAYPAL);
Config::set('checkout.currency', 'EUR');
Config::set('package-addons.ai_styling_unlock', [
'label' => 'AI Styling Add-on',
'price' => 9.00,
'increments' => [],
'metadata' => [
'scope' => 'feature',
],
]);
$package = Package::factory()->endcustomer()->create([
'max_photos' => 100,
'max_guests' => 50,
'gallery_days' => 7,
]);
$event = Event::factory()->for($this->tenant)->create([
'status' => 'published',
]);
$eventPackage = EventPackage::create([
'event_id' => $event->id,
'package_id' => $package->id,
'purchased_price' => $package->price,
'purchased_at' => now(),
'used_photos' => 0,
'used_guests' => 0,
'gallery_expires_at' => now()->addDays(7),
]);
$orders = Mockery::mock(PayPalOrderService::class);
$orders->shouldReceive('createSimpleOrder')
->once()
->andReturn([
'id' => 'ORDER-AI-1',
'links' => [
['rel' => 'approve', 'href' => 'https://paypal.test/approve-ai'],
],
]);
$orders->shouldReceive('resolveApproveUrl')
->once()
->andReturn('https://paypal.test/approve-ai');
$this->app->instance(PayPalOrderService::class, $orders);
$response = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/addons/checkout", [
'addon_key' => 'ai_styling_unlock',
'quantity' => 1,
'accepted_terms' => true,
'accepted_waiver' => true,
]);
$response->assertOk();
$response->assertJsonPath('checkout_id', 'ORDER-AI-1');
$this->assertDatabaseHas('event_package_addons', [
'event_package_id' => $eventPackage->id,
'addon_key' => 'ai_styling_unlock',
'status' => 'pending',
'amount' => 9.00,
'currency' => 'EUR',
]);
$addon = EventPackageAddon::where('event_package_id', $eventPackage->id)->latest()->first();
$this->assertSame('AI Styling Add-on', $addon->metadata['label'] ?? null);
$this->assertSame('feature', $addon->metadata['scope'] ?? null);
$this->assertSame(['ai_styling'], $addon->metadata['entitlements']['features'] ?? null);
$this->assertEquals(9.0, $addon->metadata['price_eur'] ?? null);
$this->assertSame(0, $addon->extra_photos);
$this->assertSame(0, $addon->extra_guests);
$this->assertSame(0, $addon->extra_gallery_days);
}
public function test_checkout_requires_both_legal_consents(): void
{
$package = Package::factory()->endcustomer()->create([
'max_photos' => 100,
'max_guests' => 50,
'gallery_days' => 7,
]);
$event = Event::factory()->for($this->tenant)->create([
'status' => 'published',
]);
EventPackage::create([
'event_id' => $event->id,
'package_id' => $package->id,
'purchased_price' => $package->price,
'purchased_at' => now(),
'used_photos' => 0,
'used_guests' => 0,
'gallery_expires_at' => now()->addDays(7),
]);
$response = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/addons/checkout", [
'addon_key' => 'extra_photos_small',
'quantity' => 1,
'accepted_terms' => true,
]);
$response->assertStatus(422);
$response->assertJsonValidationErrors(['accepted_waiver']);
}
}

View File

@@ -5,11 +5,10 @@ namespace Tests\Feature\Tenant;
use App\Models\Event;
use App\Models\EventPackage;
use App\Models\Package;
use Carbon\Carbon;
class EventAddonControllerTest extends TenantTestCase
{
public function test_tenant_admin_can_apply_addons(): void
public function test_apply_endpoint_is_not_available_for_tenant_admins(): void
{
$package = Package::factory()->endcustomer()->create([
'max_photos' => 100,
@@ -28,11 +27,9 @@ class EventAddonControllerTest extends TenantTestCase
'purchased_at' => now()->subDay(),
'used_photos' => 10,
'used_guests' => 5,
'gallery_expires_at' => Carbon::now()->addDays(7),
'gallery_expires_at' => now()->addDays(7),
]);
$originalExpiry = $eventPackage->gallery_expires_at->copy();
$response = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/addons/apply", [
'extra_photos' => 50,
'extra_guests' => 25,
@@ -40,39 +37,12 @@ class EventAddonControllerTest extends TenantTestCase
'reason' => 'Manual boost for event',
]);
$response->assertOk();
$response->assertJsonPath('data.limits.photos.limit', 150);
$response->assertJsonPath('data.limits.guests.limit', 75);
$response->assertNotFound();
$eventPackage->refresh();
$this->assertSame(50, $eventPackage->extra_photos);
$this->assertSame(25, $eventPackage->extra_guests);
$this->assertSame(3, $eventPackage->extra_gallery_days);
$this->assertTrue($eventPackage->gallery_expires_at->isSameDay($originalExpiry->addDays(3)));
}
public function test_validation_fails_when_no_addons_provided(): void
{
$package = Package::factory()->endcustomer()->create();
$event = Event::factory()->for($this->tenant)->create([
'status' => 'published',
]);
EventPackage::create([
'event_id' => $event->id,
'package_id' => $package->id,
'purchased_price' => $package->price,
'purchased_at' => now(),
'used_photos' => 0,
'used_guests' => 0,
'gallery_expires_at' => now()->addDays(7),
]);
$response = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/addons/apply", []);
$response->assertStatus(422);
$response->assertJsonValidationErrors('addons');
$this->assertSame(0, (int) $eventPackage->extra_photos);
$this->assertSame(0, (int) $eventPackage->extra_guests);
$this->assertSame(0, (int) $eventPackage->extra_gallery_days);
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace Tests\Feature\Tenant;
use App\Models\Package;
use Illuminate\Support\Facades\Config;
class PackageCatalogAvailabilityTest extends TenantTestCase
{
public function test_paypal_catalog_marks_paid_reseller_packages_as_checkout_ready(): void
{
Config::set('checkout.default_provider', 'paypal');
Package::factory()->create([
'type' => 'reseller',
'price' => 99,
'lemonsqueezy_variant_id' => null,
]);
$response = $this->authenticatedRequest('GET', '/api/v1/tenant/packages?type=reseller');
$response->assertOk();
$response->assertJsonPath('data.0.checkout_provider', 'paypal');
$response->assertJsonPath('data.0.can_checkout', true);
}
public function test_lemonsqueezy_catalog_requires_variant_for_checkout(): void
{
Config::set('checkout.default_provider', 'lemonsqueezy');
Package::factory()->create([
'type' => 'reseller',
'price' => 99,
'lemonsqueezy_variant_id' => null,
]);
Package::factory()->create([
'type' => 'reseller',
'price' => 199,
'lemonsqueezy_variant_id' => 'pri_reseller_2',
]);
$response = $this->authenticatedRequest('GET', '/api/v1/tenant/packages?type=reseller');
$response->assertOk();
$response->assertJsonPath('data.0.checkout_provider', 'lemonsqueezy');
$response->assertJsonPath('data.0.can_checkout', false);
$response->assertJsonPath('data.1.can_checkout', true);
}
}

View File

@@ -31,6 +31,10 @@ class EventAddonCatalogTest extends TestCase
'sort' => 1,
'metadata' => [
'price_eur' => 12,
'scope' => 'feature',
'entitlements' => [
'features' => ['ai_styling'],
],
],
]);
@@ -44,5 +48,7 @@ class EventAddonCatalogTest extends TestCase
$this->assertSame(200, $addon['increments']['extra_photos']);
$this->assertSame(12.0, $addon['price']);
$this->assertSame('EUR', $addon['currency']);
$this->assertSame('feature', $addon['metadata']['scope'] ?? null);
$this->assertSame(['ai_styling'], $addon['metadata']['entitlements']['features'] ?? null);
}
}

View File

@@ -0,0 +1,108 @@
<?php
namespace Tests\Unit\Services;
use App\Models\Event;
use App\Models\EventPackage;
use App\Models\EventPackageAddon;
use App\Models\Package;
use App\Services\Addons\EventFeatureEntitlementService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class EventFeatureEntitlementServiceTest extends TestCase
{
use RefreshDatabase;
public function test_it_grants_feature_via_package(): void
{
$service = app(EventFeatureEntitlementService::class);
$event = Event::factory()->create();
$package = Package::factory()->endcustomer()->create([
'features' => ['basic_uploads', 'ai_styling'],
]);
EventPackage::query()->create([
'event_id' => $event->id,
'package_id' => $package->id,
'purchased_price' => $package->price,
'purchased_at' => now(),
'gallery_expires_at' => now()->addDays(30),
]);
$result = $service->resolveForEvent($event->fresh(), 'ai_styling', ['ai_styling_unlock']);
$this->assertTrue($result['allowed']);
$this->assertSame('package', $result['granted_by']);
}
public function test_it_grants_feature_via_completed_addon_key(): void
{
$service = app(EventFeatureEntitlementService::class);
$event = Event::factory()->create();
$package = Package::factory()->endcustomer()->create([
'features' => ['basic_uploads'],
]);
$eventPackage = EventPackage::query()->create([
'event_id' => $event->id,
'package_id' => $package->id,
'purchased_price' => $package->price,
'purchased_at' => now(),
'gallery_expires_at' => now()->addDays(30),
]);
EventPackageAddon::query()->create([
'event_package_id' => $eventPackage->id,
'event_id' => $event->id,
'tenant_id' => $event->tenant_id,
'addon_key' => 'custom_entitlement',
'quantity' => 1,
'status' => 'completed',
'purchased_at' => now(),
]);
$result = $service->resolveForEvent($event->fresh(), 'ai_styling', ['custom_entitlement']);
$this->assertTrue($result['allowed']);
$this->assertSame('addon', $result['granted_by']);
}
public function test_it_denies_feature_when_addon_is_expired(): void
{
$service = app(EventFeatureEntitlementService::class);
$event = Event::factory()->create();
$package = Package::factory()->endcustomer()->create([
'features' => ['basic_uploads'],
]);
$eventPackage = EventPackage::query()->create([
'event_id' => $event->id,
'package_id' => $package->id,
'purchased_price' => $package->price,
'purchased_at' => now(),
'gallery_expires_at' => now()->addDays(30),
]);
EventPackageAddon::query()->create([
'event_package_id' => $eventPackage->id,
'event_id' => $event->id,
'tenant_id' => $event->tenant_id,
'addon_key' => 'custom_entitlement',
'quantity' => 1,
'status' => 'completed',
'purchased_at' => now()->subDays(2),
'metadata' => [
'entitlements' => [
'expires_at' => now()->subDay()->toIso8601String(),
'features' => ['ai_styling'],
],
],
]);
$result = $service->resolveForEvent($event->fresh(), 'ai_styling', ['custom_entitlement']);
$this->assertFalse($result['allowed']);
$this->assertNull($result['granted_by']);
}
}