From 446aada9c8360cb82634b54cd3cfef2409483b91 Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Thu, 18 Jun 2026 16:36:09 +0000 Subject: [PATCH] Paket-Vorteile: dynamische, im Admin pflegbare Vorteilsliste je Tarif Neue JSON-Spalte plans.benefits (Migration + Cast + Plan::benefitsList()). Im Admin (Tarife & Pakete) pro Tarif zeilenweise pflegbar; die In-App- Buchungsseite rendert die Liste in den Tarif-Karten statt einer festen Zeile. Seeder/Factory mit Beispiel-Vorteilen. Single Source of Truth fuer Buchungsseite und (folgt separat) die oeffentliche Preise-Seite. Co-Authored-By: Claude Opus 4.8 --- app/Models/Plan.php | 17 ++++++++ database/factories/PlanFactory.php | 4 ++ ..._18_183248_add_benefits_to_plans_table.php | 31 ++++++++++++++ database/seeders/PlanSeeder.php | 27 +++++++++++-- .../livewire/admin/payments/plans.blade.php | 17 ++++++++ .../livewire/customer/bookings.blade.php | 15 +++++-- tests/Feature/Billing/AdminPlansPageTest.php | 40 +++++++++++++++++++ tests/Feature/Billing/BookingsPageTest.php | 18 +++++++++ 8 files changed, 161 insertions(+), 8 deletions(-) create mode 100644 database/migrations/2026_06_18_183248_add_benefits_to_plans_table.php diff --git a/app/Models/Plan.php b/app/Models/Plan.php index 94af7821..b7cf0650 100644 --- a/app/Models/Plan.php +++ b/app/Models/Plan.php @@ -30,6 +30,7 @@ class Plan extends Model 'stripe_price_id_yearly', 'is_active', 'sort_order', + 'benefits', ]; protected function casts(): array @@ -41,9 +42,25 @@ class Plan extends Model 'daily_limit' => 'integer', 'is_active' => 'boolean', 'sort_order' => 'integer', + 'benefits' => 'array', ]; } + /** + * Gepflegte Vorteilsliste als bereinigtes String-Array (leere Zeilen + * entfernt). Single Source of Truth für Buchungsseite und Preise-Seite. + * + * @return list + */ + public function benefitsList(): array + { + return collect($this->benefits ?? []) + ->map(fn ($benefit): string => trim((string) $benefit)) + ->filter() + ->values() + ->all(); + } + public function scopeActive(Builder $query): Builder { return $query->where('is_active', true)->orderBy('sort_order'); diff --git a/database/factories/PlanFactory.php b/database/factories/PlanFactory.php index 0c680077..bc135708 100644 --- a/database/factories/PlanFactory.php +++ b/database/factories/PlanFactory.php @@ -26,6 +26,10 @@ class PlanFactory extends Factory 'daily_limit' => null, 'is_active' => true, 'sort_order' => fake()->numberBetween(0, 10), + 'benefits' => [ + 'Redaktionelle Prüfung inklusive', + fake()->sentence(4), + ], ]; } diff --git a/database/migrations/2026_06_18_183248_add_benefits_to_plans_table.php b/database/migrations/2026_06_18_183248_add_benefits_to_plans_table.php new file mode 100644 index 00000000..e57d186d --- /dev/null +++ b/database/migrations/2026_06_18_183248_add_benefits_to_plans_table.php @@ -0,0 +1,31 @@ +json('benefits')->nullable()->after('daily_limit'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('plans', function (Blueprint $table): void { + $table->dropColumn('benefits'); + }); + } +}; diff --git a/database/seeders/PlanSeeder.php b/database/seeders/PlanSeeder.php index 417b92c2..af779918 100644 --- a/database/seeders/PlanSeeder.php +++ b/database/seeders/PlanSeeder.php @@ -15,10 +15,29 @@ class PlanSeeder extends Seeder public function run(): void { $plans = [ - ['slug' => 'starter', 'name' => 'Starter', 'monthly_price_cents' => 2900, 'press_release_quota' => 3, 'daily_limit' => null, 'sort_order' => 1], - ['slug' => 'business', 'name' => 'Business', 'monthly_price_cents' => 4900, 'press_release_quota' => 10, 'daily_limit' => 2, 'sort_order' => 2], - ['slug' => 'pro', 'name' => 'Pro', 'monthly_price_cents' => 9900, 'press_release_quota' => 25, 'daily_limit' => 3, 'sort_order' => 3], - ['slug' => 'agency', 'name' => 'Agency', 'monthly_price_cents' => 19900, 'press_release_quota' => 60, 'daily_limit' => 5, 'sort_order' => 4], + ['slug' => 'starter', 'name' => 'Starter', 'monthly_price_cents' => 2900, 'press_release_quota' => 3, 'daily_limit' => null, 'sort_order' => 1, 'benefits' => [ + 'Redaktionelle Prüfung & Veröffentlichung inklusive', + 'Veröffentlichung auf presseecho und businessportal24', + 'Reduzierter Extra-PM-Preis', + ]], + ['slug' => 'business', 'name' => 'Business', 'monthly_price_cents' => 4900, 'press_release_quota' => 10, 'daily_limit' => 2, 'sort_order' => 2, 'benefits' => [ + 'Alles aus Starter', + 'Höheres Monatskontingent', + 'Günstigere Extra-Pressemitteilungen', + 'Boost-Platzierung vergünstigt', + ]], + ['slug' => 'pro', 'name' => 'Pro', 'monthly_price_cents' => 9900, 'press_release_quota' => 25, 'daily_limit' => 3, 'sort_order' => 3, 'benefits' => [ + 'Alles aus Business', + 'Großes Monatskontingent', + 'Beste Extra-PM-Konditionen', + 'Priorisierte redaktionelle Prüfung', + ]], + ['slug' => 'agency', 'name' => 'Agency', 'monthly_price_cents' => 19900, 'press_release_quota' => 60, 'daily_limit' => 5, 'sort_order' => 4, 'benefits' => [ + 'Alles aus Pro', + 'Maximales Monatskontingent für mehrere Marken', + 'Niedrigster Extra-PM-Preis', + 'Mengenrabatte auf Add-ons', + ]], ]; foreach ($plans as $plan) { diff --git a/resources/views/livewire/admin/payments/plans.blade.php b/resources/views/livewire/admin/payments/plans.blade.php index 412c62cc..de764020 100644 --- a/resources/views/livewire/admin/payments/plans.blade.php +++ b/resources/views/livewire/admin/payments/plans.blade.php @@ -25,6 +25,8 @@ new #[Layout('components.layouts.app'), Title('Tarife & Pakete')] class extends public string $sortOrder = '0'; + public string $benefits = ''; + public ?string $savedMessage = null; public function edit(int $planId): void @@ -39,6 +41,7 @@ new #[Layout('components.layouts.app'), Title('Tarife & Pakete')] class extends $this->dailyLimit = $plan->daily_limit === null ? '' : (string) $plan->daily_limit; $this->isActive = $plan->is_active; $this->sortOrder = (string) $plan->sort_order; + $this->benefits = implode("\n", $plan->benefitsList()); $this->resetValidation(); Flux::modal('plan-edit')->show(); @@ -58,6 +61,7 @@ new #[Layout('components.layouts.app'), Title('Tarife & Pakete')] class extends 'quota' => ['required', 'integer', 'min:0'], 'dailyLimit' => ['nullable', 'integer', 'min:1'], 'sortOrder' => ['required', 'integer', 'min:0'], + 'benefits' => ['nullable', 'string', 'max:2000'], ], attributes: [ 'name' => __('Name'), @@ -66,9 +70,17 @@ new #[Layout('components.layouts.app'), Title('Tarife & Pakete')] class extends 'quota' => __('PM-Kontingent'), 'dailyLimit' => __('Tageslimit'), 'sortOrder' => __('Sortierung'), + 'benefits' => __('Vorteile'), ], ); + // Vorteile zeilenweise erfassen: je Zeile ein Vorteil, leere Zeilen raus. + $benefitsList = collect(preg_split('/\r\n|\r|\n/', $this->benefits) ?: []) + ->map(fn (string $line): string => trim($line)) + ->filter() + ->values() + ->all(); + $plan = Plan::query()->findOrFail($this->editingPlanId); $plan->fill([ @@ -79,6 +91,7 @@ new #[Layout('components.layouts.app'), Title('Tarife & Pakete')] class extends 'daily_limit' => $validated['dailyLimit'] === null || $validated['dailyLimit'] === '' ? null : (int) $validated['dailyLimit'], 'is_active' => $this->isActive, 'sort_order' => (int) $validated['sortOrder'], + 'benefits' => $benefitsList, ]); $priceChanged = $plan->isDirty(['monthly_price_cents', 'yearly_price_cents']); @@ -272,6 +285,10 @@ new #[Layout('components.layouts.app'), Title('Tarife & Pakete')] class extends + + diff --git a/resources/views/livewire/customer/bookings.blade.php b/resources/views/livewire/customer/bookings.blade.php index 8429f172..021d1d81 100644 --- a/resources/views/livewire/customer/bookings.blade.php +++ b/resources/views/livewire/customer/bookings.blade.php @@ -562,10 +562,17 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte {{ __('Ohne Tageslimit') }} @endif -
  • - - {{ __('Prüfung und Veröffentlichung inklusive') }} -
  • + @forelse ($plan->benefitsList() as $benefit) +
  • + + {{ $benefit }} +
  • + @empty +
  • + + {{ __('Prüfung und Veröffentlichung inklusive') }} +
  • + @endforelse
    diff --git a/tests/Feature/Billing/AdminPlansPageTest.php b/tests/Feature/Billing/AdminPlansPageTest.php index 467237f2..3de928f6 100644 --- a/tests/Feature/Billing/AdminPlansPageTest.php +++ b/tests/Feature/Billing/AdminPlansPageTest.php @@ -100,6 +100,46 @@ test('saving a plan updates the local record and triggers the stripe sync', func ->and($plan->daily_limit)->toBe(3); }); +test('benefitsList returns a trimmed, non-empty list and tolerates null', function () { + $plan = Plan::factory()->create(['benefits' => [' Vorteil A ', '', 'Vorteil B', ' ']]); + + expect($plan->benefitsList())->toBe(['Vorteil A', 'Vorteil B']); + + $bare = Plan::factory()->create(['benefits' => null]); + expect($bare->benefitsList())->toBe([]); +}); + +test('saving a plan persists the benefits line by line', function () { + /** @var TestCase $this */ + $plan = Plan::factory()->create(['name' => 'Business', 'benefits' => []]); + + $this->mock(StripePlanSyncService::class, function ($mock) { + $mock->shouldReceive('syncAfterUpdate')->once(); + }); + + $this->actingAs(plansPageAdmin()); + + LivewireVolt::test('admin.payments.plans') + ->call('edit', $plan->id) + ->set('benefits', "Erster Vorteil\n\n Zweiter Vorteil \nDritter Vorteil") + ->call('save') + ->assertHasNoErrors(); + + expect($plan->refresh()->benefitsList()) + ->toBe(['Erster Vorteil', 'Zweiter Vorteil', 'Dritter Vorteil']); +}); + +test('editing a plan preloads its benefits as newline text', function () { + /** @var TestCase $this */ + $plan = Plan::factory()->create(['benefits' => ['Vorteil eins', 'Vorteil zwei']]); + + $this->actingAs(plansPageAdmin()); + + LivewireVolt::test('admin.payments.plans') + ->call('edit', $plan->id) + ->assertSet('benefits', "Vorteil eins\nVorteil zwei"); +}); + test('saving without a price change shows the plain success message', function () { /** @var TestCase $this */ $plan = Plan::factory()->create([ diff --git a/tests/Feature/Billing/BookingsPageTest.php b/tests/Feature/Billing/BookingsPageTest.php index afcf8c49..c990781c 100644 --- a/tests/Feature/Billing/BookingsPageTest.php +++ b/tests/Feature/Billing/BookingsPageTest.php @@ -54,6 +54,24 @@ test('the bookings page renders the active plans with checkout links', function ->assertSee(route('me.checkout.subscription', ['planSlug' => 'business', 'interval' => 'monthly']), false); }); +test('the plan card renders the maintained benefits list', function () { + /** @var TestCase $this */ + Plan::factory()->create([ + 'name' => 'Business', + 'slug' => 'business', + 'press_release_quota' => 10, + 'benefits' => ['Günstigere Extra-Pressemitteilungen', 'Boost-Platzierung vergünstigt'], + ]); + + $user = bookingsTestCustomer(); + BillingAddress::factory()->create(['user_id' => $user->id]); + $this->actingAs($user); + + LivewireVolt::test('customer.bookings') + ->assertSee('Günstigere Extra-Pressemitteilungen') + ->assertSee('Boost-Platzierung vergünstigt'); +}); + test('the single pm block links to its checkout', function () { /** @var TestCase $this */ config()->set('billing.single_pm_stripe_price_id', 'price_test_single_pm');