From 6376f5e9fa6083c5a06689ede3502ffe641497cf Mon Sep 17 00:00:00 2001 From: nexxo Date: Mon, 27 Jul 2026 16:03:47 +0200 Subject: [PATCH] One design system for every surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The console was a second product. admin-tokens.css held a complete dark set — its own surfaces, its own orange, its own status triad — so every shared component had to work in two worlds, and the two halves of the application drifted apart in ways nobody could see from inside either one. That file now carries density and nothing else: smaller type, tighter rows, tighter corners. Colour comes from one place. The tokens themselves are rebuilt around what the redesign settled on: - Radii scaled to object size (9/11/16/22) instead of one value everywhere, which is what made the interface read as a design-system specification rather than as a product. - Warm, broad, low shadows. A neutral grey drop shadow belongs to a different design language and reads as cold on this ground. - --accent-press, which several rules already referenced and nothing defined. An undefined var() does not make a browser skip the declaration; it computes to , and for background-color that is transparent — the accent button lost its fill on hover and left white text on white. - No serif. A serif headline is what made every page read as a document, which is the impression the whole redesign exists to remove. --font-serif now points at the sans so nothing breaks while call sites are cleaned up. The two shells are one shell with two configurations, and both finally have a mobile navigation: the sidebar used to simply disappear below 900px, leaving no way to move around the application at all on a phone. The customer dashboard is bound to real records — instance, seats, traffic, backups, contract — instead of the fixtures it shipped with. Where something is not measured, storage consumption, it says what was contractually agreed rather than inventing a figure. This is the sheet a customer forwards to their auditor. Co-Authored-By: Claude Opus 5 --- app/Livewire/Dashboard.php | 329 +++++++---- lang/de/admin.php | 5 + lang/de/cloud.php | 1 + lang/de/dashboard.php | 91 ++++ lang/en/admin.php | 5 + lang/en/cloud.php | 1 + lang/en/dashboard.php | 91 ++++ resources/css/admin-tokens.css | 67 ++- resources/css/app.css | 47 +- resources/css/portal-tokens.css | 142 +++-- resources/views/coming-soon.blade.php | 79 +-- .../views/components/auth/brand.blade.php | 20 +- .../views/components/shell/head.blade.php | 14 + .../views/components/shell/nav.blade.php | 65 +++ .../views/components/shell/topbar.blade.php | 30 + resources/views/components/ui/alert.blade.php | 2 +- resources/views/components/ui/badge.blade.php | 2 +- .../views/components/ui/button.blade.php | 41 +- resources/views/components/ui/card.blade.php | 14 +- resources/views/components/ui/input.blade.php | 4 +- .../views/components/ui/nav-item.blade.php | 25 +- resources/views/errors/layout.blade.php | 80 +-- resources/views/layouts/admin.blade.php | 107 ++-- resources/views/layouts/portal-app.blade.php | 121 ++--- resources/views/livewire/auth/login.blade.php | 4 +- .../views/livewire/auth/register.blade.php | 4 +- resources/views/livewire/dashboard.blade.php | 512 +++++++++--------- resources/views/status.blade.php | 15 +- tests/Feature/Admin/AdminConsoleTest.php | 9 +- tests/Feature/DashboardTest.php | 34 ++ 30 files changed, 1181 insertions(+), 780 deletions(-) create mode 100644 resources/views/components/shell/head.blade.php create mode 100644 resources/views/components/shell/nav.blade.php create mode 100644 resources/views/components/shell/topbar.blade.php diff --git a/app/Livewire/Dashboard.php b/app/Livewire/Dashboard.php index b9d416f..1491d80 100644 --- a/app/Livewire/Dashboard.php +++ b/app/Livewire/Dashboard.php @@ -3,12 +3,26 @@ namespace App\Livewire; use App\Livewire\Concerns\ResolvesCustomer; +use App\Models\Customer; +use App\Models\Datacenter; +use App\Models\Instance; +use App\Models\MaintenanceWindow; +use App\Models\Seat; +use App\Models\Subscription; use App\Services\Traffic\TrafficMeter; use Illuminate\Support\Carbon; -use Illuminate\Support\Number; use Livewire\Attributes\Layout; use Livewire\Component; +/** + * The customer's Betriebsblatt: what they have, and the evidence that it is + * being looked after. + * + * Every figure on this page comes from a record. Where something is not + * measured yet — storage consumption, restore tests — the page says nothing + * rather than showing a plausible number: this is the sheet a customer forwards + * to their auditor, and one invented line on it is worse than a short register. + */ #[Layout('layouts.portal-app')] class Dashboard extends Component { @@ -16,118 +30,217 @@ class Dashboard extends Component public function render() { - // Real numbers where they exist: traffic is metered, unlike the - // fixtures below, and a customer needs to see it before it runs out. - $instance = $this->customer()?->instances()->latest('id')->first(); - $traffic = $instance !== null ? TrafficMeter::for($instance) : null; + $customer = $this->customer(); - // Locale-aware date / number formatting for the fixture values below. - $locale = app()->getLocale(); - $date = fn (string $iso, string $fmt) => Carbon::parse($iso)->locale($locale)->isoFormat($fmt); - $gb = fn (float $v) => Number::format($v, precision: 1, locale: $locale).' GB'; - // Fixture data until the provisioning engine (separate handoff) supplies - // real instances / metrics. Tenant-specific values stand in for DB rows. - $storageUsed = 235; - $storageQuota = 500; - $storagePercent = (int) round($storageUsed / max(1, $storageQuota) * 100); + // The instance that is actually in service. A cancelled one still has + // rows, and would otherwise keep filling this page after it is gone. + $instance = $customer?->instances() + ->whereIn('status', ['active', 'provisioning', 'cancellation_scheduled']) + ->latest('id') + ->first(); - // ~30 days of availability, gently trending near 100%. - $availability = [99.95, 99.97, 99.96, 99.98, 99.99, 99.97, 99.98, 99.99, 99.99, 99.98, - 99.97, 99.99, 100, 99.98, 99.99, 99.99, 99.97, 99.98, 99.99, 100, - 99.98, 99.99, 99.99, 99.98, 100, 99.99, 99.98, 99.99, 100, 99.98]; + $contract = $instance?->subscription; + $maintenance = MaintenanceWindow::forInstance($instance)->first(); return view('livewire.dashboard', [ - 'traffic' => $traffic, - 'storageUsed' => $storageUsed, - 'storageQuota' => $storageQuota, - 'storagePercent' => $storagePercent, - 'usersActive' => 8, - 'usersQuota' => 20, - 'lastBackup' => '03:12', - 'availability' => Number::format(99.98, precision: 2, locale: $locale), - - 'storageChart' => [ - 'type' => 'doughnut', - 'data' => [ - 'datasets' => [[ - 'data' => [$storagePercent, 100 - $storagePercent], - 'backgroundColor' => ['token:accent', 'token:surface-2'], - 'borderWidth' => 0, - ]], - ], - 'options' => [ - 'cutout' => '76%', - 'plugins' => [ - 'legend' => ['display' => false], - 'tooltip' => ['enabled' => false], - ], - ], - ], - - 'availabilityChart' => [ - 'type' => 'line', - 'data' => [ - 'labels' => array_fill(0, count($availability), ''), - 'datasets' => [[ - 'data' => $availability, - 'borderColor' => 'token:success-bright', - 'backgroundColor' => 'token:success-bright/0.12', - 'fill' => true, - 'tension' => 0.4, - 'pointRadius' => 0, - 'borderWidth' => 2, - ]], - ], - 'options' => [ - 'scales' => [ - 'x' => ['display' => false], - 'y' => ['display' => false, 'min' => 99.9, 'max' => 100], - ], - 'plugins' => [ - 'legend' => ['display' => false], - 'tooltip' => ['enabled' => false], - ], - ], - ], - - 'cloud' => [ - 'name' => 'Cloud der Kanzlei Berger', - 'domain' => 'cloud.kanzlei-berger.at', - 'package' => __('dashboard.cloud.plan_line', ['plan' => 'CluPilot Cloud Team', 'price' => 179]), - 'since' => $date('2026-03-14', 'LL'), - 'location' => __('dashboard.cloud.datacenter'), - 'version' => 'Nextcloud 31.0.4', - ], - - 'onboarding' => [ - ['label' => __('dashboard.onboarding.instance'), 'done' => true, 'date' => $date('2026-03-14', 'D. MMMM')], - ['label' => __('dashboard.onboarding.domain'), 'done' => true, 'date' => $date('2026-03-15', 'D. MMMM')], - ['label' => __('dashboard.onboarding.branding'), 'done' => true, 'date' => $date('2026-03-15', 'D. MMMM')], - ['label' => __('dashboard.onboarding.users'), 'done' => true, 'date' => $date('2026-03-16', 'D. MMMM')], - ['label' => __('dashboard.onboarding.migration'), 'done' => false, 'date' => null], - ['label' => __('dashboard.onboarding.training'), 'done' => false, 'date' => null], - ['label' => __('dashboard.onboarding.docs'), 'done' => false, 'date' => null], - ], - - 'backups' => [ - ['when' => __('dashboard.today').', 03:12', 'size' => $gb(18.4)], - ['when' => __('dashboard.yesterday').', 03:09', 'size' => $gb(18.4)], - ['when' => $date('2026-07-21', 'D. MMMM').', 03:11', 'size' => $gb(18.3)], - ], - - 'modules' => [ - ['icon' => 'cloud', 'name' => 'Nextcloud + Collabora', 'desc' => __('dashboard.modules.nextcloud'), 'status' => 'active'], - ['icon' => 'shield-check', 'name' => 'Vaultwarden', 'desc' => __('dashboard.modules.vaultwarden'), 'status' => 'active'], - ['icon' => 'receipt', 'name' => 'Paperless-ngx', 'desc' => __('dashboard.modules.paperless'), 'status' => 'available'], - ['icon' => 'pen', 'name' => __('dashboard.modules.signature_name'), 'desc' => __('dashboard.modules.signature'), 'status' => 'soon'], - ], - - 'activity' => [ - ['icon' => 'download', 'text' => __('dashboard.activity_items.backup_ok'), 'time' => '03:12'], - ['icon' => 'shield-check', 'text' => __('dashboard.activity_items.security'), 'time' => '04:01'], - ['icon' => 'users', 'text' => __('dashboard.activity_items.upload'), 'time' => '07:56'], - ['icon' => 'shield-check', 'text' => __('dashboard.activity_items.cert'), 'time' => __('dashboard.yesterday').' 22:10'], + 'customer' => $customer, + 'instance' => $instance, + 'contract' => $contract, + 'domain' => $this->domain($instance), + 'location' => $this->location($instance), + 'seats' => $this->seats($customer, $contract), + 'traffic' => $instance !== null ? TrafficMeter::for($instance) : null, + 'proofs' => $this->proofs($instance, $maintenance), + 'openTasks' => $instance?->onboardingTasks()->where('done', false)->count() ?? 0, + // What the plan costs is true whether or not another invoice is + // coming — a customer who has given notice still pays until the + // term ends and still needs the figure on their master record. + 'planPrice' => $contract === null ? null : [ + 'cents' => (int) $contract->price_cents, + 'currency' => (string) ($contract->currency ?: 'EUR'), + 'term' => (string) $contract->term, ], + 'nextInvoice' => $this->nextInvoice($contract, $instance), + 'asOf' => Carbon::now(), ]); } + + /** The address the customer actually reaches their cloud at. */ + private function domain(?Instance $instance): ?string + { + if ($instance === null) { + return null; + } + + return $instance->custom_domain + ?: ($instance->subdomain + ? $instance->subdomain.'.'.config('provisioning.dns.zone', 'clupilot.com') + : null); + } + + /** + * Where the data physically sits. + * + * Named from the datacenter register rather than from a translation string, + * because this is the line a customer copies into a processing record — it + * has to be the place their instance really runs on, not the place we + * usually sell. + * + * @return array{name: string, note: ?string}|null + */ + private function location(?Instance $instance): ?array + { + $code = $instance?->host?->datacenter; + + if ($code === null || $code === '') { + return null; + } + + $datacenter = Datacenter::query()->where('code', $code)->first(); + + return [ + 'name' => $datacenter?->name ?: $code, + 'note' => $datacenter?->location, + ]; + } + + /** + * Seats in use against seats bought. + * + * Invited seats count as used: the licence is committed the moment the + * invitation goes out, and showing them as free is how a customer finds out + * at the worst moment that they cannot add the person in front of them. + * + * @return array{used: int, total: int|null} + */ + private function seats(?Customer $customer, ?Subscription $contract): array + { + return [ + 'used' => $customer === null ? 0 : Seat::query() + ->where('customer_id', $customer->id) + ->whereIn('status', ['active', 'invited']) + ->count(), + 'total' => $contract?->seats, + ]; + } + + /** + * The proof register — what was done for this customer, and when. + * + * Only entries backed by a record. A row here is something the customer can + * be asked to evidence, so "we back up nightly" is not one; "the last backup + * completed at 03:12 and reported ok" is. + * + * @return array + */ + private function proofs(?Instance $instance, ?MaintenanceWindow $maintenance): array + { + if ($instance === null) { + return []; + } + + $proofs = []; + + // A failing job outranks a succeeding one, whatever their dates. + // + // Ordering by last_ok_at alone hides exactly the case this register + // exists for: last night's job failed and so has no success time at + // all, an older row that DID succeed sorts above it, and the sheet + // reports backups as fine. On the page a customer shows their auditor, + // a problem has to beat a date. + $backups = $instance->backups()->get(); + $backup = $backups->first(fn ($b) => $b->status !== 'ok') + ?? $backups->sortByDesc('last_ok_at')->first(); + + if ($backup !== null) { + $proofs[] = [ + 'key' => 'backup', + 'at' => $backup->last_ok_at, + // The job's own verdict, not "there is a row, so it worked". + 'state' => $backup->status === 'ok' ? 'ok' : 'attention', + 'note' => $backup->schedule, + ]; + } + + // A certificate that stopped renewing is the failure a customer meets as + // "my browser says my own cloud is unsafe", so it is stated either way. + $proofs[] = [ + 'key' => 'certificate', + 'at' => null, + 'state' => $instance->cert_ok ? 'ok' : 'attention', + 'note' => null, + ]; + + if ($maintenance !== null) { + $proofs[] = [ + 'key' => 'maintenance', + 'at' => $maintenance->starts_at, + 'state' => 'planned', + 'note' => $maintenance->title, + ]; + } + + if ($instance->service_ends_at !== null) { + $proofs[] = [ + 'key' => 'service_ends', + 'at' => $instance->service_ends_at, + 'state' => 'attention', + 'note' => null, + ]; + } + + // Newest first, but an entry with no time of its own — the certificate — + // stays at the top rather than sinking to the bottom of the register. + usort($proofs, fn (array $a, array $b) => ($b['at']?->timestamp ?? PHP_INT_MAX) <=> ($a['at']?->timestamp ?? PHP_INT_MAX)); + + return $proofs; + } + + /** + * What is owed next, from the contract rather than from a price list. + * + * The plan and the modules are kept apart. A customer with modules booked + * would otherwise read the plan's price as their bill and be short every + * month — and putting the combined figure next to the word "Paket" on the + * master record would be just as wrong in the other direction. + * + * Add-ons carry a MONTHLY price (SubscriptionAddon::monthlyCents), while the + * plan's price_cents is the price of a whole term. On a yearly contract the + * modules therefore multiply by twelve to land on the same invoice. + * + * Nothing at all once the customer has given notice. Cancelling leaves the + * subscription `active` until the term runs out — that is deliberate, the + * service keeps running — but `current_period_end` is then the day service + * ENDS, not the day the next charge falls. Billing a customer, on the sheet + * they check their invoices against, for a renewal that will never happen is + * the worst single line this page could carry. The end date is still stated; + * it is in the proof register as "contract ends". + * + * @return array{plan: int, addons: int, total: int, currency: string, due: \Illuminate\Support\Carbon|null, term: string}|null + */ + private function nextInvoice(?Subscription $contract, ?Instance $instance): ?array + { + if ($contract === null || $contract->status === 'cancelled') { + return null; + } + + if ($instance?->cancel_requested_at !== null || $instance?->service_ends_at !== null) { + return null; + } + + $months = $contract->isYearly() ? 12 : 1; + + $addons = $contract->addons + ->whereNull('cancelled_at') + ->sum(fn ($addon) => $addon->monthlyCents()) * $months; + + return [ + 'plan' => (int) $contract->price_cents, + 'addons' => (int) $addons, + 'total' => (int) $contract->price_cents + (int) $addons, + 'currency' => (string) ($contract->currency ?: 'EUR'), + 'due' => $contract->current_period_end, + 'term' => (string) $contract->term, + ]; + } } diff --git a/lang/de/admin.php b/lang/de/admin.php index cde5116..d1ebd1e 100644 --- a/lang/de/admin.php +++ b/lang/de/admin.php @@ -5,6 +5,11 @@ return [ 'badge' => 'Admin', 'to_portal' => 'Zum Kundenportal', + 'nav_group' => [ + 'operations' => 'Betrieb', + 'system' => 'System', + ], + 'nav' => [ 'overview' => 'Übersicht', 'customers' => 'Kunden', diff --git a/lang/de/cloud.php b/lang/de/cloud.php index 96d9a39..b7ace03 100644 --- a/lang/de/cloud.php +++ b/lang/de/cloud.php @@ -6,6 +6,7 @@ return [ 'plan_line' => ':plan · :price €/Monat', 'datacenter' => 'EU · Rechenzentrum Falkenstein', 'now' => 'jetzt', + 'instance_label' => 'Instanz', 'open' => 'Cloud öffnen', 'status_active' => 'Aktiv', 'status_provisioning' => 'Bereitstellung', diff --git a/lang/de/dashboard.php b/lang/de/dashboard.php index 1ef882a..3c32b4c 100644 --- a/lang/de/dashboard.php +++ b/lang/de/dashboard.php @@ -3,12 +3,96 @@ return [ 'traffic' => [ 'title' => 'Datenvolumen diesen Monat', + 'of' => 'von :total', 'resets' => 'setzt sich in :days Tagen zurück', 'remaining' => ':amount übrig', 'almost' => ':percent % verbraucht — es wird knapp.', 'exhausted' => 'Kontingent aufgebraucht. Ihre Nextcloud läuft weiter, aber langsamer, bis Sie nachbuchen oder der Monat wechselt.', 'buy' => 'Volumen nachbuchen', ], + // Das Betriebsblatt: was der Kunde hat, und der Nachweis, dass es betreut + // wird. Jede Zeile kommt aus einem Datensatz — was nicht gemessen wird, + // steht hier auch nicht. + 'sheet' => 'Betriebsblatt · Stand :when', + 'title_running' => 'Ihre Cloud läuft.', + 'title_pending' => 'Ihre Cloud wird eingerichtet.', + + 'instance_status' => [ + 'active' => 'Alle Dienste aktiv', + 'provisioning' => 'Wird eingerichtet', + 'cancellation_scheduled' => 'Kündigung vorgemerkt', + ], + + 'no_instance_label' => 'Noch keine Instanz', + 'no_instance_body' => 'Für dieses Konto läuft derzeit keine Cloud. Sobald eine Bestellung bezahlt ist, richten wir sie ein — dieser Bereich füllt sich dann von selbst.', + 'no_instance_cta' => 'Paket wählen', + + 'master' => [ + 'label' => 'Stammblatt', + 'package' => 'Paket', + 'operation' => 'Betriebsform', + 'location' => 'Serverstandort', + 'users' => 'Benutzer', + 'address' => 'Adresse', + 'storage' => 'Speicher', + 'storage_note' => 'vertraglich zugesichert', + 'gb' => ':n GB', + 'of_total' => ':used von :total', + 'per_month' => ':amount netto / Monat', + 'per_year' => ':amount netto / Jahr', + ], + + 'proofs' => [ + 'label' => 'Nachweise', + 'all' => 'Alle ansehen', + 'empty' => 'Sobald die erste Sicherung gelaufen ist, steht sie hier.', + 'when' => 'Zeitpunkt', + 'what' => 'Vorgang', + 'result' => 'Ergebnis', + 'item' => [ + 'backup' => 'Sicherung', + 'certificate' => 'Verschlüsselte Verbindung', + 'maintenance' => 'Geplante Wartung', + 'service_ends' => 'Vertragsende', + ], + 'state' => [ + 'ok' => 'Geprüft', + 'planned' => 'Geplant', + 'attention' => 'Prüfen', + ], + ], + + 'invoice' => [ + 'label' => 'Nächste Abrechnung', + 'plan' => 'Paket', + 'addons' => 'Module', + 'amount' => 'Betrag', + 'net' => 'zzgl. USt.', + 'due' => 'Fällig', + 'term' => 'Abrechnung', + 'all' => 'Rechnungen ansehen', + ], + + 'term' => [ + 'monthly' => 'monatlich', + 'yearly' => 'jährlich', + ], + + 'setup' => [ + 'label' => 'Einrichtung', + 'open' => '{1} Ein Schritt ist noch offen.|[2,*] :count Schritte sind noch offen.', + 'continue' => 'Einrichtung fortsetzen', + ], + + 'quick' => [ + 'add_user_title' => 'Benutzer hinzufügen', + 'add_user_body' => 'Zugang für neue Mitarbeiter anlegen', + 'restore_title' => 'Datei wiederherstellen', + 'restore_body' => 'Aus den vorhandenen Sicherungen', + 'grow_title' => 'Paket erweitern', + 'grow_body' => 'Speicher, Benutzer oder Datenvolumen', + ], + 'title' => 'Übersicht', 'subtitle' => 'Status Ihrer Cloud auf einen Blick.', 'greeting' => 'Guten Morgen, :name', @@ -26,6 +110,13 @@ return [ 'support' => 'Support', ], + // Zwei Gruppen: was der Kunde betreibt, und was im Vertrag steht. + 'nav_group' => [ + 'contract' => 'Vertrag', + ], + 'nav_footer' => "Betreut aus Österreich\nAntwort am selben Werktag", + 'signed_in_as' => 'Angemeldet als', + 'open_nav' => 'Navigation öffnen', 'close_nav' => 'Navigation schließen', 'logout' => 'Abmelden', diff --git a/lang/en/admin.php b/lang/en/admin.php index d099a51..b624c01 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -5,6 +5,11 @@ return [ 'badge' => 'Admin', 'to_portal' => 'To customer portal', + 'nav_group' => [ + 'operations' => 'Operations', + 'system' => 'System', + ], + 'nav' => [ 'overview' => 'Overview', 'customers' => 'Customers', diff --git a/lang/en/cloud.php b/lang/en/cloud.php index 42ebfa1..80f832b 100644 --- a/lang/en/cloud.php +++ b/lang/en/cloud.php @@ -6,6 +6,7 @@ return [ 'plan_line' => ':plan · €:price/mo', 'datacenter' => 'EU · Falkenstein data center', 'now' => 'now', + 'instance_label' => 'Instance', 'open' => 'Open cloud', 'status_active' => 'Active', 'status_provisioning' => 'Provisioning', diff --git a/lang/en/dashboard.php b/lang/en/dashboard.php index f1bc7ae..ee14e98 100644 --- a/lang/en/dashboard.php +++ b/lang/en/dashboard.php @@ -3,12 +3,96 @@ return [ 'traffic' => [ 'title' => 'Data transfer this month', + 'of' => 'of :total', 'resets' => 'resets in :days days', 'remaining' => ':amount left', 'almost' => ':percent % used — getting tight.', 'exhausted' => 'Allowance used up. Your Nextcloud keeps running, but slower, until you top up or the month rolls over.', 'buy' => 'Add data volume', ], + // The operating sheet: what the customer has, and the evidence it is being + // looked after. Every line comes from a record — anything not measured does + // not appear here at all. + 'sheet' => 'Operating sheet · as of :when', + 'title_running' => 'Your cloud is running.', + 'title_pending' => 'Your cloud is being set up.', + + 'instance_status' => [ + 'active' => 'All services running', + 'provisioning' => 'Being set up', + 'cancellation_scheduled' => 'Cancellation scheduled', + ], + + 'no_instance_label' => 'No instance yet', + 'no_instance_body' => 'No cloud is running for this account at the moment. As soon as an order is paid we set one up — this area then fills itself.', + 'no_instance_cta' => 'Choose a package', + + 'master' => [ + 'label' => 'Master record', + 'package' => 'Package', + 'operation' => 'Operating mode', + 'location' => 'Server location', + 'users' => 'Users', + 'address' => 'Address', + 'storage' => 'Storage', + 'storage_note' => 'contractually guaranteed', + 'gb' => ':n GB', + 'of_total' => ':used of :total', + 'per_month' => ':amount net / month', + 'per_year' => ':amount net / year', + ], + + 'proofs' => [ + 'label' => 'Evidence', + 'all' => 'View all', + 'empty' => 'Once the first backup has run, it appears here.', + 'when' => 'Time', + 'what' => 'Event', + 'result' => 'Result', + 'item' => [ + 'backup' => 'Backup', + 'certificate' => 'Encrypted connection', + 'maintenance' => 'Scheduled maintenance', + 'service_ends' => 'Contract ends', + ], + 'state' => [ + 'ok' => 'Verified', + 'planned' => 'Scheduled', + 'attention' => 'Check', + ], + ], + + 'invoice' => [ + 'label' => 'Next invoice', + 'plan' => 'Package', + 'addons' => 'Modules', + 'amount' => 'Amount', + 'net' => 'plus VAT', + 'due' => 'Due', + 'term' => 'Billed', + 'all' => 'View invoices', + ], + + 'term' => [ + 'monthly' => 'monthly', + 'yearly' => 'yearly', + ], + + 'setup' => [ + 'label' => 'Setup', + 'open' => '{1} One step is still open.|[2,*] :count steps are still open.', + 'continue' => 'Continue setup', + ], + + 'quick' => [ + 'add_user_title' => 'Add a user', + 'add_user_body' => 'Create access for a new colleague', + 'restore_title' => 'Restore a file', + 'restore_body' => 'From the backups on record', + 'grow_title' => 'Extend the package', + 'grow_body' => 'Storage, users or data allowance', + ], + 'title' => 'Overview', 'subtitle' => 'Your cloud status at a glance.', 'greeting' => 'Good morning, :name', @@ -26,6 +110,13 @@ return [ 'support' => 'Support', ], + // Two groups: what the customer operates, and what the contract says. + 'nav_group' => [ + 'contract' => 'Contract', + ], + 'nav_footer' => "Operated from Austria\nAnswered the same working day", + 'signed_in_as' => 'Signed in as', + 'open_nav' => 'Open navigation', 'close_nav' => 'Close navigation', 'logout' => 'Sign out', diff --git a/resources/css/admin-tokens.css b/resources/css/admin-tokens.css index 7918d29..a910b97 100644 --- a/resources/css/admin-tokens.css +++ b/resources/css/admin-tokens.css @@ -1,33 +1,40 @@ -/* CluPilot admin console — "Tactical Terminal": dark graphite, signal-orange, - ops status triad. Scoped to .theme-admin so it overrides the light portal - tokens only inside the admin layout — every shared component adapts for free. */ +/* CluPilot operator console — density, not a second palette. + * + * This file used to hold a complete dark "Tactical Terminal" set: its own + * surfaces, its own orange, its own status triad. That made the console a + * second product. Two colours for "healthy", two oranges, two ideas of what a + * card is — and every shared component had to work in both. + * + * The console is now the same light system as everything else. What genuinely + * differs is DENSITY: an operator scans forty rows, a customer reads four. So + * this file changes spacing and type size, and nothing about colour. + * + * Dark mode is a preference, not the operator aesthetic. Light stays the + * canonical expression on every surface. + */ .theme-admin { - --bg: #0b0e13; - --surface: #12161d; - --surface-2: #1a2029; - --surface-hover: #1c232d; + /* One step down across the board. The scale is the same scale — a console + that used different type sizes as well as different spacing stops being + recognisably the same application. */ + --text-base: 13.5px; + --text-md: 15px; + --text-2xl: 26px; + --text-3xl: 32px; - --border: #232b37; - --border-strong: #313b4a; - - --text-strong: #eef1f6; - --text: #c2cad6; - --text-muted: #8b95a5; - --text-faint: #626d7e; - - /* signal orange — brighter for dark; dark text sits on the fill (AA) */ - --accent: #ff7a2f; - --accent-hover: #ff8f4d; - --accent-active: #ff7a2f; - --accent-press: #e8630f; - --accent-subtle: rgba(255, 122, 47, .14); - --accent-border: rgba(255, 122, 47, .32); - --accent-ring: rgba(255, 122, 47, .45); - --on-accent: #0b0e13; - --accent-text: #ff9152; - - --success: #35d07f; --success-bg: rgba(53, 208, 127, .13); --success-border: rgba(53, 208, 127, .32); --success-bright: #35d07f; - --warning: #e8b931; --warning-bg: rgba(232, 185, 49, .13); --warning-border: rgba(232, 185, 49, .32); - --danger: #ff5247; --danger-bg: rgba(255, 82, 71, .13); --danger-border: rgba(255, 82, 71, .32); - --info: #5b9bff; --info-bg: rgba(91, 155, 255, .13); --info-border: rgba(91, 155, 255, .32); + /* Tighter corners on a denser grid: a 16px radius around a 40px-high row + looks inflated when there are twenty of them. */ + --radius-lg: 13px; + --radius-xl: 16px; +} + +/* Table rows an operator scans rather than reads: 40–44px, against the 48–56px + the portal uses. Written here rather than in the views so a future table + inherits it instead of restating it. */ +.theme-admin table td { + padding-top: 11px; + padding-bottom: 11px; +} + +.theme-admin table th { + padding-bottom: 10px; } diff --git a/resources/css/app.css b/resources/css/app.css index 43c2caf..9c71049 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -7,9 +7,8 @@ @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'; +/* No serif: a serif headline made every page read as a document, which is + exactly the impression the redesign removes. One family, used assertively. */ /* Design tokens, then Tailwind v3 layers. */ @import './portal-tokens.css'; @@ -53,48 +52,6 @@ } } -@layer components { - /* Registration marks — the corner crosses on a printed form. A pseudo - element rather than markup: they are decoration, and a screen reader - announcing "plus, plus" on every panel would be noise. Tailwind cannot - express ::before content, so this is the one place it lives in CSS. */ - .doc-marks { - position: relative; - } - - .doc-marks::before, - .doc-marks::after { - content: "+"; - position: absolute; - font-family: var(--font-mono); - font-size: 0.78rem; - line-height: 1; - color: var(--border-strong); - pointer-events: none; - } - - .doc-marks::before { - top: -6px; - left: -6px; - } - - .doc-marks::after { - bottom: -6px; - right: -6px; - } - - /* The rule that precedes an eyebrow label, in the accent. Same reasoning: - decorative, and it has to sit on the text baseline of a flex row. */ - .doc-eyebrow::before { - content: ""; - display: block; - width: 22px; - height: 1px; - background: var(--accent); - flex: none; - } -} - /* iOS zooms the whole page when a field smaller than 16px takes focus, and * then leaves the page zoomed — every tap into a form throws the layout about. * diff --git a/resources/css/portal-tokens.css b/resources/css/portal-tokens.css index 690f294..021acfb 100644 --- a/resources/css/portal-tokens.css +++ b/resources/css/portal-tokens.css @@ -1,75 +1,105 @@ -/* CluPilot customer-portal design tokens — refined enterprise, light, single - orange accent. Framework-neutral CSS custom properties (work in Tailwind v3 - or v4). Values calibrated for AA contrast. See design handoff §6. */ +/* CluPilot design tokens — one set for every surface. + * + * Light, confident, one warm accent. The public site, the customer portal and + * the operator console draw from this file and differ only in density: same + * colours, same radii, same button hierarchy, same status vocabulary. A + * customer who signs in must not feel handed to a different product, and an + * operator switching between the two should not have to relearn anything. + * + * Framework-neutral custom properties (work in Tailwind v3 or v4). Contrast + * ratios are noted where they were the reason for the value. + */ :root { - /* Surfaces (light, slightly cool) */ - --bg: #f6f7f9; + /* ── Surfaces ───────────────────────────────────────────────────────── + The ground is a shade darker than the cards, so a card lifts off the + page without a shadow doing the work. */ + --bg: #f6f6f8; --surface: #ffffff; - --surface-2: #f1f3f5; - --surface-hover: #f8f9fb; + --surface-2: #fafafb; + --surface-hover: #f2f2f5; - /* Lines */ - --border: #e4e7ec; - --border-strong: #d0d5dd; + /* Lines carry the structure. Depth is used sparingly and warmly. */ + --border: #e9e9ee; + --border-strong: #dcdce4; - /* Text */ - --text-strong: #101828; - --text: #344054; - --text-muted: #667085; - --text-faint: #98a2b3; + /* ── Text ───────────────────────────────────────────────────────────── + Near-black rather than pure black: on a light ground pure black reads + as a printing error, not as emphasis. */ + --text-strong: #17171c; /* headings — 16.4:1 on white */ + --text: #43434e; /* body — 9.8:1 */ + --text-muted: #6e6e7a; /* secondary copy — 5.0:1, AA for body text */ + --text-faint: #b4b4be; /* decoration only: rules, placeholder glyphs */ - /* Accent = the single brand / interaction tone: orange */ - --accent: #f97316; /* brand tone: decorative fills, borders, tints, focus */ - --accent-hover: #ea6a0c; - --accent-active: #c2560a; /* AA-safe fill for white text on orange (~5:1) */ - --accent-press: #a8480a; /* darker press/hover for accent-fill buttons */ - --accent-subtle: #fff4ec; - --accent-border: #fed7aa; - --accent-ring: rgba(249, 115, 22, .35); + /* ── Accent ─────────────────────────────────────────────────────────── + Orange appears in FEW places, but one of them is allowed to be large — + a whole closing panel. Frequency low, area high; the reverse reads as + editorial annotation rather than as brand. */ + --accent: #f97316; /* rings, bars, chart strokes, the logo mark */ + --accent-hover: #d95f0c; + --accent-active: #b8500a; /* solid fills and text — 5.0:1 with white */ + --accent-press: #974208; + --accent-subtle: #fff3e9; + --accent-border: #e8cdb2; + --accent-ring: rgba(249, 115, 22, .38); --on-accent: #ffffff; - /* Accent as TEXT on light surfaces (links, small labels) must be dark enough - for AA — #f97316 on white is only ~2.9:1, so text uses --accent-active. */ - --accent-text: #c2560a; + /* #f97316 as TEXT on white is only 2.9:1, so anything read uses the + darker tone. This is the single most common way the palette gets + broken, which is why the two have different names. */ + --accent-text: #b8500a; - /* 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; + /* ── Ink fields ─────────────────────────────────────────────────────── + The dark surfaces: the site's contrast bands, the sign-in panel, the + operator sidebar. Neutral-warm, never pure black. */ + --plate: #16151b; + --plate-2: #201f27; + --plate-rule: #33323d; + --plate-text: #c9c7d2; + --plate-muted: #918f9d; /* 4.6:1 on --plate */ - /* 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) */ - --warning: #b54708; --warning-bg: #fffaeb; --warning-border: #fedf89; - --danger: #b42318; --danger-bg: #fef3f2; --danger-border: #fecdca; - --info: #175cd3; --info-bg: #eff8ff; --info-border: #b2ddff; + /* ── Status ─────────────────────────────────────────────────────────── + Independent of the brand. Amber must keep meaning "this needs you", so + "scheduled" and "informational" are blue — next to an orange brand the + two are otherwise indistinguishable. */ + --success: #1c7c50; --success-bg: #eaf6f0; --success-border: #c3e3d3; + --success-bright: #30b15c; /* dots, rings and charts — never small text */ + --warning: #9a5b0b; --warning-bg: #fdf4e7; --warning-border: #eed9b3; + --danger: #a8302a; --danger-bg: #fbeeed; --danger-border: #eec9c6; + --info: #1e5aa8; --info-bg: #eef4fa; --info-border: #c3d6ea; - /* Typography */ + /* ── Type ───────────────────────────────────────────────────────────── + One family, used assertively. Mono is for operational data only — + storage, dates, addresses, versions, invoice figures — where tabular + figures let a column of numbers line up. Never for headings. */ --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; + /* Kept for compatibility; the display voice is now the sans at weight 700. + A serif headline made every page read as a document. */ + --font-serif: "IBM Plex Sans", ui-sans-serif, system-ui, sans-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; + --text-xs: 12px; --text-sm: 13px; --text-base: 14.5px; --text-md: 16px; + --text-lg: 18px; --text-xl: 22px; --text-2xl: 30px; --text-3xl: 40px; - --lh-tight: 1.2; --lh-normal: 1.5; - --tracking-tight: -0.02em; - --tracking-label: 0.06em; + --lh-tight: 1.12; --lh-normal: 1.55; + --tracking-tight: -0.03em; + --tracking-label: 0.07em; - /* Radius / shadow / motion / focus */ - --radius-sm: 6px; --radius: 8px; --radius-lg: 12px; --radius-xl: 20px; --radius-pill: 999px; + /* ── Radius ─────────────────────────────────────────────────────────── + Scaled to object size. One value everywhere is what makes an interface + read as a design-system specification rather than as a product. */ + --radius-sm: 9px; /* chips, small controls */ + --radius: 11px; /* buttons, inputs */ + --radius-lg: 16px; /* cards */ + --radius-xl: 22px; /* full-width panels, product frames */ + --radius-pill: 999px; - --shadow-xs: 0 1px 2px rgba(16, 24, 40, .05); - --shadow-sm: 0 1px 3px rgba(16, 24, 40, .10), 0 1px 2px rgba(16, 24, 40, .06); - --shadow-md: 0 4px 8px -2px rgba(16, 24, 40, .10), 0 2px 4px -2px rgba(16, 24, 40, .06); + /* Warm, broad and low — a neutral grey drop shadow belongs to a different + design language and reads as cold against this ground. */ + --shadow-xs: 0 1px 2px rgba(40, 24, 15, .04); + --shadow-sm: 0 2px 6px rgba(40, 24, 15, .06); + --shadow-md: 0 16px 44px -18px rgba(40, 24, 15, .20); - --dur-fast: 120ms; --dur: 180ms; --dur-slow: 240ms; - --ease: cubic-bezier(.2, .6, .2, 1); + --dur-fast: 120ms; --dur: 180ms; --dur-slow: 260ms; + --ease: cubic-bezier(.2, .7, .2, 1); --focus-ring: 0 0 0 3px var(--accent-ring); } diff --git a/resources/views/coming-soon.blade.php b/resources/views/coming-soon.blade.php index 5bc5924..9e1a8a5 100644 --- a/resources/views/coming-soon.blade.php +++ b/resources/views/coming-soon.blade.php @@ -19,66 +19,69 @@ --}} @verbatim @endverbatim
-
+
{{ __('coming_soon.label') }} - {{ __('coming_soon.state') }} + {{ __('coming_soon.state') }}
- CluPilot Cloud + CluPilot Cloud

