Split the price sheet into what everyone gets, what differs, and what costs extra

One boolean matrix was carrying three different kinds of statement, and it
made all three unreadable.

Updates, backup and monitoring were a tick in every column — a comparison
that compares nothing — and the same promises were repeated in a prose line
under the table. They are now stated once, above it, together with the
platform address in the shape a customer will actually type
(ihrefirma.<zone>, from the configured zone) rather than as the phrase
"eigene Subdomain", which said nothing and was modelled as though an own
domain replaced it. Provisioning issues both.

Support was two independent booleans on one axis, so enterprise — which
carries the SLA and not the priority flag — rendered "Bevorzugter Support —"
beside team's tick: the top plan looking worse than the middle one. It is
one row of levels now, and a plan with neither shows Standard, because
standard support is a level and not an absence.

A cell had two states, so a dash meant both "you cannot have this" and "you
can have this for nine euros". There are three now, and the middle one
quotes AddonCatalogue — which is also how the modules we have been selling
all along finally appear on the page at all. Nothing is invented: where we
sell no module for a missing feature, the cell stays a dash.

custom_domain joins the module list at the owner's figure so the two plans
without it can be quoted a price instead of told no.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat/plan-reopen
nexxo 2026-07-29 15:25:20 +02:00
parent d4e8822dbb
commit 517606b4db
6 changed files with 671 additions and 52 deletions

View File

