Rebuild the public site as a document, and read prices from the catalogue
The marketing page was a generic centred hero in the system font stack: it neither used the design system the console is built on nor looked like the product it sells. Rebuilt around what CluPilot actually promises — security you can produce evidence for — so the page reads as a controlled technical document: a specification plate instead of a hero image, numbered sections, an audit register with rhythm and proof per measure, and a price sheet rather than floating cards. IBM Plex Serif joins Sans and Mono as the display voice, self hosted as static files so the public page renders even without the Vite build. The prices were written into the page by hand, and the page and the catalogue had already drifted apart on three of four plans: the site advertised 249 € for a plan the checkout charges 399 € for. The sheet now reads the catalogue, like every other caller — including the currency, which is configurable and was also hard-coded here. The catalogue fails loudly on purpose; a public website must not. A broken or overlapping catalogue is caught in the controller alone: the page still renders, the sheet is replaced by "on request", and nobody is quoted a number the checkout would not honour. The sign-in and registration panels shared a copy-pasted orange gradient. They now share one component and the same ink plate as the site, so the two halves of the product no longer look like two companies. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>feat/portal-design tested-20260727-0208-b844ff3
parent
0066b1c6a0
commit
b844ff377d
|
|
@ -0,0 +1,166 @@
|
|||
<?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',
|
||||
];
|
||||
|
||||
/** 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<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']),
|
||||
'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<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,
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,50 @@
|
|||
@props([
|
||||
'headline' => '',
|
||||
'sub' => '',
|
||||
/** @var array<int, string> 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.
|
||||
--}}
|
||||
<aside class="relative hidden w-[44%] shrink-0 overflow-hidden bg-plate lg:flex lg:flex-col lg:justify-between lg:p-12 xl:p-14">
|
||||
{{-- Survey grid, faded out towards the bottom. --}}
|
||||
<div aria-hidden="true" class="pointer-events-none absolute inset-0 opacity-60"
|
||||
style="background-image:linear-gradient(var(--plate-rule) 1px,transparent 1px),linear-gradient(90deg,var(--plate-rule) 1px,transparent 1px);
|
||||
background-size:76px 76px;
|
||||
mask-image:radial-gradient(ellipse 78% 62% at 30% 8%,#000 0%,transparent 72%);
|
||||
-webkit-mask-image:radial-gradient(ellipse 78% 62% at 30% 8%,#000 0%,transparent 72%);"></div>
|
||||
|
||||
<div class="relative flex items-center gap-2.5">
|
||||
<x-ui.logo class="size-8" />
|
||||
<span class="font-serif text-xl font-semibold tracking-tight text-white">CluPilot</span>
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<p class="mb-6 flex items-center gap-3 font-mono text-[11px] uppercase tracking-[0.16em] text-plate-muted">
|
||||
<span class="h-px w-6 bg-accent"></span>{{ __('auth.brand_eyebrow') }}
|
||||
</p>
|
||||
|
||||
<h2 class="max-w-md font-serif text-4xl font-semibold leading-[1.08] tracking-tight text-white xl:text-[2.9rem]">{{ $headline }}</h2>
|
||||
<p class="mt-5 max-w-sm leading-relaxed text-plate-text">{{ $sub }}</p>
|
||||
|
||||
@if ($facts !== [])
|
||||
<dl class="mt-10 max-w-sm border-t border-plate-rule">
|
||||
@foreach ($facts as $term => $value)
|
||||
<div class="flex items-baseline justify-between gap-6 border-b border-plate-rule py-3">
|
||||
<dt class="text-sm text-plate-muted">{{ $term }}</dt>
|
||||
<dd class="text-right text-sm font-medium text-white">{{ $value }}</dd>
|
||||
</div>
|
||||
@endforeach
|
||||
</dl>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<p class="relative font-mono text-[11px] uppercase tracking-[0.14em] text-plate-muted">{{ __('auth.brand_footer') }}</p>
|
||||
</aside>
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,47 +1,30 @@
|
|||
<div class="flex min-h-screen bg-bg">
|
||||
{{-- Brand panel (enterprise, hidden on small screens) --}}
|
||||
<aside class="relative hidden w-[44%] overflow-hidden bg-gradient-to-br from-accent via-accent-active to-accent-press lg:flex lg:flex-col lg:justify-between lg:p-12 xl:p-16">
|
||||
<div aria-hidden="true" class="pointer-events-none absolute inset-0 opacity-[0.15]"
|
||||
style="background-image: radial-gradient(circle at 1px 1px, #fff 1px, transparent 0); background-size: 28px 28px;"></div>
|
||||
<div aria-hidden="true" class="pointer-events-none absolute -right-24 -top-24 size-96 rounded-full bg-white/10 blur-3xl"></div>
|
||||
|
||||
<div class="relative flex items-center gap-2.5">
|
||||
<x-ui.logo class="size-9" />
|
||||
<span class="font-mono text-xl font-semibold tracking-tight text-white">CluPilot</span>
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<h2 class="max-w-md text-4xl font-semibold leading-tight tracking-tight text-white xl:text-5xl">{{ __('auth.brand_headline') }}</h2>
|
||||
<p class="mt-4 max-w-sm text-lg text-white/80">{{ __('auth.brand_sub') }}</p>
|
||||
<ul class="mt-10 space-y-3.5">
|
||||
@foreach (['brand_point_1', 'brand_point_2', 'brand_point_3'] as $point)
|
||||
<li class="flex items-center gap-3 text-white/90">
|
||||
<span class="grid size-6 shrink-0 place-items-center rounded-pill bg-white/15"><x-ui.icon name="check" class="size-3.5 text-white" /></span>
|
||||
<span>{{ __('auth.'.$point) }}</span>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p class="relative text-sm text-white/60">{{ __('auth.brand_footer') }}</p>
|
||||
</aside>
|
||||
<x-auth.brand
|
||||
:headline="__('auth.brand_headline')"
|
||||
:sub="__('auth.brand_sub')"
|
||||
:facts="[
|
||||
__('auth.fact_location_term') => __('auth.fact_location'),
|
||||
__('auth.fact_backup_term') => __('auth.fact_backup'),
|
||||
__('auth.fact_restore_term') => __('auth.fact_restore'),
|
||||
__('auth.fact_support_term') => __('auth.fact_support'),
|
||||
]" />
|
||||
|
||||
{{-- Form panel --}}
|
||||
<main class="flex flex-1 items-center justify-center px-6 py-12">
|
||||
<div class="w-full max-w-sm animate-rise">
|
||||
<div class="mb-8 flex items-center gap-2.5 lg:hidden">
|
||||
<div class="mb-9 flex items-center gap-2.5 lg:hidden">
|
||||
<x-ui.logo class="size-8" />
|
||||
<span class="font-mono text-lg font-semibold tracking-tight text-ink">CluPilot</span>
|
||||
<span class="font-serif text-lg font-semibold tracking-tight text-ink">CluPilot</span>
|
||||
</div>
|
||||
|
||||
<h1 class="text-2xl font-semibold tracking-tight text-ink">{{ __('auth.login_title') }}</h1>
|
||||
<p class="mt-1.5 text-sm text-muted">{{ __('auth.login_subtitle') }}</p>
|
||||
<h1 class="font-serif text-[1.75rem] font-semibold leading-tight tracking-tight text-ink">{{ __('auth.login_title') }}</h1>
|
||||
<p class="mt-2 text-sm text-muted">{{ __('auth.login_subtitle') }}</p>
|
||||
|
||||
@if (session('status'))
|
||||
<x-ui.alert variant="info" class="mt-5">{{ session('status') }}</x-ui.alert>
|
||||
@endif
|
||||
|
||||
<form method="POST" action="{{ route('login.store') }}" class="mt-7 space-y-5">
|
||||
<form method="POST" action="{{ route('login.store') }}" class="mt-8 space-y-5">
|
||||
@csrf
|
||||
<x-ui.input name="email" type="email" :label="__('auth.email')" autocomplete="email" autofocus required value="{{ old('email') }}" />
|
||||
<x-ui.input name="password" type="password" :label="__('auth.password_label')" autocomplete="current-password" required />
|
||||
|
|
@ -49,9 +32,9 @@
|
|||
<x-ui.button type="submit" variant="primary" size="md" class="w-full">{{ __('auth.sign_in') }}</x-ui.button>
|
||||
</form>
|
||||
|
||||
<p class="mt-8 text-center text-sm text-muted">
|
||||
<p class="mt-9 border-t border-line pt-6 text-center text-sm text-muted">
|
||||
{{ __('auth.no_account') }}
|
||||
<a href="{{ route('register') }}" wire:navigate class="font-semibold text-accent-text hover:underline">{{ __('auth.sign_up') }}</a>
|
||||
<a href="{{ route('register') }}" wire:navigate class="font-medium text-accent-text hover:underline">{{ __('auth.sign_up') }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -1,41 +1,24 @@
|
|||
<div class="flex min-h-screen bg-bg">
|
||||
{{-- Brand panel (enterprise, hidden on small screens) --}}
|
||||
<aside class="relative hidden w-[44%] overflow-hidden bg-gradient-to-br from-accent via-accent-active to-accent-press lg:flex lg:flex-col lg:justify-between lg:p-12 xl:p-16">
|
||||
<div aria-hidden="true" class="pointer-events-none absolute inset-0 opacity-[0.15]"
|
||||
style="background-image: radial-gradient(circle at 1px 1px, #fff 1px, transparent 0); background-size: 28px 28px;"></div>
|
||||
<div aria-hidden="true" class="pointer-events-none absolute -left-24 -bottom-24 size-96 rounded-full bg-white/10 blur-3xl"></div>
|
||||
|
||||
<div class="relative flex items-center gap-2.5">
|
||||
<x-ui.logo class="size-9" />
|
||||
<span class="font-mono text-xl font-semibold tracking-tight text-white">CluPilot</span>
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<h2 class="max-w-md text-4xl font-semibold leading-tight tracking-tight text-white xl:text-5xl">{{ __('auth.register_brand_headline') }}</h2>
|
||||
<p class="mt-4 max-w-sm text-lg text-white/80">{{ __('auth.register_brand_sub') }}</p>
|
||||
<ul class="mt-10 space-y-3.5">
|
||||
@foreach (['brand_point_1', 'brand_point_2', 'brand_point_3'] as $point)
|
||||
<li class="flex items-center gap-3 text-white/90">
|
||||
<span class="grid size-6 shrink-0 place-items-center rounded-pill bg-white/15"><x-ui.icon name="check" class="size-3.5 text-white" /></span>
|
||||
<span>{{ __('auth.'.$point) }}</span>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p class="relative text-sm text-white/60">{{ __('auth.brand_footer') }}</p>
|
||||
</aside>
|
||||
<x-auth.brand
|
||||
:headline="__('auth.register_brand_headline')"
|
||||
:sub="__('auth.register_brand_sub')"
|
||||
:facts="[
|
||||
__('auth.fact_setup_term') => __('auth.fact_setup'),
|
||||
__('auth.fact_location_term') => __('auth.fact_location'),
|
||||
__('auth.fact_backup_term') => __('auth.fact_backup'),
|
||||
__('auth.fact_support_term') => __('auth.fact_support'),
|
||||
]" />
|
||||
|
||||
{{-- Form panel --}}
|
||||
<main class="flex flex-1 items-center justify-center px-6 py-12">
|
||||
<div class="w-full max-w-sm animate-rise">
|
||||
<div class="mb-8 flex items-center gap-2.5 lg:hidden">
|
||||
<div class="mb-9 flex items-center gap-2.5 lg:hidden">
|
||||
<x-ui.logo class="size-8" />
|
||||
<span class="font-mono text-lg font-semibold tracking-tight text-ink">CluPilot</span>
|
||||
<span class="font-serif text-lg font-semibold tracking-tight text-ink">CluPilot</span>
|
||||
</div>
|
||||
|
||||
<h1 class="text-2xl font-semibold tracking-tight text-ink">{{ __('auth.register_title') }}</h1>
|
||||
<p class="mt-1.5 text-sm text-muted">{{ __('auth.register_subtitle') }}</p>
|
||||
<h1 class="font-serif text-[1.75rem] font-semibold leading-tight tracking-tight text-ink">{{ __('auth.register_title') }}</h1>
|
||||
<p class="mt-2 text-sm text-muted">{{ __('auth.register_subtitle') }}</p>
|
||||
|
||||
<form method="POST" action="{{ route('register.store') }}" class="mt-7 space-y-5">
|
||||
@csrf
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<?php
|
||||
|
||||
use App\Http\Controllers\ImpersonationController;
|
||||
use App\Http\Controllers\LandingController;
|
||||
use App\Http\Controllers\StripeWebhookController;
|
||||
use App\Livewire\Admin;
|
||||
use App\Livewire\Billing;
|
||||
|
|
@ -14,7 +15,7 @@ use App\Livewire\Support;
|
|||
use App\Livewire\Users;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::get('/', fn () => view('landing'))->name('home');
|
||||
Route::get('/', LandingController::class)->name('home');
|
||||
|
||||
// Generated, not a static file: while the site is hidden this has to say so,
|
||||
// and a crawler that gets a 404 here simply crawls anyway.
|
||||
|
|
|
|||
|
|
@ -34,6 +34,13 @@ export default {
|
|||
text: 'var(--accent-text)',
|
||||
},
|
||||
'on-accent': 'var(--on-accent)',
|
||||
plate: {
|
||||
DEFAULT: 'var(--plate)',
|
||||
2: 'var(--plate-2)',
|
||||
rule: 'var(--plate-rule)',
|
||||
text: 'var(--plate-text)',
|
||||
muted: 'var(--plate-muted)',
|
||||
},
|
||||
success: { DEFAULT: 'var(--success)', bg: 'var(--success-bg)', border: 'var(--success-border)', bright: 'var(--success-bright)' },
|
||||
warning: { DEFAULT: 'var(--warning)', bg: 'var(--warning-bg)', border: 'var(--warning-border)' },
|
||||
danger: { DEFAULT: 'var(--danger)', bg: 'var(--danger-bg)', border: 'var(--danger-border)' },
|
||||
|
|
@ -42,6 +49,7 @@ export default {
|
|||
fontFamily: {
|
||||
sans: 'var(--font-sans)',
|
||||
mono: 'var(--font-mono)',
|
||||
serif: 'var(--font-serif)',
|
||||
},
|
||||
fontSize: {
|
||||
xs: 'var(--text-xs)', sm: 'var(--text-sm)', base: 'var(--text-base)', md: 'var(--text-md)',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,97 @@
|
|||
<?php
|
||||
|
||||
use App\Models\PlanFamily;
|
||||
use App\Models\PlanVersion;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* The public price sheet reads the catalogue.
|
||||
*
|
||||
* It used to be written into the page by hand, and the two had already drifted:
|
||||
* 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.
|
||||
*/
|
||||
|
||||
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();
|
||||
$monthly = $version->prices()->where('term', 'monthly')->firstOrFail();
|
||||
|
||||
$page = $this->get('/')->assertOk();
|
||||
|
||||
// Whole euros, German grouping, non-breaking space — the exact string the
|
||||
// sheet renders, so a formatting change cannot slip past this test.
|
||||
$page->assertSee(number_format($monthly->amount_cents / 100, 0, ',', '.')."\u{00A0}€", false);
|
||||
$page->assertSee('bis '.$version->seats);
|
||||
});
|
||||
|
||||
it('follows the catalogue when the owner changes a price', function () {
|
||||
// The point of the whole exercise: one place to change a price.
|
||||
DB::table('plan_prices')
|
||||
->where('plan_version_id', PlanVersion::query()
|
||||
->where('plan_family_id', PlanFamily::query()->where('key', 'team')->value('id'))
|
||||
->value('id'))
|
||||
->where('term', 'monthly')
|
||||
->update(['amount_cents' => 21500]);
|
||||
|
||||
$this->get('/')
|
||||
->assertOk()
|
||||
->assertSee("215\u{00A0}€", false)
|
||||
->assertDontSee("179\u{00A0}€", false);
|
||||
});
|
||||
|
||||
it('keeps the front page up when the catalogue cannot be read', function () {
|
||||
// A second version of the same plan on sale at the same moment. The
|
||||
// catalogue refuses to guess which one is being sold — correctly, because
|
||||
// guessing would mean quoting a price at random. The marketing page must
|
||||
// survive that: an outage in the shop is not an outage of the company.
|
||||
Log::spy();
|
||||
|
||||
$team = PlanFamily::query()->where('key', 'team')->firstOrFail();
|
||||
$original = PlanVersion::query()->where('plan_family_id', $team->id)->firstOrFail();
|
||||
|
||||
$clone = collect($original->getAttributes())->except('id')->all();
|
||||
$clone['uuid'] = (string) Str::uuid();
|
||||
$clone['version'] = $original->version + 1;
|
||||
DB::table('plan_versions')->insert($clone);
|
||||
|
||||
$this->get('/')
|
||||
->assertOk()
|
||||
->assertSee('Preise derzeit auf Anfrage')
|
||||
// No number survives from the broken catalogue.
|
||||
->assertDontSee("179\u{00A0}€", false);
|
||||
|
||||
Log::shouldHaveReceived('error')
|
||||
->withArgs(fn (string $message) => str_contains($message, 'plan catalogue'))
|
||||
->once();
|
||||
});
|
||||
|
||||
it('names the currency the catalogue is priced in, not the one the page was written in', function () {
|
||||
// The currency is configurable. A sheet that prints "€" while the checkout
|
||||
// charges francs is the same two-sources-of-truth mistake as a hard-coded
|
||||
// amount — only harder to spot, because the number looks right.
|
||||
config()->set('provisioning.currency', 'CHF');
|
||||
DB::table('plan_prices')->update(['currency' => 'CHF']);
|
||||
|
||||
$this->get('/')
|
||||
->assertOk()
|
||||
->assertSee("179\u{00A0}CHF", false)
|
||||
->assertDontSee("179\u{00A0}€", false);
|
||||
});
|
||||
|
||||
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'))
|
||||
->firstOrFail();
|
||||
|
||||
DB::table('plan_versions')->where('id', $version->id)->update([
|
||||
'features' => json_encode([...$version->features, 'quantum_entanglement']),
|
||||
]);
|
||||
|
||||
$this->get('/')
|
||||
->assertOk()
|
||||
->assertDontSee('quantum_entanglement');
|
||||
});
|
||||
Loading…
Reference in New Issue