{{ __('coming_soon.title') }}

{{ __('coming_soon.body') }}

{{ __('coming_soon.contact') }} diff --git a/resources/views/components/auth/brand.blade.php b/resources/views/components/auth/brand.blade.php index f48dda9..0dd4e1b 100644 --- a/resources/views/components/auth/brand.blade.php +++ b/resources/views/components/auth/brand.blade.php @@ -14,24 +14,20 @@ would only push it under the fold. --}}

diff --git a/resources/views/components/shell/head.blade.php b/resources/views/components/shell/head.blade.php new file mode 100644 index 0000000..d97a55b --- /dev/null +++ b/resources/views/components/shell/head.blade.php @@ -0,0 +1,14 @@ +@props(['title' => null]) +{{-- + The document head both shells share. Written once because a divergence here + is invisible until something is missing on exactly one of them — a favicon, + the CSRF token, a viewport that lets iOS zoom. +--}} + + + + + +{{ $title ?? config('app.name') }} +@vite(['resources/css/app.css', 'resources/js/app.js']) +@livewireStyles diff --git a/resources/views/components/shell/nav.blade.php b/resources/views/components/shell/nav.blade.php new file mode 100644 index 0000000..00fd757 --- /dev/null +++ b/resources/views/components/shell/nav.blade.php @@ -0,0 +1,65 @@ +@props([ + // [[route, icon, translation key], …] or [['label' => …, 'items' => [...]], …] + 'groups', + // dot-prefix for the labels, e.g. 'dashboard.nav' or 'admin.nav' + 'prefix', + // console routes are matched through AdminArea so recovery hostnames still + // highlight; the portal uses the router directly + 'console' => false, + 'footer' => null, +]) +{{-- + The sidebar, shared by both shells. + + Light, like everything else. The console used to be dark, which made it a + second product: two ideas of what a card is, two oranges, two status + palettes. What actually differs between an operator and a customer is + density, and that lives in the tokens — not here. +--}} + + +{{-- Backdrop. Tapping it closes the drawer, which is the behaviour every + phone user tries first. --}} +
diff --git a/resources/views/components/shell/topbar.blade.php b/resources/views/components/shell/topbar.blade.php new file mode 100644 index 0000000..a172e5a --- /dev/null +++ b/resources/views/components/shell/topbar.blade.php @@ -0,0 +1,30 @@ +@props(['crumbs' => []]) +{{-- + The bar the content scrolls under. Translucent with a blur rather than a + solid strip: on a light ground a solid bar reads as chrome bolted on top, + and the blur is the one borrowed detail worth keeping. +--}} +
+ + + {{-- On a phone only the leaf survives: the trail is a desktop affordance, + and the intermediate crumbs push the page name off screen. --}} + + +
+ {{ $slot }} +
diff --git a/resources/views/components/ui/alert.blade.php b/resources/views/components/ui/alert.blade.php index 7d54139..2a68382 100644 --- a/resources/views/components/ui/alert.blade.php +++ b/resources/views/components/ui/alert.blade.php @@ -7,6 +7,6 @@ 'danger' => 'bg-danger-bg border-danger-border text-danger', ]; @endphp -
merge(['class' => 'rounded border px-3 py-2 text-sm '.($variants[$variant] ?? $variants['info'])]) }}> +
merge(['class' => 'rounded border px-4 py-3 text-sm '.($variants[$variant] ?? $variants['info'])]) }}> {{ $slot }}
diff --git a/resources/views/components/ui/badge.blade.php b/resources/views/components/ui/badge.blade.php index 821bca0..a66823f 100644 --- a/resources/views/components/ui/badge.blade.php +++ b/resources/views/components/ui/badge.blade.php @@ -24,7 +24,7 @@ 'info' => 'bg-info-bg border-info-border text-info', ][$tone]; @endphp -merge(['class' => 'inline-flex items-center gap-1.5 rounded-pill border px-2.5 py-0.5 text-xs font-medium '.$classes]) }}> +merge(['class' => 'inline-flex items-center gap-1.5 rounded-pill border px-2.5 py-1 text-xs font-semibold '.$classes]) }}> {{ $slot }} diff --git a/resources/views/components/ui/button.blade.php b/resources/views/components/ui/button.blade.php index af60a40..cde07a7 100644 --- a/resources/views/components/ui/button.blade.php +++ b/resources/views/components/ui/button.blade.php @@ -3,6 +3,10 @@ 'size' => 'md', 'type' => 'button', 'loading' => false, + // Given an href this renders an anchor instead. A link that looks like a + // button must still BE a link: middle-click, "open in new tab" and the + // status bar all come from the element, not from the styling. + 'href' => null, ]) @php $base = 'inline-flex items-center justify-center gap-2 rounded font-semibold transition select-none disabled:opacity-50 disabled:pointer-events-none'; @@ -19,21 +23,30 @@ 'secondary' => 'border border-line-strong bg-surface text-body hover:bg-surface-hover', 'ghost' => 'text-body hover:bg-surface-hover', 'danger' => 'bg-danger text-on-accent hover:opacity-90', + // The document primary: ink on paper, warming to the accent on hover. + // Orange is the accent, not the loudest thing on the page — on a sheet + // full of hairlines a filled orange button is the only thing anyone + // sees, and it is rarely the most important thing on the page. + 'ink' => 'bg-ink text-bg hover:bg-accent-text', ]; $classes = $base.' '.($sizes[$size] ?? $sizes['md']).' '.($variants[$variant] ?? $variants['primary']); @endphp - +@if ($href !== null) + merge(['class' => $classes]) }}>{{ $slot }} +@else + +@endif diff --git a/resources/views/components/ui/card.blade.php b/resources/views/components/ui/card.blade.php index f795217..09e0529 100644 --- a/resources/views/components/ui/card.blade.php +++ b/resources/views/components/ui/card.blade.php @@ -1,11 +1,19 @@ @props([ 'title' => null, - 'padding' => 'p-6', + 'padding' => 'p-5', ]) +{{-- + The unit every panel is built from. Depth is one warm, broad shadow — a + neutral grey drop shadow belongs to a different design language and reads + as cold against this ground. +--}}
merge(['class' => 'rounded-lg border border-line bg-surface shadow-xs']) }}> @if ($title) -
-

