Let the owner mark one plan as recommended, from the console

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.
feat/plan-marketing
nexxo 2026-07-29 14:22:22 +02:00
parent 012d0f5092
commit 4ef5b9519c
12 changed files with 236 additions and 10 deletions

View File

@ -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),
];
}

View File

@ -0,0 +1,65 @@
<?php
namespace App\Livewire\Admin;
use App\Models\PlanFamily;
use App\Services\Billing\PlanCatalogue;
use LivewireUI\Modal\ModalComponent;
/**
* The marketing presentation of one plan line starting with 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.
*/
class EditPlanFamily extends ModalComponent
{
public string $uuid = '';
public bool $recommended = false;
public function mount(string $uuid): void
{
$this->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(),
]);
}
}

View File

@ -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

View File

@ -57,6 +57,7 @@ class PlanFamily extends Model
return [
'tier' => 'integer',
'sales_enabled' => 'boolean',
'is_recommended' => 'boolean',
];
}

View File

@ -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.
*

View File

@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* The recommendation mark, moved onto the plan family it describes.
*
* `is_recommended` was `LandingController::RECOMMENDED`, a single hardcoded
* key that always pointed at "team". It belongs on the FAMILY, not the
* 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 freezing it per version would mean
* the recommendation silently vanishing every time a new version is
* published.
*
* Backfilled to "team" in the same statement that adds the column, so the
* public page marks exactly the plan it already marked a moment ago, sourced
* from the console instead of from code.
*/
return new class extends Migration
{
private const RECOMMENDED = 'team';
public function up(): void
{
Schema::table('plan_families', function (Blueprint $table) {
$table->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');
});
}
};

View File

@ -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.',

View File

@ -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.',

View File

@ -0,0 +1,16 @@
<div class="rounded-lg bg-surface p-6">
<div class="flex items-center justify-between">
<h3 class="text-base font-semibold text-ink">{{ __('plans.marketing_title') }}</h3>
<span class="rounded bg-surface-2 px-2 py-0.5 font-mono text-xs text-muted">{{ $family->name }}</span>
</div>
<div class="mt-4 rounded-lg border border-line bg-surface-2 p-4">
<x-ui.checkbox name="recommended" wire:model="recommended" :label="__('plans.recommended_label')" />
<p class="mt-1.5 pl-7 text-xs text-muted">{{ __('plans.recommended_hint') }}</p>
</div>
<div class="mt-6 flex justify-end gap-3">
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('plans.cancel') }}</x-ui.button>
<x-ui.button variant="primary" wire:click="save" wire:loading.attr="disabled">{{ __('plans.save') }}</x-ui.button>
</div>
</div>

View File

@ -27,6 +27,11 @@
<td class="px-4 py-3">
<span class="font-semibold text-ink">{{ $family['name'] }}</span>
<span class="ml-1.5 font-mono text-xs text-muted">{{ $family['key'] }}</span>
@if ($family['is_recommended'])
<span class="ml-2 rounded-pill bg-accent-active px-2 py-0.5 font-mono text-[10px] font-medium uppercase tracking-[0.07em] text-on-accent">
{{ __('plans.recommended_badge') }}
</span>
@endif
@if ($family['drafts'] > 0)
<span class="ml-2 rounded-pill border border-line-strong bg-surface-2 px-2 py-0.5 text-xs text-muted">
{{ trans_choice('plans.drafts', $family['drafts'], ['count' => $family['drafts']]) }}
@ -56,11 +61,18 @@
</button>
</td>
<td class="px-4 py-3 text-right">
<a href="{{ route('admin.plans.versions', $family['uuid']) }}" wire:navigate>
<x-ui.button variant="secondary" size="sm">
{{ __('plans.manage_versions', ['count' => $family['versions_count']]) }}
</x-ui.button>
</a>
<div class="inline-flex items-center gap-2">
<button type="button" aria-label="{{ __('plans.edit_marketing') }}"
x-on:click="$dispatch('openModal', { component: 'admin.edit-plan-family', arguments: { uuid: '{{ $family['uuid'] }}' } })"
class="grid size-8 place-items-center rounded-md border border-line text-muted hover:border-accent-border hover:text-accent-text">
<x-ui.icon name="pen" class="size-4" />
</button>
<a href="{{ route('admin.plans.versions', $family['uuid']) }}" wire:navigate>
<x-ui.button variant="secondary" size="sm">
{{ __('plans.manage_versions', ['count' => $family['versions_count']]) }}
</x-ui.button>
</a>
</div>
</td>
</tr>
@endforeach

View File

@ -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'));

View File

@ -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'))