diff --git a/app/Http/Controllers/LandingController.php b/app/Http/Controllers/LandingController.php index c244996..8299bca 100644 --- a/app/Http/Controllers/LandingController.php +++ b/app/Http/Controllers/LandingController.php @@ -25,14 +25,6 @@ use Throwable; */ class LandingController extends Controller { - /** Presentation that belongs to the marketing page, not to the catalogue. */ - private const COPY = [ - 'start' => ['audience' => 'Für kleine Teams', 'note' => 'Einstieg für Büros, die heute noch Dateien per Mail schicken.'], - 'team' => ['audience' => 'Für wachsende Teams', 'note' => 'Der Regelfall — Einschulung des gesamten Teams inklusive.'], - 'business' => ['audience' => 'Für dokumentenlastige Betriebe', 'note' => 'Mehr Speicher, längere Aufbewahrung, bevorzugte Reaktionszeit.'], - 'enterprise' => ['audience' => 'Individuell für Ihr Unternehmen', 'note' => 'Umfang, Einrichtung und Reaktionszeiten nach Vereinbarung.'], - ]; - /** Catalogue feature keys, in the words a customer uses. */ private const FEATURES = [ 'managed_updates' => 'Updates & Wartung', @@ -100,8 +92,12 @@ class LandingController extends Controller $plans[] = [ 'key' => $key, 'name' => $plan['name'], - 'audience' => self::COPY[$key]['audience'] ?? '', - 'note' => self::COPY[$key]['note'] ?? '', + // Console-edited, from the family the plan belongs to — a + // family that has none yet (created but not written up) gets + // an empty string rather than a missing array key, and the + // template renders that sensibly instead of an empty gap. + 'audience' => (string) ($plan['audience'] ?? ''), + 'note' => (string) ($plan['note'] ?? ''), 'price' => $this->money((int) $plan['price_cents'], (string) $plan['currency']), 'storage' => $this->storage((int) $plan['quota_gb']), 'traffic' => $this->storage((int) $plan['traffic_gb']), diff --git a/app/Livewire/Admin/EditPlanFamily.php b/app/Livewire/Admin/EditPlanFamily.php index 782e9c4..5f9c2df 100644 --- a/app/Livewire/Admin/EditPlanFamily.php +++ b/app/Livewire/Admin/EditPlanFamily.php @@ -7,17 +7,25 @@ use App\Services\Billing\PlanCatalogue; use LivewireUI\Modal\ModalComponent; /** - * The marketing presentation of one plan line — starting with whether it - * carries the "recommended" mark. + * The marketing presentation of one plan line: who it is for, the sentence + * under its name on the price sheet, and whether it carries the + * "recommended" mark. * - * Lives on the family, not a version: a version is what gets published and - * superseded, but "which one plan we point a visitor towards" is a stance - * about the product line, not a capability that changes with a price. + * These were a hardcoded array in LandingController, keyed on exactly four + * plan keys — a plan created under any other key rendered on the public page + * with no copy at all. They live on the family, not a version: a version is + * what gets published and superseded, but "who this is for" and "which one + * plan we point a visitor towards" are a stance about the product line, not a + * capability that changes with a price. */ class EditPlanFamily extends ModalComponent { public string $uuid = ''; + public string $audience = ''; + + public string $note = ''; + public bool $recommended = false; public function mount(string $uuid): void @@ -26,6 +34,8 @@ class EditPlanFamily extends ModalComponent $family = PlanFamily::query()->where('uuid', $uuid)->firstOrFail(); $this->uuid = $uuid; + $this->audience = (string) $family->audience; + $this->note = (string) $family->note; $this->recommended = (bool) $family->is_recommended; } @@ -34,11 +44,18 @@ class EditPlanFamily extends ModalComponent $this->authorize('plans.manage'); $data = $this->validate([ + 'audience' => 'nullable|string|max:255', + 'note' => 'nullable|string|max:500', 'recommended' => 'boolean', ]); $family = PlanFamily::query()->where('uuid', $this->uuid)->firstOrFail(); + PlanFamily::query()->whereKey($family->getKey())->update([ + 'audience' => $data['audience'] !== '' ? $data['audience'] : null, + 'note' => $data['note'] !== '' ? $data['note'] : null, + ]); + // The singular invariant — only one family recommended at a time — // lives in the catalogue service, not here: two operators recommending // two different plans at the same moment must not both win. diff --git a/app/Services/Billing/PlanCatalogue.php b/app/Services/Billing/PlanCatalogue.php index 5404502..2ed730c 100644 --- a/app/Services/Billing/PlanCatalogue.php +++ b/app/Services/Billing/PlanCatalogue.php @@ -78,7 +78,9 @@ final class PlanCatalogue // Marketing presentation, not a capability: it lives on the // family (see the migration), and every reader of the shop // gets it from the same place rather than each keeping its - // own copy of which plan to point a visitor towards. + // own copy of who a plan is for. + 'audience' => $family->audience, + 'note' => $family->note, 'recommended' => (bool) $family->is_recommended, ])]; }) diff --git a/database/migrations/2026_07_29_210001_add_marketing_copy_to_plan_families.php b/database/migrations/2026_07_29_210001_add_marketing_copy_to_plan_families.php new file mode 100644 index 0000000..77055bd --- /dev/null +++ b/database/migrations/2026_07_29_210001_add_marketing_copy_to_plan_families.php @@ -0,0 +1,53 @@ + ['audience' => 'Für kleine Teams', 'note' => 'Einstieg für Büros, die heute noch Dateien per Mail schicken.'], + 'team' => ['audience' => 'Für wachsende Teams', 'note' => 'Der Regelfall — Einschulung des gesamten Teams inklusive.'], + 'business' => ['audience' => 'Für dokumentenlastige Betriebe', 'note' => 'Mehr Speicher, längere Aufbewahrung, bevorzugte Reaktionszeit.'], + 'enterprise' => ['audience' => 'Individuell für Ihr Unternehmen', 'note' => 'Umfang, Einrichtung und Reaktionszeiten nach Vereinbarung.'], + ]; + + public function up(): void + { + Schema::table('plan_families', function (Blueprint $table) { + $table->string('audience', 255)->nullable()->after('is_recommended'); + $table->string('note', 500)->nullable()->after('audience'); + }); + + foreach (self::COPY as $key => $copy) { + DB::table('plan_families')->where('key', $key)->update([ + 'audience' => $copy['audience'], + 'note' => $copy['note'], + ]); + } + } + + public function down(): void + { + Schema::table('plan_families', function (Blueprint $table) { + $table->dropColumn(['audience', 'note']); + }); + } +}; diff --git a/lang/de/plans.php b/lang/de/plans.php index 20bf766..2b62743 100644 --- a/lang/de/plans.php +++ b/lang/de/plans.php @@ -36,6 +36,11 @@ return [ 'recommended_badge' => 'Empfohlen', 'recommended_label' => 'Als empfohlen markieren', 'recommended_hint' => 'Nur ein Paket kann empfohlen sein — die Markierung wandert von einem anderen Paket hierher.', + 'audience' => 'Zielgruppe', + 'audience_placeholder' => 'z. B. Für wachsende Teams', + 'audience_hint' => 'Kurzer Satz unter dem Paketnamen auf der Website.', + 'note' => 'Anmerkung', + 'note_hint' => 'Freitext zu diesem Paket — wird derzeit nicht auf der Website angezeigt.', 'save' => 'Speichern', 'back' => 'Zurück zu den Paketen', diff --git a/lang/en/plans.php b/lang/en/plans.php index 1a9c2ac..15b6840 100644 --- a/lang/en/plans.php +++ b/lang/en/plans.php @@ -36,6 +36,11 @@ return [ 'recommended_badge' => 'Recommended', 'recommended_label' => 'Mark as recommended', 'recommended_hint' => 'Only one plan can be recommended — the mark moves here from whichever plan carried it before.', + 'audience' => 'Audience', + 'audience_placeholder' => 'e.g. For growing teams', + 'audience_hint' => 'Short line under the plan name on the website.', + 'note' => 'Note', + 'note_hint' => 'Free text about this plan — not currently shown on the website.', 'save' => 'Save', 'back' => 'Back to plans', diff --git a/resources/views/landing.blade.php b/resources/views/landing.blade.php index ee151d5..f01755b 100644 --- a/resources/views/landing.blade.php +++ b/resources/views/landing.blade.php @@ -471,7 +471,12 @@ Empfohlen @endif -

{{ $plan['audience'] }}

+ {{-- A plan can exist before its copy is written — the empty + string renders nothing rather than an empty paragraph + with margin above it and nothing in it. --}} + @if ($plan['audience']) +

{{ $plan['audience'] }}

+ @endif

{{ $plan['price'] }} diff --git a/resources/views/livewire/admin/edit-plan-family.blade.php b/resources/views/livewire/admin/edit-plan-family.blade.php index bf44120..5aacb83 100644 --- a/resources/views/livewire/admin/edit-plan-family.blade.php +++ b/resources/views/livewire/admin/edit-plan-family.blade.php @@ -4,7 +4,24 @@ {{ $family->name }} -

+
+
+ + +

{{ __('plans.audience_hint') }}

+ @error('audience')

{{ $message }}

@enderror +
+
+ + +

{{ __('plans.note_hint') }}

+ @error('note')

{{ $message }}

@enderror +
+
+ +

{{ __('plans.recommended_hint') }}

diff --git a/tests/Feature/Admin/PlanAdminTest.php b/tests/Feature/Admin/PlanAdminTest.php index 9549ab4..0c84e6f 100644 --- a/tests/Feature/Admin/PlanAdminTest.php +++ b/tests/Feature/Admin/PlanAdminTest.php @@ -282,6 +282,17 @@ it('opens the modal already showing the current mark, and can clear it entirely' expect($team->fresh()->is_recommended)->toBeFalse(); }); +it('saves the audience line and note beside the plan', function () { + Livewire::actingAs(owner(), 'operator')->test(EditPlanFamily::class, ['uuid' => teamFamily()->uuid]) + ->set('audience', 'Für Redaktionen') + ->set('note', 'Testnotiz') + ->call('save'); + + expect(teamFamily()) + ->audience->toBe('Für Redaktionen') + ->note->toBe('Testnotiz'); +}); + it('closes the marketing modal to operators without the capability', function () { // Modals are reachable without the page's guards — same reason // ConfirmDeletePlanDraft checks this on itself. diff --git a/tests/Feature/LandingPriceSheetTest.php b/tests/Feature/LandingPriceSheetTest.php index 38eee14..43af041 100644 --- a/tests/Feature/LandingPriceSheetTest.php +++ b/tests/Feature/LandingPriceSheetTest.php @@ -100,6 +100,19 @@ it('marks the plan the console recommends, and only that one', function () { ->and(substr_count($content, 'Empfohlen'))->toBe(1); }); +it('shows the audience line the console saved, not a string compiled into the controller', function () { + // The point of this migration: the console is the one place this + // sentence is written, same as the price. + DB::table('plan_families') + ->where('key', 'team') + ->update(['audience' => 'Für Redaktionen und Kanzleien']); + + $this->get('/') + ->assertOk() + ->assertSee('Für Redaktionen und Kanzleien') + ->assertDontSee('Für wachsende Teams'); +}); + it('drops a catalogue feature it has no wording for, rather than printing its key', function () { $version = PlanVersion::query() ->where('plan_family_id', PlanFamily::query()->where('key', 'team')->value('id'))