{{ $title }}

+ {{-- A real heading, not a small-caps label. It was set in --text-faint, + which is 2.8:1 on white: a decoration token used for something a + reader has to read. --}} +
+

{{ $title }}

@endif
diff --git a/resources/views/components/ui/input.blade.php b/resources/views/components/ui/input.blade.php index 6b87243..ba76336 100644 --- a/resources/views/components/ui/input.blade.php +++ b/resources/views/components/ui/input.blade.php @@ -10,7 +10,7 @@ $errors ??= new \Illuminate\Support\ViewErrorBag; $id = $attributes->get('id', $name); $hasError = $errors->has($name); - $field = 'block w-full rounded border bg-surface px-3 py-2 text-sm text-ink placeholder:text-faint transition ' + $field = 'block w-full rounded border bg-surface px-3.5 py-2.5 text-sm text-ink placeholder:text-faint transition ' .($hasError ? 'border-danger bg-danger-bg' : 'border-line'); @endphp
@@ -27,7 +27,7 @@ > @if ($hint && ! $hasError) -

{{ $hint }}

+

{{ $hint }}

@endif @error($name) diff --git a/resources/views/components/ui/nav-item.blade.php b/resources/views/components/ui/nav-item.blade.php index 1586e14..aa4beb8 100644 --- a/resources/views/components/ui/nav-item.blade.php +++ b/resources/views/components/ui/nav-item.blade.php @@ -6,21 +6,26 @@ @php // items-center, not baseline: the icon is a block-level svg (Tailwind's // preflight makes it one) and must sit BESIDE the label, never above it. - $base = 'flex items-center gap-3 rounded border-l-2 px-3 py-2 text-sm font-medium min-h-11'; + // R18. + $base = 'relative flex min-h-11 items-center gap-3 rounded px-3 text-sm'; - // The label is a flex row of its own so that an icon handed to the default - // slot instead of the icon slot still lands next to the text. Passing it in - // the default slot put a display:block svg into an inline span and pushed - // the label onto a second line — the sidebar entry then stood twice as tall - // as every other one. Layout must not depend on which slot was used. + // The label is a flex row of its own so an icon handed to the default slot + // instead of the icon slot still lands next to the text. Passing it in the + // default slot put a display:block svg into an inline span and pushed the + // label onto a second line — the entry then stood twice as tall as every + // other one. Layout must not depend on which slot was used. $label = 'flex min-w-0 items-center gap-3'; + + // Active is a soft pill with a 3px accent tab flush at its left edge — a + // physical "you are here" marker rather than a colour wash. The tab is a + // pseudo-element via before:, so it cannot push the label around. $state = $active - ? 'bg-accent-subtle text-accent-text border-accent' - : 'text-muted border-transparent hover:bg-surface-hover hover:text-body'; + ? 'bg-surface-hover font-semibold text-ink before:absolute before:left-0 before:top-2.5 before:bottom-2.5 before:w-[3px] before:rounded-r before:bg-accent before:content-[\'\']' + : 'font-medium text-muted hover:bg-surface-hover hover:text-ink'; @endphp @if ($disabled) - {{-- Section not built yet: render a non-interactive item, never a dead link. --}} - merge(['class' => $base.' border-transparent text-faint cursor-not-allowed']) }}> + {{-- Section not built yet: a non-interactive item, never a dead link. --}} + merge(['class' => $base.' cursor-not-allowed font-medium text-faint']) }}> @isset($icon){{ $icon }}@endisset {{ $slot }} diff --git a/resources/views/errors/layout.blade.php b/resources/views/errors/layout.blade.php index d4491bf..3dc445f 100644 --- a/resources/views/errors/layout.blade.php +++ b/resources/views/errors/layout.blade.php @@ -37,62 +37,66 @@ --}} @verbatim @endverbatim
-
+
{{ __('errors.heading') }}{{ $code }}
+ CluPilot

