Move each plan's marketing copy from the controller into the console
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
parent
4ef5b9519c
commit
8c105c17d6
|
|
@ -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']),
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
])];
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
{{-- 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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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'))
|
||||
|
|
|
|||
Loading…
Reference in New Issue