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(