{{ $title }}

@if ($body)

{{ $body }}

@@ -108,7 +112,7 @@
-

CluPilot Cloud

+

CluPilot Cloud

diff --git a/resources/views/layouts/admin.blade.php b/resources/views/layouts/admin.blade.php index f924f3d..14cc0b3 100644 --- a/resources/views/layouts/admin.blade.php +++ b/resources/views/layouts/admin.blade.php @@ -1,103 +1,67 @@ - - - - - - {{ $title ?? __('admin.console') }} - @vite(['resources/css/app.css', 'resources/js/app.js']) - @livewireStyles + -{{-- theme-admin swaps every design token to the dark Tactical-Terminal set. --}} - +{{-- + theme-admin no longer swaps the palette. It used to load a complete dark set + — its own surfaces, its own orange, its own status triad — which made the + console a second product and forced every shared component to work in two + worlds. It now carries density only: smaller type, tighter rows, tighter + corners. Colour comes from one place. +--}} + + @php $release = App\Services\Deployment\Release::current(); @endphp +
- - -
+ ]], + ['label' => __('admin.nav_group.system'), 'items' => [ + ['admin.secrets', 'lock', 'secrets', 'secrets.manage'], + ['admin.settings', 'settings', 'settings', null], + ]], + ]" + />
-
- - -
- + @auth
- -
+
@csrf -
@endauth -
+ -
+
{{ $slot }}
@@ -114,13 +78,12 @@ x-transition:leave-start="opacity-100 translate-y-0" x-transition:leave-end="opacity-0 translate-y-12" role="status" aria-live="polite" - class="fixed bottom-6 left-1/2 z-[80] -translate-x-1/2 rounded-pill border border-line bg-surface-2 px-5 py-3 text-sm font-medium text-ink shadow-md" + class="fixed bottom-6 left-1/2 z-[80] -translate-x-1/2 rounded-pill bg-plate px-5 py-3 text-sm font-semibold text-white shadow-md" >
@livewire('wire-elements-modal') - @livewireScripts diff --git a/resources/views/layouts/portal-app.blade.php b/resources/views/layouts/portal-app.blade.php index 0a17bcb..e4c510c 100644 --- a/resources/views/layouts/portal-app.blade.php +++ b/resources/views/layouts/portal-app.blade.php @@ -1,16 +1,9 @@ - - - - - - {{ $title ?? config('app.name') }} - @vite(['resources/css/app.css', 'resources/js/app.js']) - @livewireStyles + - + @php $maintenanceWindows = collect(); if (auth()->check()) { @@ -21,12 +14,13 @@ } } @endphp + @if (auth()->check() && ! ($portalCustomer ?? null)) {{-- No customer behind this login (e.g. an operator account): say so, or every customer action on these pages looks like a dead button. --}} -
- - {{ __('dashboard.no_customer_title') }} +
+ + {{ __('dashboard.no_customer_title') }} {{ __('dashboard.no_customer_hint') }} @if (auth()->user()->can('console.view')) {{ __('dashboard.no_customer_cta') }} @@ -35,54 +29,34 @@ @endif
- {{-- Sidebar: fixed drawer on small screens, fixed full-height column on large (R7). --}} - - - {{-- Mobile backdrop --}} -
+
-
- - {{-- Impersonation is a MODE, not a notice: it stays visible rather - than hiding in a dropdown, because acting as someone else - without noticing is how mistakes happen. --}} + + {{-- Impersonation is a MODE, not a notice: it stays visible + rather than hiding in a dropdown, because acting as someone + else without noticing is how mistakes happen. --}} @if (session('impersonator_id')) -
- - {{ __('impersonate.banner', ['name' => auth()->user()->name]) }} +
+ + {{ __('impersonate.banner', ['name' => auth()->user()->name]) }}
@csrf
@endif -
- - {{-- Notices --}} @if ($maintenanceWindows->isNotEmpty()) -
+
-

