Move each plan's marketing copy from the controller into the console
tests / pest (push) Failing after 8m10s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details

Audience line and note were LandingController::COPY, a hardcoded array
keyed on exactly the four plan keys the seed migration created — a plan
made under any other key rendered with no copy at all. They move onto
the family for the same reason the recommendation mark does: they are a
stance about the product line, not a capability that changes with a
price, so they must not be frozen per version.

Backfilled in the migration from the controller's own strings, so the
public page prints exactly what it printed a moment ago. A family with
no copy yet renders without the audience line rather than an empty gap.

Feature labels (managed_updates, daily_backups, ...) stay in the
controller: they name catalogue features, not individual plans, so
there is no per-plan or per-version place to hang an editable
customer-facing label without re-introducing a shared feature table.
That table existed once and was deliberately removed when the plan
catalogue closed its pricing split-brain; bringing it back for wording
alone is a bigger, separate decision.
feat/plan-marketing
nexxo 2026-07-29 14:31:44 +02:00
parent 4ef5b9519c
commit 8c105c17d6
10 changed files with 142 additions and 18 deletions

View File

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

View File

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

View File

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

View File

@ -0,0 +1,53 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* The plan's marketing copy, moved onto the family it describes.
*
* `audience` and `note` were a hardcoded array in LandingController, keyed on
* exactly the four family keys the seed migration created a plan created
* under any other key rendered on the public page with no copy at all. They
* belong on the family for the same reason the recommendation mark does (see
* the previous migration): "who this plan is for" does not change with a
* price, so it must not be frozen per version.
*
* Backfilled from the controller's own strings in the same statement that
* adds the columns, so the public page prints exactly what it printed a
* moment ago, sourced from the console instead of from code.
*/
return new class extends Migration
{
/** The exact copy LandingController::COPY held before this migration. */
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.'],
];
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']);
});
}
};

View File

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

View File

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

View File

@ -471,7 +471,12 @@
<span class="rounded-pill bg-accent-active px-2 py-0.5 font-mono text-[10px] font-medium uppercase tracking-[0.07em] text-on-accent">Empfohlen</span>
@endif
</div>
<p class="mt-1 text-sm text-muted">{{ $plan['audience'] }}</p>
{{-- 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'])
<p class="mt-1 text-sm text-muted">{{ $plan['audience'] }}</p>
@endif
<p class="mt-6 flex items-baseline gap-1.5">
<span class="text-[2.4rem] font-bold leading-none tabular-nums tracking-[-0.04em] text-ink">{{ $plan['price'] }}</span>

View File

@ -4,7 +4,24 @@
<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">
<div class="mt-4 space-y-4">
<div>
<label class="text-sm font-medium text-body" for="pf-audience">{{ __('plans.audience') }}</label>
<input id="pf-audience" type="text" wire:model="audience" placeholder="{{ __('plans.audience_placeholder') }}"
class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-ink" />
<p class="mt-1 text-xs text-muted">{{ __('plans.audience_hint') }}</p>
@error('audience')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
</div>
<div>
<label class="text-sm font-medium text-body" for="pf-note">{{ __('plans.note') }}</label>
<textarea id="pf-note" wire:model="note" rows="3"
class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-ink"></textarea>
<p class="mt-1 text-xs text-muted">{{ __('plans.note_hint') }}</p>
@error('note')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
</div>
</div>
<div class="mt-5 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>

View File

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

View File

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