164 lines
6.3 KiB
PHP
164 lines
6.3 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers;
|
|
|
|
use App\Services\Billing\PlanCatalogue;
|
|
use Illuminate\Contracts\View\View;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Throwable;
|
|
|
|
/**
|
|
* The public price sheet.
|
|
*
|
|
* Prices, storage and seat counts are read from the catalogue rather than
|
|
* written into the page. They were hard-coded here once, and the page and the
|
|
* catalogue had already drifted apart on three of four plans — a visitor was
|
|
* quoted 249 € for a plan that charged 399 € at checkout. The marketing site is
|
|
* a reader of the catalogue like every other caller.
|
|
*
|
|
* The catalogue fails loudly by design: an empty or overlapping catalogue is an
|
|
* outage for commerce, not something to paper over. But a public website is not
|
|
* commerce — a mistyped availability window must not take the company's front
|
|
* 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.
|
|
*/
|
|
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',
|
|
'daily_backups' => 'Tägliche Sicherung',
|
|
'monitoring' => 'Überwachung rund um die Uhr',
|
|
'subdomain' => 'Adresse auf clupilot.cloud',
|
|
'custom_domain' => 'Eigene Domain',
|
|
'office' => 'Office im Browser',
|
|
'branding' => 'Ihr Logo & Ihre Farben',
|
|
'priority_support' => 'Bevorzugter Support',
|
|
'premium_sla' => 'Vereinbarte Reaktionszeiten',
|
|
'extended_retention' => 'Verlängerte Aufbewahrung',
|
|
'audit_log' => 'Protokollierung der Zugriffe',
|
|
'onboarding' => 'Begleitete Einführung',
|
|
];
|
|
|
|
public function __invoke(): View
|
|
{
|
|
$plans = $this->plans();
|
|
|
|
return view('landing', [
|
|
'plans' => $plans,
|
|
'featureRows' => $this->featureRows($plans),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* @param array<int, array<string, mixed>> $plans
|
|
* @return array<int, string>
|
|
*/
|
|
private function featureRows(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),
|
|
));
|
|
}
|
|
|
|
/**
|
|
* @return array<int, array<string, mixed>> empty when the catalogue cannot be read
|
|
*/
|
|
private function plans(): array
|
|
{
|
|
try {
|
|
$sellable = app(PlanCatalogue::class)->sellable();
|
|
} catch (Throwable $e) {
|
|
// Deliberately swallowed: see the class docblock. Logged at error
|
|
// level because a shop that cannot list its plans is not selling.
|
|
Log::error('Landing page could not read the plan catalogue', ['exception' => $e]);
|
|
|
|
return [];
|
|
}
|
|
|
|
$plans = [];
|
|
|
|
foreach ($sellable as $key => $plan) {
|
|
$plans[] = [
|
|
'key' => $key,
|
|
'name' => $plan['name'],
|
|
'audience' => self::COPY[$key]['audience'] ?? '',
|
|
'note' => self::COPY[$key]['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']),
|
|
'seats' => (int) $plan['seats'],
|
|
'features' => $this->features($plan['features'] ?? []),
|
|
'recommended' => (bool) ($plan['recommended'] ?? false),
|
|
];
|
|
}
|
|
|
|
return $plans;
|
|
}
|
|
|
|
/** Currencies we have a symbol for; anything else prints its ISO code. */
|
|
private const SYMBOLS = ['EUR' => '€', 'CHF' => 'CHF', 'USD' => '$', 'GBP' => '£'];
|
|
|
|
/**
|
|
* A price as the sheet prints it, currency included.
|
|
*
|
|
* The symbol comes from the catalogue rather than from the template: the
|
|
* currency is configurable (CLUPILOT_CURRENCY), and a page that says "€"
|
|
* while the checkout charges francs is the same two-sources-of-truth
|
|
* mistake as a hard-coded amount, only harder to notice.
|
|
*
|
|
* Whole units when the amount is whole — a price sheet reads "179", not
|
|
* "179,00".
|
|
*/
|
|
private function money(int $cents, string $currency): string
|
|
{
|
|
$amount = $cents % 100 === 0
|
|
? number_format($cents / 100, 0, ',', '.')
|
|
: number_format($cents / 100, 2, ',', '.');
|
|
|
|
// Non-breaking: in a narrow table column "49 €" otherwise wraps, and the
|
|
// currency ends up on a line of its own under the number.
|
|
return $amount."\u{00A0}".(self::SYMBOLS[strtoupper($currency)] ?? strtoupper($currency));
|
|
}
|
|
|
|
private function storage(int $gb): string
|
|
{
|
|
return $gb >= 1000 && $gb % 1000 === 0
|
|
? ($gb / 1000).' TB'
|
|
: $gb.' GB';
|
|
}
|
|
|
|
/**
|
|
* @param array<int, string> $keys
|
|
* @return array<int, string>
|
|
*/
|
|
private function features(array $keys): array
|
|
{
|
|
// Unknown keys are dropped rather than printed raw: a feature added to
|
|
// 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,
|
|
$keys,
|
|
)));
|
|
}
|
|
}
|