@ -2,7 +2,10 @@
namespace App\Http\Controllers;
use App\Models\Subscription;
use App\Services\Billing\AddonCatalogue;
use App\Services\Billing\PlanCatalogue;
use App\Support\ProvisioningSettings;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Log;
use Throwable;
@ -22,15 +25,44 @@ use Throwable;
* page down with it. So the failure is caught HERE and only here: the page
* still renders, the price sheet is replaced by "on request", and nobody is
* shown a number the checkout would not honour.
*
* The sheet is three blocks, not one matrix. A single grid of ticks was being
* asked to carry three different kinds of sentence at once, and it made all
* three unreadable: what every package has (a tick straight down every column,
* which is no information at all), what tells the packages apart, and what
* costs extra. Worse, two of the columns were lying by construction support
* was modelled as two independent booleans, so the top plan rendered a dash
* where the middle one had a tick, and the platform address was modelled as if
* an own domain replaced it, when provisioning issues both. Each block now
* makes one kind of statement: BASELINE what everyone gets, the comparison
* table what differs, ADDONS what is for sale on top.
*/
class LandingController extends Controller
{
/** Catalogue feature keys, in the words a customer uses. */
/**
* Catalogue feature keys, in the words a customer uses.
*
* Deliberately separate from the operator-facing labels in
* lang/*\/billing.php's `feature` array, which name the same keys for the
* console's checkboxes. The two are different registers for different
* readers "Protokollierung der Zugriffe" here versus "Audit-Log" there
* and features are catalogue-wide, not per-plan, so there is nowhere on a
* plan family or version to hang an editable customer-facing label
* without re-introducing a shared feature table. That table
* (`plan_features`) existed once and was deliberately removed in the
* catalogue rebuild that closed the pricing split-brain; bringing it back
* for wording alone is a bigger, separate decision.
*/
private const FEATURES = [
'managed_updates' => 'Updates & Wartung',
'daily_backups' => 'Tägliche Sicherung',
'monitoring' => 'Überwachung rund um die Uhr',
'subdomain' => 'Adresse auf clupilot.cloud',
// Filled in from the configured zone by features(), because a
// hard-coded domain here is the same two-sources-of-truth mistake
// as a hard-coded price — and it shipped naming clupilot.cloud, a
// domain the company does not own, while provisioning was issuing
// addresses under something else entirely.
'subdomain' => 'Adresse auf :zone',
'custom_domain' => 'Eigene Domain',
'office' => 'Office im Browser',
'branding' => 'Ihr Logo & Ihre Farben',
@ -41,38 +73,355 @@ class LandingController extends Controller
'onboarding' => 'Begleitete Einführung',
];
/**
* What every package carries, said once above the table.
*
* Each of these was a row of ticks running straight down all four columns
* a comparison that compares nothing and the same promises were then
* repeated in a prose line under the table. `key` names the catalogue
* feature the claim stands on: it keeps the row out of the comparison
* table, and, unless the claim is `issued`, the whole item is dropped
* should a plan ever stop carrying it. The page must not promise for
* "jedes Paket" what one package does not have.
*
* The last two have no key because the catalogue does not model them. They
* are the promises the prose line under the table made, kept word for word
* rather than reworded on the way in.
*/
private const BASELINE = [
[
'icon' => 'refresh',
'label' => 'Updates & Wartung',
'detail' => 'Betriebssystem und Anwendung, eingespielt außerhalb Ihrer Arbeitszeit.',
'key' => 'managed_updates',
],
[
'icon' => 'database',
'label' => 'Tägliche verschlüsselte Sicherung',
'detail' => 'Mit monatlichem Wiederherstellungstest, protokolliert.',
'key' => 'daily_backups',
],
[
'icon' => 'activity',
'label' => 'Überwachung rund um die Uhr',
'detail' => 'Erreichbarkeit, Speicher und Dienste — Alarm, bevor Sie es merken.',
'key' => 'monitoring',
],
[
'icon' => 'globe',
'label' => 'Ihre Adresse ab der ersten Minute',
// The shape, not the word for it. "Eigene Subdomain" tells a
// customer nothing; the address they will actually type does.
'detail' => 'ihrefirma.:zone',
'mono' => true,
// Issued, not sold: ConfigureDnsAndTls gives every instance its
// platform address whatever `subdomain` says on the plan version
// that was frozen years ago — business and enterprise do not carry
// the flag and get the address anyway. The key is named so the row
// leaves the comparison table; the promise does not hang on it.
'key' => 'subdomain',
'issued' => true,
],
[
'icon' => 'file-text',
'label' => 'AV-Vertrag & TOM-Dokumentation',
'detail' => 'Schriftlich, für Ihre eigene Dokumentation und Prüfung.',
],
[
'icon' => 'life-buoy',
'label' => 'Support auf Deutsch',
'detail' => 'Antwort am selben Werktag, ohne Warteschleife.',
],
];
/**
* The support level a plan carries, strongest first.
*
* One axis, not two. `priority_support` and `premium_sla` are steps on the
* same ladder, and as independent tick boxes they made the top plan look
* worse than the middle one: enterprise carries the SLA and not the
* priority flag, so it rendered "Bevorzugter Support —" while team
* rendered a tick. No plan carrying either is not an absence standard
* support is a real level, and the baseline block above says what it is.
*/
private const SUPPORT = [
'premium_sla' => ['level' => 'Premium', 'note' => 'mit zugesagter Reaktionszeit'],
'priority_support' => ['level' => 'Bevorzugt', 'note' => 'Anfragen werden vorgezogen'],
];
private const SUPPORT_STANDARD = ['level' => 'Standard', 'note' => 'Antwort am selben Werktag'];
/**
* The modules we sell on top of a package, in the words a customer uses.
*
* Same split as FEATURES and for the same reason: lang/*\/billing.php names
* these for the portal's booking cards ("Collabora Online Pro"), which is
* the register of somebody who already bought. A visitor deciding has not
* heard of Collabora. The prices are never written here they come from
* AddonCatalogue, so the sheet and the booking page cannot disagree.
*/
private const ADDONS = [
'extra_backups' => [
'name' => 'Zweiter Sicherungsort',
'body' => 'Eine zusätzliche verschlüsselte Kopie Ihrer Daten, getrennt vom ersten Ort gelagert.',
],
'priority_support' => [
'name' => 'Bevorzugter Support',
'body' => 'Ihre Anfragen werden vorgezogen — Reaktion innerhalb einer Stunde statt am selben Werktag.',
],
'collabora_pro' => [
'name' => 'Office mit vollem Funktionsumfang',
'body' => 'Mehr gleichzeitige Bearbeiter und die erweiterten Funktionen der Office-Integration.',
],
'custom_domain' => [
'name' => 'Eigene Domain',
'body' => 'Ihre Cloud unter Ihrer eigenen Adresse, samt Zertifikat und Einrichtung.',
],
AddonCatalogue::STORAGE => [
'name' => 'Zusatzspeicher',
'body' => 'Ein Paket mit :size — jederzeit ergänzbar, ohne Umzug Ihrer Daten.',
],
];
public function __invoke(): View
{
$plans = $this->plans();
// Read once and handed to both blocks that quote a module price, so the
// "optional" cell in the table and the module list underneath it cannot
// print two different figures for the same thing.
$modules = $this->modulePrices();
return view('landing', [
'plans' => $plans,
'featureRows' => $this->featureRows($plans),
'baseline' => $this->baseline($plans),
'comparison' => $this->comparison($plans, $modules),
'addons' => $this->addons($modules),
]);
}
/**
* Every feature any sellable plan carries, in the catalogue's narrative
* order the row headings of the price sheet.
*
* Built from what is on sale rather than from the full list, so a feature
* no current plan offers does not appear as an empty row.
* The promises that hold for every package on sale.
*
* @param array<int, array<string, mixed>> $plans
* @return array<int, string>
* @return array<int, array<string, mixed>>
*/
private function featureRows(array $plans): array
private function baseline(array $plans): array
{
$offered = array_merge(...array_column($plans, 'features')) ?: [];
return array_values(array_filter(
self::FEATURES,
fn (string $label) => in_array($label, $offered, true),
array_map(fn (array $item) => [...$item, 'detail' => $this->zone($item['detail'])], self::BASELINE),
function (array $item) use ($plans) {
$key = $item['key'] ?? null;
if ($key === null || ($item['issued'] ?? false)) {
return true;
}
// "In jedem Paket" is a claim about all of them, so one plan
// without the feature retires the claim rather than weakening
// it to a footnote nobody reads.
foreach ($plans as $plan) {
if (! in_array($key, $plan['keys'], true)) {
return false;
}
}
return true;
},
));
}
/**
* @return array<int, array<string, mixed>> empty when the catalogue cannot be read
* The rows on which the packages actually differ, each cell in one of three
* states: carried, buyable, or neither.
*
* Two states could not tell "you do not get this" apart from "you can have
* it for nine euros", so the sheet said the first about four things we
* would happily have sold which is how the page came to mention none of
* the modules at all.
*
* Built from what is on sale rather than from the full feature list, so a
* feature no current plan offers does not appear as an empty row.
*
* @param array<int, array<string, mixed>> $plans
* @param array<string, int> $modules
* @return array<int, array{label: string, cells: array<string, array<string, mixed>>}>
*/
private function comparison(array $plans, array $modules): array
{
$baselineKeys = array_column(self::BASELINE, 'key');
$rows = [];
$supportPlaced = false;
foreach (array_keys(self::FEATURES) as $key) {
if (in_array($key, $baselineKeys, true)) {
continue;
}
// The support row takes the place of the first of the two keys it
// replaces, so the sheet keeps the catalogue's narrative order
// rather than growing an appendix at the bottom.
if (isset(self::SUPPORT[$key])) {
$row = $supportPlaced ? null : $this->supportRow($plans, $modules);
$supportPlaced = true;
if ($row !== null) {
$rows[] = $row;
}
continue;
}
$offered = array_filter($plans, fn (array $plan) => in_array($key, $plan['keys'], true));
if ($offered === []) {
continue;
}
$cells = [];
foreach ($plans as $plan) {
$cells[$plan['key']] = match (true) {
in_array($key, $plan['keys'], true) => ['state' => 'included'],
// Not carried, but on sale — the only honest third answer.
// A module we do not sell stays a dash, however easy it
// would be to invent a price for it.
isset($modules[$key]) => ['state' => 'optional', 'price' => $this->modulePrice($modules[$key])],
default => ['state' => 'absent'],
};
}
$rows[] = ['label' => (string) $this->label($key), 'cells' => $cells];
}
return $rows;
}
/**
* Support as one row of levels.
*
* Omitted entirely when no plan carries either key: four columns all
* reading "Standard" is the row of identical ticks this rebuild removed,
* and the baseline block has already said what standard support is.
*
* @param array<int, array<string, mixed>> $plans
* @param array<string, int> $modules
* @return array{label: string, cells: array<string, array<string, mixed>>}|null
*/
private function supportRow(array $plans, array $modules): ?array
{
$cells = [];
$differs = false;
foreach ($plans as $plan) {
$carried = self::SUPPORT_STANDARD;
// Strongest first: a plan carrying both is shown at the level it
// actually answers at, not at whichever key was checked first.
foreach (self::SUPPORT as $key => $level) {
if (in_array($key, $plan['keys'], true)) {
$carried = $level;
$differs = true;
break;
}
}
$cells[$plan['key']] = [
'state' => 'level',
'text' => $carried['level'],
// The upgrade displaces the level's own qualifier: a customer
// reading the standard cell is better served by what the next
// step costs than by a restatement of the line above.
'note' => $carried === self::SUPPORT_STANDARD && isset($modules['priority_support'])
? 'bevorzugt ab '.$this->modulePrice($modules['priority_support'])
: $carried['note'],
];
}
return $differs ? ['label' => 'Support', 'cells' => $cells] : null;
}
/**
* Today's price for every module actually on sale, in cents.
*
* Swallowed like the plan catalogue and for the same reason (see the class
* docblock): an empty or unreadable module list costs the page a block, not
* the page. Every caller then reads a plain array, so a module that is not
* in it is simply not for sale which is exactly what the dash in the
* table means.
*
* @return array<string, int>
*/
private function modulePrices(): array
{
try {
$catalogue = app(AddonCatalogue::class);
$keys = [...array_keys((array) config('provisioning.addons', [])), AddonCatalogue::STORAGE];
$prices = [];
foreach ($keys as $key) {
$cents = $catalogue->priceCents((string) $key);
// Nothing is sold for nothing. An entry without a price reads
// back as 0 cents, and "optional · 0 €" on a public price sheet
// is an offer somebody would hold us to.
if ($cents === null || $cents <= 0) {
continue;
}
// The storage pack is sold by the pack, so a pack of no size is
// not a product either.
if ($key === AddonCatalogue::STORAGE && (int) config('provisioning.storage_addon.gb', 0) <= 0) {
continue;
}
$prices[(string) $key] = $cents;
}
return $prices;
} catch (Throwable $e) {
Log::error('Landing page could not read the add-on catalogue', ['exception' => $e]);
return [];
}
}
/**
* The modules, in the order the catalogue lists them, with today's prices.
*
* @param array<string, int> $modules
* @return array<int, array{name: string, body: string, price: string}>
*/
private function addons(array $modules): array
{
$rows = [];
foreach ($modules as $key => $cents) {
$copy = self::ADDONS[$key] ?? null;
// Same rule as an unknown feature key: a module nobody has written
// up is left out rather than advertised as "collabora_pro".
if ($copy === null) {
continue;
}
$rows[] = [
'name' => $copy['name'],
'body' => str_replace(
':size',
$this->storage((int) config('provisioning.storage_addon.gb', 0)),
$copy['body'],
),
'price' => $this->modulePrice($cents),
];
}
return $rows;
}
/**
* @return array<int, array<string, mixed>> empty when the catalogue cannot be read
*/
private function plans(): array
{
@ -103,6 +452,12 @@ class LandingController extends Controller
'traffic' => $this->storage((int) $plan['traffic_gb']),
'seats' => (int) $plan['seats'],
'features' => $this->features($plan['features'] ?? []),
// The raw keys alongside the wording. Everything that has to
// decide something — which row this plan fills, which support
// level it answers at — asks the key; only the card bullets
// read the words. Deciding on the words matched until the
// first label gained a placeholder, and then quietly stopped.
'keys' => array_values((array) ($plan['features'] ?? [])),
'recommended' => (bool) ($plan['recommended'] ?? false),
];
}
@ -110,6 +465,26 @@ class LandingController extends Controller
return $plans;
}
/**
* One feature, in the words a customer uses.
*
* The address the platform hands out is a setting, not a constant a
* server can be installed against any zone so the sentence naming it is
* assembled rather than written down twice.
*/
private function label(string $key): ?string
{
$label = self::FEATURES[$key] ?? null;
return $label === null ? null : $this->zone($label);
}
/** The configured zone, wherever a sentence names it. */
private function zone(string $text): string
{
return str_replace(':zone', ProvisioningSettings::dnsZone(), $text);
}
/** Currencies we have a symbol for; anything else prints its ISO code. */
private const SYMBOLS = ['EUR' => '€', 'CHF' => 'CHF', 'USD' => '$', 'GBP' => '£'];
@ -135,6 +510,19 @@ class LandingController extends Controller
return $amount."\u{00A0}".(self::SYMBOLS[strtoupper($currency)] ?? strtoupper($currency));
}
/**
* A module price.
*
* A plan price carries its own currency from the catalogue; a module does
* not, and cannot config holds one net amount. The one currency the
* catalogue is priced in is therefore the honest answer, and it is the same
* one the booking page charges in.
*/
private function modulePrice(int $cents): string
{
return $this->money($cents, Subscription::catalogueCurrency());
}
private function storage(int $gb): string
{
return $gb >= 1000 && $gb % 1000 === 0
@ -152,7 +540,7 @@ class LandingController extends Controller
// the catalogue without a translation would otherwise appear on the
// public page as "premium_sla".
return array_values(array_filter(array_map(
fn (string $key) => self::FEATURES[$key] ?? null,
fn (string $key) => $this->label($key),
$keys,
)));
}

View File

@ -129,6 +129,12 @@ return [
'extra_backups' => ['price_cents' => 500],
'priority_support' => ['price_cents' => 2900],
'collabora_pro' => ['price_cents' => 1900],
// The owner's commercial figure, to be adjusted once the first ones are
// sold. Listed here because the public sheet has to answer what a plan
// without an own domain costs to give one — an unpriced feature can only
// be shown as absent, and it was being shown that way to the two plans
// we would most like to sell it to.
'custom_domain' => ['price_cents' => 900],
],
// Feature bullets moved onto plan_versions.features, so that what a version

View File

@ -105,5 +105,6 @@ return [
'extra_backups' => ['name' => 'Tägliche Off-Site-Backups', 'desc' => 'Zusätzliche verschlüsselte Backups an einem zweiten Standort.'],
'priority_support' => ['name' => 'Priority-Support', 'desc' => 'Bevorzugte Bearbeitung, Reaktionszeit unter 1 Stunde.'],
'collabora_pro' => ['name' => 'Collabora Online Pro', 'desc' => 'Erweiterte Office-Funktionen und mehr gleichzeitige Bearbeiter.'],
'custom_domain' => ['name' => 'Eigene Domain', 'desc' => 'Ihre Cloud unter Ihrer eigenen Adresse, samt Zertifikat und Einrichtung.'],
],
];

View File

@ -105,5 +105,6 @@ return [
'extra_backups' => ['name' => 'Daily off-site backups', 'desc' => 'Additional encrypted backups at a second location.'],
'priority_support' => ['name' => 'Priority support', 'desc' => 'Priority handling, response time under 1 hour.'],
'collabora_pro' => ['name' => 'Collabora Online Pro', 'desc' => 'Advanced office features and more concurrent editors.'],
'custom_domain' => ['name' => 'Own domain', 'desc' => 'Your cloud under your own address, certificate and setup included.'],
],
];

View File

@ -517,46 +517,124 @@
@endforeach
</div>
<details class="faq rv group mt-6 overflow-hidden rounded-xl border border-line bg-surface shadow-xs">
<summary class="flex min-h-14 items-center gap-4 px-6 py-4 text-sm font-medium text-ink transition-colors hover:bg-surface-hover">
Alle Merkmale im direkten Vergleich
<x-ui.icon name="chevron-down" class="ml-auto size-4 shrink-0 text-muted transition-transform duration-200 group-open:rotate-180" />
</summary>
<div class="overflow-x-auto border-t border-line">
<table class="w-full min-w-[680px] border-collapse text-left text-sm">
<caption class="sr-only">Pakete und Merkmale im Vergleich</caption>
<thead>
<tr class="border-b border-line">
<th scope="col" class="px-6 py-4"><span class="sr-only">Merkmal</span></th>
@foreach ($plans as $plan)
<th scope="col" class="px-6 py-4 font-semibold text-ink">{{ $plan['name'] }}</th>
@endforeach
</tr>
</thead>
<tbody>
@foreach ($featureRows as $feature)
<tr class="border-b border-line last:border-0">
<th scope="row" class="px-6 py-3 font-medium text-muted">{{ $feature }}</th>
{{-- The baseline, said once and said first. Four of these were rows of
ticks running straight down every column of the table below, and
the same promises were repeated in a prose line under it a
visitor read the comparison twice to learn the two places where it
compared nothing. The address is shown as the address, not as the
phrase "eigene Subdomain": what is worth knowing about it is its
shape. --}}
<div class="rv mt-12 overflow-hidden rounded-xl border border-line bg-surface shadow-xs">
<div class="flex flex-wrap items-baseline gap-x-3 gap-y-1 border-b border-line px-7 py-5">
<h3 class="text-lg font-bold tracking-[-0.02em] text-ink">In jedem Paket enthalten</h3>
<span class="text-sm text-muted">ohne Aufpreis, ab dem ersten Tag</span>
</div>
<div class="grid sm:grid-cols-2">
@foreach ($baseline as $item)
<div class="flex gap-3.5 border-b border-line px-7 py-5 sm:odd:border-r">
<x-ui.icon name="{{ $item['icon'] }}" class="mt-0.5 size-4 text-accent-text" />
<div class="min-w-0">
<b class="block text-sm font-semibold text-ink">{{ $item['label'] }}</b>
<span @class([
'mt-1 block text-xs leading-relaxed text-muted',
'font-mono' => $item['mono'] ?? false,
])>{{ $item['detail'] }}</span>
</div>
</div>
@endforeach
</div>
</div>
{{-- Only what differs, and three answers instead of two. A dash used to
mean both "you cannot have this" and "you can have this for nine
euros", which is how a page that sells five modules came to mention
none of them. --}}
@if (! empty($comparison))
<details class="faq rv group mt-4 overflow-hidden rounded-xl border border-line bg-surface shadow-xs">
<summary class="flex min-h-14 items-center gap-4 px-6 py-4 text-sm font-medium text-ink transition-colors hover:bg-surface-hover">
Was die Pakete unterscheidet
<x-ui.icon name="chevron-down" class="ml-auto size-4 shrink-0 text-muted transition-transform duration-200 group-open:rotate-180" />
</summary>
{{-- The table keeps its own scroll. Four columns of prose cells
do not fit a phone, and a page that scrolls sideways as a
whole loses its header and its margins with it. --}}
<div class="overflow-x-auto border-t border-line">
<table class="w-full min-w-[720px] border-collapse text-left text-sm">
<caption class="sr-only">Was die Pakete unterscheidet</caption>
<thead>
<tr class="border-b border-line">
<th scope="col" class="px-6 py-4"><span class="sr-only">Merkmal</span></th>
@foreach ($plans as $plan)
<td class="px-6 py-3">
@if (in_array($feature, $plan['features'], true))
<x-ui.icon name="check" class="size-4 text-success" aria-label="enthalten" />
@else
<span class="text-faint" aria-label="nicht enthalten"></span>
@endif
</td>
<th scope="col" class="px-6 py-4 font-semibold text-ink">{{ $plan['name'] }}</th>
@endforeach
</tr>
@endforeach
</tbody>
</table>
</div>
</details>
</thead>
<tbody>
@foreach ($comparison as $row)
<tr class="border-b border-line last:border-0">
<th scope="row" class="px-6 py-3.5 font-medium text-muted">{{ $row['label'] }}</th>
@foreach ($plans as $plan)
@php($cell = $row['cells'][$plan['key']] ?? ['state' => 'absent'])
<td class="px-6 py-3.5 align-top">
@switch($cell['state'])
@case('included')
<span class="inline-flex items-center gap-2 text-body">
<x-ui.icon name="check" class="size-4 text-success" />inklusive
</span>
@break
<p class="rv mt-8 text-center text-sm leading-relaxed text-muted">
<b class="font-semibold text-ink">In jedem Paket:</b> tägliche verschlüsselte Sicherung mit monatlichem Wiederherstellungstest ·
Updates, Wartung und Überwachung · AV-Vertrag und TOM-Dokumentation · Support auf Deutsch am selben Werktag.
</p>
@case('optional')
<span class="inline-flex items-center gap-1.5 rounded-pill border border-accent-border bg-accent-subtle px-2.5 py-1 text-xs font-medium text-accent-text">
<x-ui.icon name="plus" class="size-3.5" />optional · {{ $cell['price'] }}
</span>
@break
@case('level')
<span class="block font-medium text-ink">{{ $cell['text'] }}</span>
@if ($cell['note'])
<span class="mt-0.5 block text-xs text-muted">{{ $cell['note'] }}</span>
@endif
@break
@default
<span class="text-faint" aria-label="nicht enthalten"></span>
@endswitch
</td>
@endforeach
</tr>
@endforeach
</tbody>
</table>
</div>
<p class="border-t border-line px-6 py-4 text-xs leading-relaxed text-muted">
„optional“ heißt: in diesem Paket nicht enthalten, aber jederzeit zubuchbar netto pro Monat, monatlich kündbar.
„—“ heißt: in diesem Paket nicht verfügbar.
</p>
</details>
@endif
{{-- What costs extra, with the price it costs. The modules were on sale
the whole time and the page named none of them; the owner's
complaint was simply that nothing on it said so. --}}
@if (! empty($addons))
<div class="rv d1 mt-4 overflow-hidden rounded-xl border border-line bg-surface shadow-xs">
<div class="flex flex-wrap items-baseline gap-x-3 gap-y-1 border-b border-line px-7 py-5">
<h3 class="text-lg font-bold tracking-[-0.02em] text-ink">Optional dazubuchbar</h3>
<span class="text-sm text-muted">zu jedem Paket, monatlich kündbar</span>
</div>
<div class="grid gap-3 p-6 sm:grid-cols-2 lg:grid-cols-3">
@foreach ($addons as $addon)
<div class="flex flex-col rounded-lg border border-line bg-surface-2 px-4 py-3.5">
<b class="text-sm font-semibold text-ink">{{ $addon['name'] }}</b>
<span class="mt-1.5 flex-1 text-xs leading-relaxed text-muted">{{ $addon['body'] }}</span>
<span class="mt-3.5 flex items-baseline gap-1.5 font-mono text-sm tabular-nums text-ink">
+{{ $addon['price'] }}<span class="text-xs text-muted">/Monat</span>
</span>
</div>
@endforeach
</div>
</div>
@endif
@endif
</section>

View File

@ -13,8 +13,48 @@ use Illuminate\Support\Str;
* the site advertised 249 for a plan the checkout charged 399 for. These
* tests exist so that cannot come back and so a catalogue that is broken
* takes the price sheet down, never the whole front page.
*
* The sheet is three blocks what every package has, what tells them apart,
* what costs extra and the tests below are mostly about the seams between
* them: the same words mean different things in each block, so a promise made
* once above the table must not reappear as a row inside it, and a cell that
* says nothing must be distinguishable from one that says "nine euros".
*/
/**
* The comparison table's own markup.
*
* A search across the whole page cannot tell the blocks apart "Überwachung
* rund um die Uhr" is a promise above the table and a card bullet beside it,
* and the point of the rebuild is that it is no longer a row inside it.
*/
function priceSheetTable(string $html): string
{
$section = substr($html, (int) strpos($html, 'id="preise"'));
$start = strpos($section, '<table');
if ($start === false) {
return '';
}
return substr($section, $start, (int) strpos($section, '</table>') - $start);
}
/** One row of that table, by its heading — the cells of a single feature. */
function priceSheetRow(string $html, string $label): string
{
$table = priceSheetTable($html);
$at = strpos($table, '>'.$label.'</th>');
if ($at === false) {
return '';
}
$start = (int) strrpos(substr($table, 0, $at), '<tr');
return substr($table, $start, (int) strpos($table, '</tr>', $at) - $start);
}
it('quotes the price the catalogue would actually charge', function () {
$team = PlanFamily::query()->where('key', 'team')->firstOrFail();
$version = PlanVersion::query()->where('plan_family_id', $team->id)->firstOrFail();
@ -113,6 +153,111 @@ it('shows the audience line the console saved, not a string compiled into the co
->assertDontSee('Für wachsende Teams');
});
it('states what every package has once, above the table instead of as a row in it', function () {
// Four rows of ticks running straight down every column, and then the same
// promises again in a line under the table. Neither told a visitor anything
// about the choice they were there to make.
$content = $this->get('/')->assertOk()->getContent();
expect($content)->toContain('In jedem Paket enthalten')
->and($content)->toContain('Überwachung rund um die Uhr')
// That the table was found at all: every "is not in the table"
// assertion in this file passes for free against an empty string.
->and(priceSheetTable($content))->toContain('Eigene Domain')
->and(priceSheetTable($content))->not->toContain('Überwachung rund um die Uhr')
// The prose line that used to repeat it under the table is gone with it.
->and($content)->not->toContain('In jedem Paket:');
});
it('offers what a plan lacks at the price the module catalogue would charge', function () {
// The third cell state, and the reason it exists: start and team do not
// carry an own domain, but we sell one — a dash there was the page telling
// a visitor no on a sale we would happily make.
$this->get('/')->assertOk()->assertSee("optional · 9\u{00A0}€", false);
// And the figure follows the catalogue. Written into the template it would
// be right until the first time somebody changed the price and read the
// page as confirmation.
config()->set('provisioning.addons.custom_domain.price_cents', 1900);
$this->get('/')
->assertOk()
->assertSee("optional · 19\u{00A0}€", false)
->assertDontSee("optional · 9\u{00A0}€", false);
});
it('leaves a feature we do not sell as a dash, without inventing a price for it', function () {
// Longer retention is not on the module list, so start does not get an
// offer for it. Being able to say "no" is what makes the "optional" cells
// above worth anything.
$row = priceSheetRow($this->get('/')->assertOk()->getContent(), 'Verlängerte Aufbewahrung');
expect($row)->toContain('—')
->and($row)->not->toContain('optional')
->and($row)->not->toContain('€');
});
it('shows one support level per plan, and never a dash for the best of them', function () {
// priority_support and premium_sla are steps on one ladder, and as two
// independent rows they made the top plan look worse than the middle one:
// enterprise carries the SLA and not the priority flag, so it rendered
// "Bevorzugter Support —" beside team's tick.
$content = $this->get('/')->assertOk()->getContent();
$row = priceSheetRow($content, 'Support');
expect($row)->toContain('Premium')
->and($row)->toContain('Standard')
->and($row)->not->toContain('—')
// The two booleans are gone from the table as separate rows.
->and(priceSheetTable($content))->not->toContain('Vereinbarte Reaktionszeiten');
});
it('shows the platform address in the shape a customer will type it, under the configured zone', function () {
// "Eigene CluPilot-Subdomain" as a table row was meaningless twice over:
// every instance gets one, and the phrase does not say what it looks like.
config()->set('provisioning.dns.zone', 'wolke.test');
$this->get('/')
->assertOk()
->assertSee('ihrefirma.wolke.test')
->assertDontSee('ihrefirma.clupilot.com');
});
it('names the modules it sells, with the prices the catalogue charges for them', function () {
// The complaint that started this: the shop sold five modules and the
// public page mentioned none of them.
$storage = (int) config('provisioning.storage_addon.gb');
$money = fn (int $cents) => number_format($cents / 100, 0, ',', '.')."\u{00A0}€";
$page = $this->get('/')->assertOk();
$page->assertSee('Optional dazubuchbar');
foreach (['extra_backups', 'priority_support', 'collabora_pro', 'custom_domain'] as $key) {
$page->assertSee($money((int) config("provisioning.addons.{$key}.price_cents")), false);
}
// The storage pack is sold by the pack, so the sheet says how big one is.
$page->assertSee($storage.' GB')
->assertSee($money((int) config('provisioning.storage_addon.price_cents')), false);
});
it('keeps the sheet up when nothing is on sale on top of a package', function () {
// An installation that sells no modules is a configuration, not a failure.
// The block disappears; the cells that could have been an offer go back to
// being honest dashes.
config()->set('provisioning.addons', []);
config()->set('provisioning.storage_addon', []);
$this->get('/')
->assertOk()
->assertDontSee('Optional dazubuchbar')
->assertDontSee('optional · ', false)
// The comparison itself is untouched: an own domain still tells the
// packages apart, it just cannot be bought into the smaller ones.
->assertSee('Eigene Domain');
});
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'))