{{ __('maintenance.notices') }}

+

{{ __('maintenance.notices') }}

@foreach ($maintenanceWindows as $mw) @php $isActive = now()->betweenIncluded($mw->starts_at, $mw->ends_at); @endphp
- +
-

{{ $mw->title }}

+

{{ $mw->title }}

{{ $isActive ? __('maintenance.banner_active', ['end' => $mw->ends_at->isoFormat('LT')]) @@ -130,15 +101,15 @@ @auth

- -
+
@csrf - @@ -146,9 +117,9 @@
@endauth -
+ -
+
{{ $slot }}
@@ -168,7 +139,7 @@ x-transition:leave-end="opacity-0 translate-y-12" role="status" aria-live="polite" - class="fixed bottom-6 left-1/2 z-[80] -translate-x-1/2 rounded-pill bg-ink px-5 py-3 text-sm font-medium text-on-accent shadow-md" + class="fixed bottom-6 left-1/2 z-[80] -translate-x-1/2 rounded-pill bg-plate px-5 py-3 text-sm font-semibold text-white shadow-md" >
diff --git a/resources/views/livewire/auth/login.blade.php b/resources/views/livewire/auth/login.blade.php index 3d1563c..d84813b 100644 --- a/resources/views/livewire/auth/login.blade.php +++ b/resources/views/livewire/auth/login.blade.php @@ -14,10 +14,10 @@
- CluPilot + CluPilot
-

{{ __('auth.login_title') }}

+

{{ __('auth.login_title') }}

{{ __('auth.login_subtitle') }}

@if (session('status')) diff --git a/resources/views/livewire/auth/register.blade.php b/resources/views/livewire/auth/register.blade.php index 883a5fb..05af8fa 100644 --- a/resources/views/livewire/auth/register.blade.php +++ b/resources/views/livewire/auth/register.blade.php @@ -14,10 +14,10 @@
- CluPilot + CluPilot
-

{{ __('auth.register_title') }}

+

{{ __('auth.register_title') }}

{{ __('auth.register_subtitle') }}

diff --git a/resources/views/livewire/dashboard.blade.php b/resources/views/livewire/dashboard.blade.php index 6ce715d..ca91c7d 100644 --- a/resources/views/livewire/dashboard.blade.php +++ b/resources/views/livewire/dashboard.blade.php @@ -1,272 +1,262 @@ -
- {{-- Header --}} -
-

{{ __('dashboard.greeting', ['name' => auth()->user()->name]) }}

- - {{ __('dashboard.system_ok') }} - -

{{ __('dashboard.as_of', ['time' => '08:42']) }}

-
+@php + use App\Support\Bytes; + use Illuminate\Support\Number; + + $locale = app()->getLocale(); + $money = fn (int $cents, string $currency) => Number::currency($cents / 100, in: $currency, locale: $locale); +@endphp +
+ {{-- Page head. The same pill the public site uses to label a section, so a + customer meets one vocabulary on both sides of the login. --}} +
+
+ + {{ __('dashboard.sheet', ['when' => $asOf->locale($locale)->isoFormat('LL, LT')]) }} + +

+ {{ $instance !== null ? __('dashboard.title_running') : __('dashboard.title_pending') }} +

+
+ + @if ($instance !== null) +
+ $instance->status === 'active', + 'border-warning-border bg-warning-bg text-warning' => $instance->status !== 'active', + ])> + + {{ __('dashboard.instance_status.'.$instance->status) }} + + @if ($domain) + + {{ __('dashboard.cloud.open') }} + + @endif +
+ @endif +
{{-- Live provisioning progress (only while a run is in flight) --}} - {{-- Traffic: the one allowance that throttles the service when it runs out, - so it gets a full-width band rather than a tile in the KPI row. --}} - @if ($traffic) - @php - $state = $traffic->state(); - $bar = ['ok' => 'bg-accent', 'warn' => 'bg-warning', 'critical' => 'bg-warning', 'exhausted' => 'bg-danger'][$state]; - $frame = [ - 'ok' => 'border-line', - 'warn' => 'border-warning-border bg-warning-bg', - 'critical' => 'border-warning-border bg-warning-bg', - 'exhausted' => 'border-danger-border bg-danger-bg', - ][$state]; - @endphp -
-
-
-

