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.recommended_hint') }}
+