['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> $plans * @return array */ 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> 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 $keys * @return array */ 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, ))); } }