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
parent
012d0f5092
commit
4ef5b9519c
|
|
@ -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),
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ class PlanFamily extends Model
|
|||
return [
|
||||
'tier' => 'integer',
|
||||
'sales_enabled' => 'boolean',
|
||||
'is_recommended' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
||||
|
|
|
|||
|
|
@ -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'))
|
||||
|
|
|
|||
Loading…
Reference in New Issue