{{ __('dashboard.traffic.title') }}

- {{ __('dashboard.traffic.resets', ['days' => $traffic->daysUntilReset()]) }} -
-

- {{ \App\Support\Bytes::human($traffic->usedBytes) }} - / {{ \App\Support\Bytes::human($traffic->quotaBytes) }} + @if ($instance === null) + {{-- Said plainly rather than papered over with an empty record: a page + of dashes reads as broken, and "we are still setting it up" and + "you have not ordered yet" are different things. --}} +

+

{{ __('dashboard.no_instance_label') }}

+

{{ __('dashboard.no_instance_body') }}

+
+ {{ __('dashboard.no_instance_cta') }} + {{ __('dashboard.nav.support') }} +
+
+ @else + {{-- ── What the customer has ─────────────────────────────────────── --}} +
+ @if ($instance->quota_gb) +
+

{{ __('dashboard.master.storage') }}

+

{{ __('dashboard.master.gb', ['n' => $instance->quota_gb]) }}

+ {{-- Consumption is not metered yet. An invented "used" + figure on the sheet a customer forwards to their + auditor is the one thing this page must never do. --}} +

{{ __('dashboard.master.storage_note') }}

+
+ @endif + +
+

{{ __('dashboard.master.users') }}

+

+ {{ $seats['total'] !== null + ? __('dashboard.master.of_total', ['used' => $seats['used'], 'total' => $seats['total']]) + : $seats['used'] }}

-
+ @if ($seats['total']) +
+ {{-- R4: width is data, not decoration. --}} +
+
+ @endif + -
- {{-- R4: width is data, not decoration — the only inline style allowed. --}} -
-
- -
-

- @if ($state === 'exhausted') - {{ __('dashboard.traffic.exhausted') }} - @elseif ($state === 'ok') - {{ __('dashboard.traffic.remaining', ['amount' => \App\Support\Bytes::human($traffic->remainingBytes())]) }} - @else - {{ __('dashboard.traffic.almost', ['percent' => $traffic->percent()]) }} + @if ($traffic) + @php $state = $traffic->state(); @endphp +

+

{{ __('dashboard.traffic.title') }}

+

{{ Bytes::human($traffic->usedBytes) }}

+
+
$state === 'ok', + 'bg-warning' => $state === 'warn' || $state === 'critical', + 'bg-danger' => $state === 'exhausted', + ]) + style="width: {{ min(100, $traffic->percent()) }}%">
+
+

{{ __('dashboard.traffic.of', ['total' => Bytes::human($traffic->quotaBytes)]) }}

+ @if ($state !== 'ok') + {{-- The offer belongs next to the warning: someone + reading that they are nearly out should not have to + go looking for the way to fix it. --}} + + {{ __('dashboard.traffic.buy') }} + @endif -

- @if ($state !== 'ok') - {{-- The offer belongs next to the warning: someone reading that - they are nearly out should not have to go looking. --}} - - {{ __('dashboard.traffic.buy') }} - +
+ @endif + + @if ($location) +
+

{{ __('dashboard.master.location') }}

+

{{ $location['name'] }}

+ @if ($location['note']) +

{{ $location['note'] }}

+ @endif +
+ @endif + + +
+ {{-- ── The proof register ────────────────────────────────────── --}} +
+
+

{{ __('dashboard.proofs.label') }}

+ {{ __('dashboard.proofs.all') }} +
+ + @if ($proofs === []) +

{{ __('dashboard.proofs.empty') }}

+ @else +
+ + + @foreach ($proofs as $proof) + + + + + @endforeach + +
+ {{ __('dashboard.proofs.item.'.$proof['key']) }} + + {{ $proof['at']?->locale($locale)->isoFormat('DD.MM. HH:mm') ?? '—' }}@if ($proof['note']) · {{ $proof['note'] }}@endif + + + $proof['state'] === 'ok', + 'border-info-border bg-info-bg text-info' => $proof['state'] === 'planned', + 'border-danger-border bg-danger-bg text-danger' => $proof['state'] === 'attention', + ])> + + {{ __('dashboard.proofs.state.'.$proof['state']) }} + +
+
+ @endif +
+ +
+ {{-- ── The contract ───────────────────────────────────────── --}} +
+

{{ __('dashboard.master.label') }}

+
+
+
{{ __('dashboard.master.package') }}
+
+ {{ __('billing.plan.'.($instance->plan ?: 'team')) }} + @if ($planPrice) + + {{ __('dashboard.master.per_'.($planPrice['term'] === 'yearly' ? 'year' : 'month'), ['amount' => $money($planPrice['cents'], $planPrice['currency'])]) }} + + @endif +
+
+ @if ($contract?->performance) +
+
{{ __('dashboard.master.operation') }}
+
{{ __('billing.perf.'.$contract->performance) }}
+
+ @endif + @if ($domain) +
+
{{ __('dashboard.master.address') }}
+
{{ $domain }}
+
+ @endif +
+
+ + @if ($nextInvoice) +
+

{{ __('dashboard.invoice.label') }}

+
+ @if ($nextInvoice['addons'] > 0) + {{-- Broken out only when there is something to + break out: a "Module 0,00 €" line on every + other customer's sheet is noise. --}} +
+
{{ __('dashboard.invoice.plan') }}
+
{{ $money($nextInvoice['plan'], $nextInvoice['currency']) }}
+
+
+
{{ __('dashboard.invoice.addons') }}
+
{{ $money($nextInvoice['addons'], $nextInvoice['currency']) }}
+
+ @endif +
+
{{ __('dashboard.invoice.amount') }}
+
+ {{ $money($nextInvoice['total'], $nextInvoice['currency']) }} + {{ __('dashboard.invoice.net') }} +
+
+
+
{{ __('dashboard.invoice.due') }}
+
{{ $nextInvoice['due']?->locale($locale)->isoFormat('LL') ?? '—' }}
+
+
+ + {{ __('dashboard.invoice.all') }} + +
+ @endif + + @if ($openTasks > 0) + {{-- Only while there is something outstanding. A permanently + green checklist is furniture; one with work left on it + is a reason to open this page. --}} + @endif
-
+ + + {{-- ── The three things customers come here to do ─────────────────── --}} +
+ @foreach ([ + ['users', 'users', 'add_user'], + ['backups', 'rotate-ccw', 'restore'], + ['billing', 'plus', 'grow'], + ] as [$route, $icon, $key]) + + + + + + {{ __('dashboard.quick.'.$key.'_title') }} + {{ __('dashboard.quick.'.$key.'_body') }} + + + @endforeach +
@endif - - {{-- KPI row --}} -
- {{-- Storage ring --}} -
-
- -
-
- {{ $storagePercent }}% - {{ __('dashboard.occupied') }} -
-
-
-
-

{{ __('dashboard.kpi.storage') }}

-

{{ $storageUsed }}/ {{ $storageQuota }} GB

-

{{ __('dashboard.storage_week') }}

-
-
- - {{-- Users --}} -
-

{{ __('dashboard.kpi.users') }}

-

{{ $usersActive }}/ {{ $usersQuota }}

-

{{ __('dashboard.users_detail') }}

-
- - {{-- Last backup --}} -
-

{{ __('dashboard.kpi.backup') }}

-

- {{ $lastBackup }} -

-

{{ __('dashboard.backup_verified') }}

-
- - {{-- Availability sparkline --}} -
-

{{ __('dashboard.kpi.availability') }}

-

{{ $availability }}%

-
- -
-
-
- - {{-- Upsell --}} -
- {{-- Shimmer sweep (left → right), disabled for reduced motion --}} - - -
-

{{ __('dashboard.upsell.title', ['percent' => $storagePercent]) }}

-

{{ __('dashboard.upsell.body') }}

-
- - {{ __('dashboard.upsell.cta') }} - -
- -
- {{-- Left column --}} -
- {{-- Cloud card --}} -
- -
-

{{ __('dashboard.cloud.package') }}

{{ $cloud['package'] }}

-

{{ __('dashboard.cloud.status') }}

{{ __('dashboard.cloud.active_since', ['date' => $cloud['since']]) }}

-

{{ __('dashboard.cloud.location') }}

{{ $cloud['location'] }}

-

{{ __('dashboard.cloud.version') }}

{{ $cloud['version'] }} · {{ __('dashboard.cloud.current') }}

-
-
- - {{-- Onboarding checklist (Alpine) --}} -
-
-

{{ __('dashboard.onboarding.title') }}

- -
-
-
-
-
    - -
-
-
- - {{-- Right column --}} -
- {{-- Backups --}} -
-
-

{{ __('dashboard.backups.title') }}

- {{ __('dashboard.backups.schedule') }} -
-
    - @foreach ($backups as $b) -
  • - - {{ $b['when'] }} - {{ $b['size'] }} - {{ __('dashboard.backups.restore') }} -
  • - @endforeach -
-

- - {{ __('dashboard.backups.note') }} -

-
- - {{-- Modules --}} -
-
-

{{ __('dashboard.modules.title') }}

- {{ __('dashboard.modules.addon') }} -
-
    - @foreach ($modules as $m) -
  • - -
    -

    {{ $m['name'] }}

    -

    {{ $m['desc'] }}

    -
    - @if ($m['status'] === 'active') - {{ __('dashboard.modules.active') }} - @elseif ($m['status'] === 'available') - {{ __('dashboard.modules.add') }} - @else - {{ __('dashboard.modules.soon') }} - @endif -
  • - @endforeach -
-
- - {{-- Activity feed --}} -
-
-

{{ __('dashboard.activity') }}

- {{ __('dashboard.activity_live') }} -
-
    - @foreach ($activity as $a) -
  • - -
    -

    {{ $a['text'] }}

    -

    {{ $a['time'] }}

    -
    -
  • - @endforeach -
-
-
-
diff --git a/resources/views/status.blade.php b/resources/views/status.blade.php index 6fe0d46..7fea40c 100644 --- a/resources/views/status.blade.php +++ b/resources/views/status.blade.php @@ -8,27 +8,24 @@ - @verbatim