From 4ef5b9519c2e9c25528fd37597fb34675ef31066 Mon Sep 17 00:00:00 2001 From: nexxo Date: Wed, 29 Jul 2026 14:22:22 +0200 Subject: [PATCH] Let the owner mark one plan as recommended, from the console MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recommended lives on the plan family, not a version: it is a stance about the product line ("which plan we point a visitor towards"), not a capability that changes with a price, and freezing it per version would make the mark silently vanish every time a new version publishes. Setting a plan recommended clears whichever other plan carried the mark, inside one transaction locking the whole table — two operators recommending two different plans at once must not both win. The public price sheet already had the highlighted-card treatment for this (it was driven by a hardcoded "team" constant); it now reads the family instead. --- app/Http/Controllers/LandingController.php | 6 +- app/Livewire/Admin/EditPlanFamily.php | 65 +++++++++++++++++++ app/Livewire/Admin/Plans.php | 1 + app/Models/PlanFamily.php | 1 + app/Services/Billing/PlanCatalogue.php | 23 +++++++ ..._add_recommended_flag_to_plan_families.php | 42 ++++++++++++ lang/de/plans.php | 8 +++ lang/en/plans.php | 8 +++ .../livewire/admin/edit-plan-family.blade.php | 16 +++++ .../views/livewire/admin/plans.blade.php | 22 +++++-- tests/Feature/Admin/PlanAdminTest.php | 42 ++++++++++++ tests/Feature/LandingPriceSheetTest.php | 12 ++++ 12 files changed, 236 insertions(+), 10 deletions(-) create mode 100644 app/Livewire/Admin/EditPlanFamily.php create mode 100644 database/migrations/2026_07_29_210000_add_recommended_flag_to_plan_families.php create mode 100644 resources/views/livewire/admin/edit-plan-family.blade.php diff --git a/app/Http/Controllers/LandingController.php b/app/Http/Controllers/LandingController.php index 296c4d6..c244996 100644 --- a/app/Http/Controllers/LandingController.php +++ b/app/Http/Controllers/LandingController.php @@ -49,9 +49,6 @@ class LandingController extends Controller 'onboarding' => 'Begleitete Einführung', ]; - /** The plan carrying the recommendation mark. */ - private const RECOMMENDED = 'team'; - public function __invoke(): View { $plans = $this->plans(); @@ -59,7 +56,6 @@ class LandingController extends Controller return view('landing', [ 'plans' => $plans, 'featureRows' => $this->featureRows($plans), - 'recommended' => self::RECOMMENDED, ]); } @@ -111,7 +107,7 @@ class LandingController extends Controller 'traffic' => $this->storage((int) $plan['traffic_gb']), 'seats' => (int) $plan['seats'], 'features' => $this->features($plan['features'] ?? []), - 'recommended' => $key === self::RECOMMENDED, + 'recommended' => (bool) ($plan['recommended'] ?? false), ]; } diff --git a/app/Livewire/Admin/EditPlanFamily.php b/app/Livewire/Admin/EditPlanFamily.php new file mode 100644 index 0000000..782e9c4 --- /dev/null +++ b/app/Livewire/Admin/EditPlanFamily.php @@ -0,0 +1,65 @@ +authorize('plans.manage'); // modals bypass the route middleware + + $family = PlanFamily::query()->where('uuid', $uuid)->firstOrFail(); + $this->uuid = $uuid; + $this->recommended = (bool) $family->is_recommended; + } + + public function save() + { + $this->authorize('plans.manage'); + + $data = $this->validate([ + 'recommended' => 'boolean', + ]); + + $family = PlanFamily::query()->where('uuid', $this->uuid)->firstOrFail(); + + // 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. + if ($data['recommended']) { + app(PlanCatalogue::class)->recommend($family); + } elseif ($family->is_recommended) { + PlanFamily::query()->whereKey($family->getKey())->update(['is_recommended' => false]); + } + + $this->dispatch('notify', message: __('plans.marketing_saved')); + + return $this->redirectRoute('admin.plans', navigate: true); + } + + public function render() + { + return view('livewire.admin.edit-plan-family', [ + // Read again rather than from the mounted properties: the header + // shows the plan's name, and that can change under an operator's + // own hands while a second tab has this modal open. + 'family' => PlanFamily::query()->where('uuid', $this->uuid)->firstOrFail(), + ]); + } +} diff --git a/app/Livewire/Admin/Plans.php b/app/Livewire/Admin/Plans.php index 2824985..321fdb2 100644 --- a/app/Livewire/Admin/Plans.php +++ b/app/Livewire/Admin/Plans.php @@ -95,6 +95,7 @@ class Plans extends Component 'name' => $family->name, 'tier' => $family->tier, 'sales_enabled' => $family->sales_enabled, + 'is_recommended' => $family->is_recommended, 'versions_count' => $family->versions_count, 'live' => $family->versions->first(fn ($v) => $v->isAvailableAt($now)), // What is queued up next, so a scheduled launch is visible diff --git a/app/Models/PlanFamily.php b/app/Models/PlanFamily.php index 8866454..2a360de 100644 --- a/app/Models/PlanFamily.php +++ b/app/Models/PlanFamily.php @@ -57,6 +57,7 @@ class PlanFamily extends Model return [ 'tier' => 'integer', 'sales_enabled' => 'boolean', + 'is_recommended' => 'boolean', ]; } diff --git a/app/Services/Billing/PlanCatalogue.php b/app/Services/Billing/PlanCatalogue.php index 847f2d4..5404502 100644 --- a/app/Services/Billing/PlanCatalogue.php +++ b/app/Services/Billing/PlanCatalogue.php @@ -75,6 +75,11 @@ final class PlanCatalogue 'price_cents' => $monthly->amount_cents, 'currency' => $monthly->currency, 'plan_version_id' => $version->id, + // 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. + 'recommended' => (bool) $family->is_recommended, ])]; }) ->all(); @@ -321,6 +326,24 @@ final class PlanCatalogue }); } + /** + * Mark exactly one plan family as recommended, clearing every other one. + * + * Singular by nature: a second recommendation is not two recommendations, + * it is none. Every family row is locked for the length of the + * transaction, so two operators recommending two different plans at the + * same moment cannot both win and leave two marked rows instead of one. + */ + public function recommend(PlanFamily $family): void + { + DB::transaction(function () use ($family) { + PlanFamily::query()->lockForUpdate()->get(); + + PlanFamily::query()->whereKeyNot($family->getKey())->update(['is_recommended' => false]); + PlanFamily::query()->whereKey($family->getKey())->update(['is_recommended' => true]); + }); + } + /** * Publish a draft: lock its capabilities and put it on sale. * diff --git a/database/migrations/2026_07_29_210000_add_recommended_flag_to_plan_families.php b/database/migrations/2026_07_29_210000_add_recommended_flag_to_plan_families.php new file mode 100644 index 0000000..c95415c --- /dev/null +++ b/database/migrations/2026_07_29_210000_add_recommended_flag_to_plan_families.php @@ -0,0 +1,42 @@ +boolean('is_recommended')->default(false)->after('sales_enabled'); + }); + + DB::table('plan_families')->where('key', self::RECOMMENDED)->update(['is_recommended' => true]); + } + + public function down(): void + { + Schema::table('plan_families', function (Blueprint $table) { + $table->dropColumn('is_recommended'); + }); + } +}; diff --git a/lang/de/plans.php b/lang/de/plans.php index 817065b..20bf766 100644 --- a/lang/de/plans.php +++ b/lang/de/plans.php @@ -30,6 +30,14 @@ return [ 'create' => 'Paket anlegen', 'created' => 'Paket angelegt.', + 'edit_marketing' => 'Marketing bearbeiten', + 'marketing_title' => 'Marketing', + 'marketing_saved' => 'Marketing-Angaben gespeichert.', + 'recommended_badge' => 'Empfohlen', + 'recommended_label' => 'Als empfohlen markieren', + 'recommended_hint' => 'Nur ein Paket kann empfohlen sein — die Markierung wandert von einem anderen Paket hierher.', + 'save' => 'Speichern', + 'back' => 'Zurück zu den Paketen', 'versions_subtitle' => 'Eine Version ist ab Veröffentlichung unveränderlich — Kunden sind darauf vertraglich gebunden.', 'family_withdrawn_note' => 'Dieses Paket ist aus dem Verkauf genommen. Solange der Schalter aus ist, kann es niemand kaufen — unabhängig davon, welche Version gerade laufen würde. Bestandskunden behalten ihren Vertrag.', diff --git a/lang/en/plans.php b/lang/en/plans.php index 4bde3f3..1a9c2ac 100644 --- a/lang/en/plans.php +++ b/lang/en/plans.php @@ -30,6 +30,14 @@ return [ 'create' => 'Create plan', 'created' => 'Plan created.', + 'edit_marketing' => 'Edit marketing', + 'marketing_title' => 'Marketing', + 'marketing_saved' => 'Marketing details saved.', + '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.', + 'save' => 'Save', + 'back' => 'Back to plans', 'versions_subtitle' => 'A version is immutable from publication — customers are contracted to it.', 'family_withdrawn_note' => 'This plan is withdrawn from sale. While the switch is off nobody can buy it, whichever version would otherwise be running. Existing customers keep their contract.', diff --git a/resources/views/livewire/admin/edit-plan-family.blade.php b/resources/views/livewire/admin/edit-plan-family.blade.php new file mode 100644 index 0000000..bf44120 --- /dev/null +++ b/resources/views/livewire/admin/edit-plan-family.blade.php @@ -0,0 +1,16 @@ +
+
+

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

