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 <noreply@anthropic.com>
This commit is contained in:
parent
58b427fcd0
commit
446aada9c8
8 changed files with 161 additions and 8 deletions
|
|
@ -30,6 +30,7 @@ class Plan extends Model
|
||||||
'stripe_price_id_yearly',
|
'stripe_price_id_yearly',
|
||||||
'is_active',
|
'is_active',
|
||||||
'sort_order',
|
'sort_order',
|
||||||
|
'benefits',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
|
|
@ -41,9 +42,25 @@ class Plan extends Model
|
||||||
'daily_limit' => 'integer',
|
'daily_limit' => 'integer',
|
||||||
'is_active' => 'boolean',
|
'is_active' => 'boolean',
|
||||||
'sort_order' => 'integer',
|
'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<string>
|
||||||
|
*/
|
||||||
|
public function benefitsList(): array
|
||||||
|
{
|
||||||
|
return collect($this->benefits ?? [])
|
||||||
|
->map(fn ($benefit): string => trim((string) $benefit))
|
||||||
|
->filter()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
public function scopeActive(Builder $query): Builder
|
public function scopeActive(Builder $query): Builder
|
||||||
{
|
{
|
||||||
return $query->where('is_active', true)->orderBy('sort_order');
|
return $query->where('is_active', true)->orderBy('sort_order');
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,10 @@ class PlanFactory extends Factory
|
||||||
'daily_limit' => null,
|
'daily_limit' => null,
|
||||||
'is_active' => true,
|
'is_active' => true,
|
||||||
'sort_order' => fake()->numberBetween(0, 10),
|
'sort_order' => fake()->numberBetween(0, 10),
|
||||||
|
'benefits' => [
|
||||||
|
'Redaktionelle Prüfung inklusive',
|
||||||
|
fake()->sentence(4),
|
||||||
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('plans', function (Blueprint $table): void {
|
||||||
|
// Marketing-Vorteile je Tarif (eine Liste freier Texte), im Admin
|
||||||
|
// pflegbar. Genutzt von der In-App-Buchungsseite und der
|
||||||
|
// öffentlichen Preise-Seite (gemeinsame Datenquelle Plan).
|
||||||
|
$table->json('benefits')->nullable()->after('daily_limit');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('plans', function (Blueprint $table): void {
|
||||||
|
$table->dropColumn('benefits');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -15,10 +15,29 @@ class PlanSeeder extends Seeder
|
||||||
public function run(): void
|
public function run(): void
|
||||||
{
|
{
|
||||||
$plans = [
|
$plans = [
|
||||||
['slug' => 'starter', 'name' => 'Starter', 'monthly_price_cents' => 2900, 'press_release_quota' => 3, 'daily_limit' => null, 'sort_order' => 1],
|
['slug' => 'starter', 'name' => 'Starter', 'monthly_price_cents' => 2900, 'press_release_quota' => 3, 'daily_limit' => null, 'sort_order' => 1, 'benefits' => [
|
||||||
['slug' => 'business', 'name' => 'Business', 'monthly_price_cents' => 4900, 'press_release_quota' => 10, 'daily_limit' => 2, 'sort_order' => 2],
|
'Redaktionelle Prüfung & Veröffentlichung inklusive',
|
||||||
['slug' => 'pro', 'name' => 'Pro', 'monthly_price_cents' => 9900, 'press_release_quota' => 25, 'daily_limit' => 3, 'sort_order' => 3],
|
'Veröffentlichung auf presseecho und businessportal24',
|
||||||
['slug' => 'agency', 'name' => 'Agency', 'monthly_price_cents' => 19900, 'press_release_quota' => 60, 'daily_limit' => 5, 'sort_order' => 4],
|
'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) {
|
foreach ($plans as $plan) {
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,8 @@ new #[Layout('components.layouts.app'), Title('Tarife & Pakete')] class extends
|
||||||
|
|
||||||
public string $sortOrder = '0';
|
public string $sortOrder = '0';
|
||||||
|
|
||||||
|
public string $benefits = '';
|
||||||
|
|
||||||
public ?string $savedMessage = null;
|
public ?string $savedMessage = null;
|
||||||
|
|
||||||
public function edit(int $planId): void
|
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->dailyLimit = $plan->daily_limit === null ? '' : (string) $plan->daily_limit;
|
||||||
$this->isActive = $plan->is_active;
|
$this->isActive = $plan->is_active;
|
||||||
$this->sortOrder = (string) $plan->sort_order;
|
$this->sortOrder = (string) $plan->sort_order;
|
||||||
|
$this->benefits = implode("\n", $plan->benefitsList());
|
||||||
$this->resetValidation();
|
$this->resetValidation();
|
||||||
|
|
||||||
Flux::modal('plan-edit')->show();
|
Flux::modal('plan-edit')->show();
|
||||||
|
|
@ -58,6 +61,7 @@ new #[Layout('components.layouts.app'), Title('Tarife & Pakete')] class extends
|
||||||
'quota' => ['required', 'integer', 'min:0'],
|
'quota' => ['required', 'integer', 'min:0'],
|
||||||
'dailyLimit' => ['nullable', 'integer', 'min:1'],
|
'dailyLimit' => ['nullable', 'integer', 'min:1'],
|
||||||
'sortOrder' => ['required', 'integer', 'min:0'],
|
'sortOrder' => ['required', 'integer', 'min:0'],
|
||||||
|
'benefits' => ['nullable', 'string', 'max:2000'],
|
||||||
],
|
],
|
||||||
attributes: [
|
attributes: [
|
||||||
'name' => __('Name'),
|
'name' => __('Name'),
|
||||||
|
|
@ -66,9 +70,17 @@ new #[Layout('components.layouts.app'), Title('Tarife & Pakete')] class extends
|
||||||
'quota' => __('PM-Kontingent'),
|
'quota' => __('PM-Kontingent'),
|
||||||
'dailyLimit' => __('Tageslimit'),
|
'dailyLimit' => __('Tageslimit'),
|
||||||
'sortOrder' => __('Sortierung'),
|
'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 = Plan::query()->findOrFail($this->editingPlanId);
|
||||||
|
|
||||||
$plan->fill([
|
$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'],
|
'daily_limit' => $validated['dailyLimit'] === null || $validated['dailyLimit'] === '' ? null : (int) $validated['dailyLimit'],
|
||||||
'is_active' => $this->isActive,
|
'is_active' => $this->isActive,
|
||||||
'sort_order' => (int) $validated['sortOrder'],
|
'sort_order' => (int) $validated['sortOrder'],
|
||||||
|
'benefits' => $benefitsList,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$priceChanged = $plan->isDirty(['monthly_price_cents', 'yearly_price_cents']);
|
$priceChanged = $plan->isDirty(['monthly_price_cents', 'yearly_price_cents']);
|
||||||
|
|
@ -272,6 +285,10 @@ new #[Layout('components.layouts.app'), Title('Tarife & Pakete')] class extends
|
||||||
<flux:input wire:model="sortOrder" type="number" min="0" :label="__('Sortierung')" />
|
<flux:input wire:model="sortOrder" type="number" min="0" :label="__('Sortierung')" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<flux:textarea wire:model="benefits" :label="__('Vorteile (eine Zeile = ein Vorteil)')" rows="5"
|
||||||
|
:placeholder="__('Redaktionelle Prüfung inklusive Höheres Monatskontingent Günstigere Extra-Pressemitteilungen')"
|
||||||
|
:description="__('Wird auf der Buchungs-Seite und der öffentlichen Preise-Seite als Vorteilsliste angezeigt.')" />
|
||||||
|
|
||||||
<flux:switch wire:model="isActive" :label="__('Tarif aktiv (buchbar und auf der Tarif-Seite sichtbar)')" />
|
<flux:switch wire:model="isActive" :label="__('Tarif aktiv (buchbar und auf der Tarif-Seite sichtbar)')" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -562,10 +562,17 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte
|
||||||
{{ __('Ohne Tageslimit') }}
|
{{ __('Ohne Tageslimit') }}
|
||||||
@endif
|
@endif
|
||||||
</li>
|
</li>
|
||||||
|
@forelse ($plan->benefitsList() as $benefit)
|
||||||
|
<li class="flex items-start gap-2">
|
||||||
|
<flux:icon.check class="size-4 flex-shrink-0 mt-0.5 text-[color:var(--color-hub)]" />
|
||||||
|
<span>{{ $benefit }}</span>
|
||||||
|
</li>
|
||||||
|
@empty
|
||||||
<li class="flex items-start gap-2">
|
<li class="flex items-start gap-2">
|
||||||
<flux:icon.check class="size-4 flex-shrink-0 mt-0.5 text-[color:var(--color-hub)]" />
|
<flux:icon.check class="size-4 flex-shrink-0 mt-0.5 text-[color:var(--color-hub)]" />
|
||||||
{{ __('Prüfung und Veröffentlichung inklusive') }}
|
{{ __('Prüfung und Veröffentlichung inklusive') }}
|
||||||
</li>
|
</li>
|
||||||
|
@endforelse
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="pt-2">
|
<div class="pt-2">
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,46 @@ test('saving a plan updates the local record and triggers the stripe sync', func
|
||||||
->and($plan->daily_limit)->toBe(3);
|
->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 () {
|
test('saving without a price change shows the plain success message', function () {
|
||||||
/** @var TestCase $this */
|
/** @var TestCase $this */
|
||||||
$plan = Plan::factory()->create([
|
$plan = Plan::factory()->create([
|
||||||
|
|
|
||||||
|
|
@ -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);
|
->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 () {
|
test('the single pm block links to its checkout', function () {
|
||||||
/** @var TestCase $this */
|
/** @var TestCase $this */
|
||||||
config()->set('billing.single_pm_stripe_price_id', 'price_test_single_pm');
|
config()->set('billing.single_pm_stripe_price_id', 'price_test_single_pm');
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue