Enterprise wird angefragt, nicht ausgepreist

Ab 500 GB ist es eine eigene Maschine — im Shop faende placeableIn() auf 388 GB
vergebbarem Platz keinen Host, und der Kunde erfuehre das nach der Zahlung.
Der Anfrage-Block erscheint als vierter Bestandteil neben der
Vergleichstabelle, sobald PlanCatalogue::sellable() Enterprise nicht mehr
liefert; die Familie selbst bleibt bestehen, nur sales_enabled entscheidet
(gesetzt wird das erst in Task 9).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat/neue-pakete
nexxo 2026-08-01 15:48:41 +02:00
parent e643769be2
commit 1692fd3977
5 changed files with 110 additions and 0 deletions

View File

@ -41,6 +41,11 @@ use Throwable;
* 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.
*
* A fourth block, ENTERPRISE, sits beside the comparison table rather than
* inside it: once a family drops out of PlanCatalogue::sellable() it has no
* price left to compare, and a dash would say "not included" about something
* that is not for sale at any figure. See enterprise() for when it shows.
*/
class LandingController extends Controller
{
@ -223,6 +228,7 @@ class LandingController extends Controller
'baseline' => $this->baseline($plans),
'comparison' => $this->comparison($plans, $modules),
'addons' => $this->addons($modules),
'enterprise' => $this->enterprise($plans),
]);
}
@ -469,6 +475,48 @@ class LandingController extends Controller
return $rows;
}
/**
* The request-a-quote block that replaces Enterprise's column once the
* family is no longer a priced catalogue entry.
*
* Null while Enterprise is still in `$plans` today's state, and the one
* every install starts in so the sheet renders exactly as before this
* method existed. Read off `$plans` rather than queried again from
* PlanFamily: `$plans` already IS PlanCatalogue::sellable()'s answer for
* this request, and a second "is Enterprise on sale" question here could
* drift from it the moment either changed the same split-brain this
* class exists to avoid.
*
* The copy is fixed rather than pulled from the catalogue on purpose:
* there is no price to quote. From 500 GB a customer gets a dedicated
* machine, and the shop would misprice it anyway placeableIn() finds no
* host on the 388 GB a shared machine actually has to give away, so a
* checkout would take the payment and only then discover it cannot
* deliver.
*
* @param array<int, array<string, mixed>> $plans
* @return array{title: string, body: string, cta: string, href: string}|null
*/
private function enterprise(array $plans): ?array
{
if (in_array('enterprise', array_column($plans, 'key'), true)) {
return null;
}
return [
'title' => __('landing.enterprise_title'),
'body' => __('landing.enterprise_body'),
'cta' => __('landing.enterprise_cta'),
// No `contact` route exists (checked against routes/web.php), and
// adding a page or a route for one button would be new surface
// for a task that only has to react to a switch. mailto: is
// already how this exact page offers "talk to us" everywhere else
// on it — the FAQ aside and the closing section both point at the
// same address.
'href' => 'mailto:office@clupilot.com',
];
}
/**
* @return array<int, array<string, mixed>> empty when the catalogue cannot be read
*/

10
lang/de/landing.php Normal file
View File

@ -0,0 +1,10 @@
<?php
return [
// 175 GB ist die Kontingentgrenze des größten noch verkauften Pakets
// (Business). Darüber ist Enterprise keine Katalogzeile mehr, sondern
// eine eigene Maschine — siehe LandingController::enterprise().
'enterprise_title' => 'Mehr als 175 GB? Dann eine eigene Maschine.',
'enterprise_body' => 'Ab 500 GB richten wir einen Server ein, auf dem nur Ihre Cloud läuft — Größe, Leistung und Sicherung nach Ihrem Bedarf. Wir rechnen Ihnen das durch.',
'enterprise_cta' => 'Angebot anfragen',
];

10
lang/en/landing.php Normal file
View File

@ -0,0 +1,10 @@
<?php
return [
// 175 GB is the quota ceiling of the largest package still on sale
// (Business). Above that, Enterprise is no longer a catalogue row but a
// dedicated machine — see LandingController::enterprise().
'enterprise_title' => 'More than 175 GB? Then a dedicated machine.',
'enterprise_body' => 'From 500 GB we set up a server that runs only your cloud — sized, powered and backed up to your needs. We work out the numbers with you.',
'enterprise_cta' => 'Request a quote',
];

View File

@ -761,6 +761,24 @@
</details>
@endif
{{-- Enterprise's replacement for a column, once it has none: this
renders exactly when LandingController::enterprise() found the
family missing from $plans, i.e. no longer sellable. Its own
block rather than a row in the table above it, because it has no
price left to sit beside a dash there would claim "not
included", when the truth is "not for sale at any figure". --}}
@if ($enterprise)
<div class="rv mt-4 overflow-hidden rounded-xl border border-line bg-surface p-7 shadow-xs sm:flex sm:items-center sm:justify-between sm:gap-6">
<div>
<h3 class="text-lg font-bold tracking-[-0.02em] text-ink">{{ $enterprise['title'] }}</h3>
<p class="mt-2 max-w-[52ch] text-sm leading-relaxed text-muted">{{ $enterprise['body'] }}</p>
</div>
<x-ui.button href="{{ $enterprise['href'] }}" variant="ink" size="lg" class="mt-5 w-full shrink-0 sm:mt-0 sm:w-auto">
{{ $enterprise['cta'] }}
</x-ui.button>
</div>
@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. --}}

View File

@ -312,3 +312,27 @@ it('drops a catalogue feature it has no wording for, rather than printing its ke
->assertOk()
->assertDontSee('quantum_entanglement');
});
it('leaves Enterprise in the comparison table while it is still on sale', function () {
// Today's state (sales_enabled defaults to true): nothing about this task
// changes the page until the owner actually flips the switch.
$content = $this->get('/')->assertOk()->getContent();
expect(priceSheetTable($content))->toContain('>Enterprise<')
->and($content)->not->toContain(__('landing.enterprise_title'));
});
it('shows Enterprise as a request for a quote once it is taken off sale', function () {
// The state Task 9's cutover migration will eventually set. Toggled by
// hand here on purpose — setting sales_enabled is that task's job, only
// reacting to it is this one's.
App\Models\PlanFamily::query()->where('key', 'enterprise')->update(['sales_enabled' => false]);
$content = $this->get('/')->assertOk()->getContent();
expect($content)->toContain(__('landing.enterprise_title'))
->and($content)->toContain(__('landing.enterprise_cta'))
// No price is left to compare it against once it drops out of
// PlanCatalogue::sellable() — not a dash in a row, no column at all.
->and(priceSheetTable($content))->not->toContain('>Enterprise<');
});