diff --git a/app/Http/Controllers/LandingController.php b/app/Http/Controllers/LandingController.php new file mode 100644 index 0000000..d9469da --- /dev/null +++ b/app/Http/Controllers/LandingController.php @@ -0,0 +1,166 @@ + ['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', + ]; + + /** The plan carrying the recommendation mark. */ + private const RECOMMENDED = 'team'; + + public function __invoke(): View + { + $plans = $this->plans(); + + return view('landing', [ + 'plans' => $plans, + 'featureRows' => $this->featureRows($plans), + 'recommended' => self::RECOMMENDED, + ]); + } + + /** + * 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']), + 'seats' => (int) $plan['seats'], + 'features' => $this->features($plan['features'] ?? []), + 'recommended' => $key === self::RECOMMENDED, + ]; + } + + 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, + ))); + } +} diff --git a/lang/de/auth.php b/lang/de/auth.php index d514448..3e0e853 100644 --- a/lang/de/auth.php +++ b/lang/de/auth.php @@ -24,15 +24,25 @@ return [ 'create_account' => 'Konto erstellen', 'have_account' => 'Bereits registriert?', - // Brand panel - 'brand_headline' => 'Ihre Cloud. Vollständig verwaltet.', - 'brand_sub' => 'Nextcloud für Ihr Unternehmen — sicher betrieben, automatisch aktualisiert, gesichert.', - 'brand_point_1' => 'Automatische Backups & Updates', - 'brand_point_2' => 'Eigene Domain & Branding', - 'brand_point_3' => 'Gehostet in der EU, DSGVO-konform', - 'brand_footer' => '© CluPilot — Managed Nextcloud.', + // Brand panel. The facts are a specification plate, not a feature list — + // the same voice the public site speaks in. + 'brand_eyebrow' => 'Betriebene Private Cloud · Österreich', + 'brand_headline' => 'Ihre Cloud. Betrieben, gesichert, belegbar.', + 'brand_sub' => 'Eine eigene, isolierte Instanz für Ihr Unternehmen — laufend gewartet und überwacht.', + 'brand_footer' => '© CluPilot — betriebene Private Cloud.', 'register_brand_headline' => 'In Minuten startklar.', - 'register_brand_sub' => 'Konto erstellen, Paket wählen — den Rest übernehmen wir vollautomatisch.', + 'register_brand_sub' => 'Konto erstellen, Paket wählen — die Einrichtung läuft danach automatisch an.', + + 'fact_location_term' => 'Serverstandort', + 'fact_location' => 'EU — Österreich / Deutschland', + 'fact_backup_term' => 'Sicherung', + 'fact_backup' => 'Täglich, verschlüsselt', + 'fact_restore_term' => 'Wiederherstellung', + 'fact_restore' => 'Monatlich getestet', + 'fact_support_term' => 'Betreuung', + 'fact_support' => 'Persönlich, auf Deutsch', + 'fact_setup_term' => 'Bereitstellung', + 'fact_setup' => 'Unter 20 Minuten', // Two-factor 'twofa_title' => 'Zwei-Faktor-Bestätigung', diff --git a/lang/en/auth.php b/lang/en/auth.php index 9f985d7..44a6839 100644 --- a/lang/en/auth.php +++ b/lang/en/auth.php @@ -24,15 +24,25 @@ return [ 'create_account' => 'Create account', 'have_account' => 'Already registered?', - // Brand panel - 'brand_headline' => 'Your cloud. Fully managed.', - 'brand_sub' => 'Nextcloud for your business — securely operated, auto-updated, backed up.', - 'brand_point_1' => 'Automatic backups & updates', - 'brand_point_2' => 'Custom domain & branding', - 'brand_point_3' => 'Hosted in the EU, GDPR-compliant', - 'brand_footer' => '© CluPilot — Managed Nextcloud.', + // Brand panel. The facts are a specification plate, not a feature list — + // the same voice the public site speaks in. + 'brand_eyebrow' => 'Managed private cloud · Austria', + 'brand_headline' => 'Your cloud. Operated, secured, evidenced.', + 'brand_sub' => 'A separate, isolated instance for your business — maintained and monitored continuously.', + 'brand_footer' => '© CluPilot — managed private cloud.', 'register_brand_headline' => 'Ready in minutes.', - 'register_brand_sub' => 'Create an account, pick a plan — we handle the rest, fully automated.', + 'register_brand_sub' => 'Create an account, pick a plan — provisioning starts automatically.', + + 'fact_location_term' => 'Server location', + 'fact_location' => 'EU — Austria / Germany', + 'fact_backup_term' => 'Backup', + 'fact_backup' => 'Daily, encrypted', + 'fact_restore_term' => 'Restore', + 'fact_restore' => 'Tested monthly', + 'fact_support_term' => 'Support', + 'fact_support' => 'Personal, in German', + 'fact_setup_term' => 'Provisioning', + 'fact_setup' => 'Under 20 minutes', // Two-factor 'twofa_title' => 'Two-factor confirmation', diff --git a/package-lock.json b/package-lock.json index 19aede1..6d0ebd0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "html", + "name": "clupilot", "lockfileVersion": 3, "requires": true, "packages": { @@ -7,6 +7,7 @@ "dependencies": { "@fontsource/ibm-plex-mono": "^5.3.0", "@fontsource/ibm-plex-sans": "^5.3.0", + "@fontsource/ibm-plex-serif": "^5.3.0", "chart.js": "^4.5.1", "laravel-echo": "^2.4.0", "pusher-js": "^8.6.0" @@ -85,6 +86,15 @@ "url": "https://github.com/sponsors/ayuhito" } }, + "node_modules/@fontsource/ibm-plex-serif": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-serif/-/ibm-plex-serif-5.3.0.tgz", + "integrity": "sha512-zwPfHB7EJbS5B8qnitPigJYzNdnt6PUeaoOA1gAAt//1pjpTVZN5sOU14NIAZ+C1xypL6GWsUG2VMYJW9bhJqQ==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", diff --git a/package.json b/package.json index 5804ed6..376be9e 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "dependencies": { "@fontsource/ibm-plex-mono": "^5.3.0", "@fontsource/ibm-plex-sans": "^5.3.0", + "@fontsource/ibm-plex-serif": "^5.3.0", "chart.js": "^4.5.1", "laravel-echo": "^2.4.0", "pusher-js": "^8.6.0" diff --git a/public/fonts/ibm-plex-mono-latin-400-normal.woff2 b/public/fonts/ibm-plex-mono-latin-400-normal.woff2 new file mode 100644 index 0000000..0804aaf Binary files /dev/null and b/public/fonts/ibm-plex-mono-latin-400-normal.woff2 differ diff --git a/public/fonts/ibm-plex-mono-latin-500-normal.woff2 b/public/fonts/ibm-plex-mono-latin-500-normal.woff2 new file mode 100644 index 0000000..090f82f Binary files /dev/null and b/public/fonts/ibm-plex-mono-latin-500-normal.woff2 differ diff --git a/public/fonts/ibm-plex-sans-latin-400-normal.woff2 b/public/fonts/ibm-plex-sans-latin-400-normal.woff2 new file mode 100644 index 0000000..f0ee65d Binary files /dev/null and b/public/fonts/ibm-plex-sans-latin-400-normal.woff2 differ diff --git a/public/fonts/ibm-plex-sans-latin-500-normal.woff2 b/public/fonts/ibm-plex-sans-latin-500-normal.woff2 new file mode 100644 index 0000000..6d5527e Binary files /dev/null and b/public/fonts/ibm-plex-sans-latin-500-normal.woff2 differ diff --git a/public/fonts/ibm-plex-sans-latin-600-normal.woff2 b/public/fonts/ibm-plex-sans-latin-600-normal.woff2 new file mode 100644 index 0000000..08c0d5a Binary files /dev/null and b/public/fonts/ibm-plex-sans-latin-600-normal.woff2 differ diff --git a/public/fonts/ibm-plex-serif-latin-400-normal.woff2 b/public/fonts/ibm-plex-serif-latin-400-normal.woff2 new file mode 100644 index 0000000..dfe0ebe Binary files /dev/null and b/public/fonts/ibm-plex-serif-latin-400-normal.woff2 differ diff --git a/public/fonts/ibm-plex-serif-latin-600-normal.woff2 b/public/fonts/ibm-plex-serif-latin-600-normal.woff2 new file mode 100644 index 0000000..c39282b Binary files /dev/null and b/public/fonts/ibm-plex-serif-latin-600-normal.woff2 differ diff --git a/resources/css/app.css b/resources/css/app.css index 21e39b8..88c0fdd 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -7,6 +7,9 @@ @import '@fontsource/ibm-plex-mono/400.css'; @import '@fontsource/ibm-plex-mono/500.css'; @import '@fontsource/ibm-plex-mono/600.css'; +/* Serif is the display voice — headlines only, matching the marketing site. */ +@import '@fontsource/ibm-plex-serif/400.css'; +@import '@fontsource/ibm-plex-serif/600.css'; /* Design tokens, then Tailwind v3 layers. */ @import './portal-tokens.css'; diff --git a/resources/css/portal-tokens.css b/resources/css/portal-tokens.css index b1b9267..690f294 100644 --- a/resources/css/portal-tokens.css +++ b/resources/css/portal-tokens.css @@ -31,6 +31,15 @@ for AA — #f97316 on white is only ~2.9:1, so text uses --accent-active. */ --accent-text: #c2560a; + /* The ink plate: the dark surface used where the brand speaks rather than + the product — the sign-in panel, the closing block of the public site. + Warm rather than blue-black, so it reads as ink, not as chrome. */ + --plate: #17140f; + --plate-2: #211d16; + --plate-rule: #39332a; + --plate-text: #ded7c9; + --plate-muted: #93897a; + /* Status semantics — only in badges/alerts, never a second decorative tone */ --success: #067a48; --success-bg: #ecfdf3; --success-border: #abefc6; --success-bright: #30b15c; /* dots/rings/charts only (large marks, not small text) */ @@ -41,6 +50,9 @@ /* Typography */ --font-sans: "IBM Plex Sans", ui-sans-serif, system-ui, sans-serif; --font-mono: "IBM Plex Mono", ui-monospace, monospace; + /* Display voice for headlines, shared with the public site so the two do + not look like two different companies. Never used for body copy. */ + --font-serif: "IBM Plex Serif", Georgia, "Times New Roman", serif; --text-xs: 12px; --text-sm: 13px; --text-base: 14px; --text-md: 15px; --text-lg: 18px; --text-xl: 22px; --text-2xl: 28px; --text-3xl: 34px; diff --git a/resources/views/components/auth/brand.blade.php b/resources/views/components/auth/brand.blade.php new file mode 100644 index 0000000..f48dda9 --- /dev/null +++ b/resources/views/components/auth/brand.blade.php @@ -0,0 +1,50 @@ +@props([ + 'headline' => '', + 'sub' => '', + /** @var array shown as a specification plate, not as a feature list */ + 'facts' => [], +]) + +{{-- + The sign-in brand panel, shared by every guest page so they cannot drift + apart. An ink plate rather than a colour gradient: the public site presents + the product as a controlled document, and this is the same voice. + + Hidden below `lg` — on a phone the form is the page, and a decorative half + would only push it under the fold. +--}} + diff --git a/resources/views/landing.blade.php b/resources/views/landing.blade.php index 9dd229b..f0b0492 100644 --- a/resources/views/landing.blade.php +++ b/resources/views/landing.blade.php @@ -3,596 +3,916 @@ -CluPilot Cloud — Ihre firmeneigene Cloud +CluPilot Cloud — Ihre firmeneigene Cloud, betrieben und belegbar + + +{{-- Self-hosted (R14). Served from our own origin as static files, so the + public page renders correctly even when the Vite build is unavailable. --}} + + + @verbatim @endverbatim -{{-- Without JS the scroll-reveal elements never get revealed — show them. --}} - +{{-- Without JS nothing would ever be revealed — show it all. --}} + - + - - -
-
-
- - EU-Serverstandort · AV-Vertrag inklusive · Persönlich betreut aus Österreich -
-

- Ihre - firmeneigene - Cloud.
- Ohne eigene IT. -

-

Sichere Dateiablage und Zusammenarbeit für Ihr Unternehmen — fertig eingerichtet, täglich gesichert, persönlich betreut.

- -
Scrollen
-