+ {{ $family->name }} +
+ +
+ +

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

+
+ +
+ {{ __('plans.cancel') }} + {{ __('plans.save') }} +
+
diff --git a/resources/views/livewire/admin/plans.blade.php b/resources/views/livewire/admin/plans.blade.php index f6bebb4..477f230 100644 --- a/resources/views/livewire/admin/plans.blade.php +++ b/resources/views/livewire/admin/plans.blade.php @@ -27,6 +27,11 @@ {{ $family['name'] }} {{ $family['key'] }} + @if ($family['is_recommended']) + + {{ __('plans.recommended_badge') }} + + @endif @if ($family['drafts'] > 0) {{ trans_choice('plans.drafts', $family['drafts'], ['count' => $family['drafts']]) }} @@ -56,11 +61,18 @@ - - - {{ __('plans.manage_versions', ['count' => $family['versions_count']]) }} - - + @endforeach diff --git a/tests/Feature/Admin/PlanAdminTest.php b/tests/Feature/Admin/PlanAdminTest.php index c3d48ba..9549ab4 100644 --- a/tests/Feature/Admin/PlanAdminTest.php +++ b/tests/Feature/Admin/PlanAdminTest.php @@ -2,6 +2,7 @@ use App\Support\LocalTime; use App\Livewire\Admin\ConfirmDeletePlanDraft; +use App\Livewire\Admin\EditPlanFamily; use App\Livewire\Admin\PlanVersions; use App\Livewire\Admin\Plans; use App\Models\Operator; @@ -248,6 +249,47 @@ it('lists a withdrawn plan as withdrawn, not as missing', function () { ->assertSee(__('plans.withdrawn_badge')); }); +it('recommends a plan, and recommending a second one clears the first', function () { + $team = teamFamily(); + $business = PlanFamily::query()->where('key', 'business')->sole(); + + Livewire::actingAs(owner(), 'operator')->test(EditPlanFamily::class, ['uuid' => $team->uuid]) + ->set('recommended', true) + ->call('save'); + + expect($team->fresh()->is_recommended)->toBeTrue(); + + // "Recommended" is singular by nature: marking a second plan is not two + // recommendations, it is none — so the first must clear the moment the + // second is set. + Livewire::actingAs(owner(), 'operator')->test(EditPlanFamily::class, ['uuid' => $business->uuid]) + ->set('recommended', true) + ->call('save'); + + expect($business->fresh()->is_recommended)->toBeTrue() + ->and($team->fresh()->is_recommended)->toBeFalse(); +}); + +it('opens the modal already showing the current mark, and can clear it entirely', function () { + $team = teamFamily(); + PlanFamily::query()->whereKey($team->id)->update(['is_recommended' => true]); + + Livewire::actingAs(owner(), 'operator')->test(EditPlanFamily::class, ['uuid' => $team->uuid]) + ->assertSet('recommended', true) + ->set('recommended', false) + ->call('save'); + + expect($team->fresh()->is_recommended)->toBeFalse(); +}); + +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. + Livewire::actingAs(Operator::factory()->role('Support')->create(), 'operator') + ->test(EditPlanFamily::class, ['uuid' => teamFamily()->uuid]) + ->assertForbidden(); +}); + it('shows the console entry only to those who may use it', function () { $this->actingAs(owner(), 'operator')->get(route('admin.overview'))->assertSee(__('admin.nav.plans')); diff --git a/tests/Feature/LandingPriceSheetTest.php b/tests/Feature/LandingPriceSheetTest.php index 5da9cb0..38eee14 100644 --- a/tests/Feature/LandingPriceSheetTest.php +++ b/tests/Feature/LandingPriceSheetTest.php @@ -88,6 +88,18 @@ it('names the currency the catalogue is priced in, not the one the page was writ ->assertDontSee("179\u{00A0}€", false); }); +it('marks the plan the console recommends, and only that one', function () { + DB::table('plan_families')->update(['is_recommended' => false]); + DB::table('plan_families')->where('key', 'business')->update(['is_recommended' => true]); + + $content = $this->get('/')->assertOk()->getContent(); + + expect(strpos($content, '>Business<'))->toBeInt() + ->and(strpos($content, 'Empfohlen'))->toBeGreaterThan(strpos($content, '>Business<')) + // Singular: exactly one plan carries the mark on the page. + ->and(substr_count($content, 'Empfohlen'))->toBe(1); +}); + 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'))