diff --git a/app/Livewire/Billing.php b/app/Livewire/Billing.php index 6142b61..80cdc9a 100644 --- a/app/Livewire/Billing.php +++ b/app/Livewire/Billing.php @@ -7,6 +7,7 @@ use App\Models\Customer; use App\Models\Order; use App\Models\Subscription; use App\Services\Billing\AddonCatalogue; +use App\Services\Billing\DowngradeCheck; use App\Services\Billing\PlanCatalogue; use Illuminate\Support\Facades\DB; use Livewire\Attributes\Layout; @@ -34,7 +35,7 @@ class Billing extends Component $addons = (array) config('provisioning.addons'); [$plan, $amount, $addonKey] = match ($type) { - 'upgrade' => [$key, (int) ($plans[$key]['price_cents'] ?? 0), null], + 'upgrade', 'downgrade' => [$key, (int) ($plans[$key]['price_cents'] ?? 0), null], 'storage' => [$currentPlan, (int) config('provisioning.storage_addon.price_cents', 0), null], 'traffic' => [$currentPlan, (int) config('provisioning.traffic.addon.price_cents', 0), null], 'addon' => [$currentPlan, (int) ($addons[$key]['price_cents'] ?? 0), $key], @@ -48,6 +49,13 @@ class Billing extends Component // one and charge for the privilege. 'upgrade' => isset($plans[$key]) && (int) ($plans[$key]['tier'] ?? 0) > (int) ($instance?->subscription?->tier ?? $plans[$currentPlan]['tier'] ?? 0), + // Re-checked here and not only in the view: the button can be + // hidden and the action still called — a stale tab, a second + // window, anyone with the component name. A limit enforced only in + // markup is not enforced. + 'downgrade' => isset($plans[$key]) + && (int) ($plans[$key]['tier'] ?? 0) < (int) ($instance?->subscription?->tier ?? $plans[$currentPlan]['tier'] ?? 0) + && DowngradeCheck::for($customer, $instance, $plans[$key])->allowed, 'storage' => true, // Always available: running out of traffic is exactly when someone // needs to be able to buy more, whatever plan they are on. @@ -73,11 +81,13 @@ class Billing extends Component $replaced = DB::transaction(function () use ($customer, $type, $plan, $addonKey, $amount, $datacenter) { Customer::query()->whereKey($customer->id)->lockForUpdate()->first(); - $replaced = $type === 'upgrade' + // Up and down are the same kind of change and contradict each + // other just as much, so either replaces a pending one of both. + $replaced = in_array($type, ['upgrade', 'downgrade'], true) ? Order::query() ->where('customer_id', $customer->id) ->where('status', 'pending') - ->where('type', 'upgrade') + ->whereIn('type', ['upgrade', 'downgrade']) ->delete() : 0; @@ -147,6 +157,16 @@ class Billing extends Component // customer immediately for losing resources. $currentTier = (int) ($instance?->subscription?->tier ?? $plans[$currentKey]['tier'] ?? 0); + // Smaller plans, each with the reason it cannot be taken today. Shown + // WITH the reason rather than hidden: a customer who cannot downgrade + // needs to know what to delete, and a plan that silently disappears + // reads as "not offered any more". + $downgrades = collect($plans) + ->filter(fn ($p) => (int) ($p['tier'] ?? 0) < $currentTier) + ->sortByDesc(fn ($p) => (int) ($p['tier'] ?? 0)) + ->map(fn ($p, $k) => $p + ['check' => DowngradeCheck::for($customer, $instance, $p)]) + ->all(); + $upgrades = collect($plans) ->filter(fn ($p) => (int) ($p['tier'] ?? 0) > $currentTier) ->keys()->all(); @@ -162,6 +182,7 @@ class Billing extends Component 'plans' => $plans, 'features' => collect($plans)->map(fn ($p) => $p['features'] ?? [])->all(), 'upgrades' => $upgrades, + 'downgrades' => $downgrades, 'storage' => (array) config('provisioning.storage_addon'), 'trafficAddon' => (array) config('provisioning.traffic.addon'), // Resolved once: the cart and the plan cards must not state diff --git a/app/Livewire/Cloud.php b/app/Livewire/Cloud.php index 9a9df87..d5021da 100644 --- a/app/Livewire/Cloud.php +++ b/app/Livewire/Cloud.php @@ -54,8 +54,6 @@ class Cloud extends Component 'price' => (int) round(($contract?->monthlyPriceCents() ?? 0) / 100), ]), 'location' => __('cloud.datacenter'), - 'version' => 'Nextcloud 31.0.4', - 'php' => 'PHP 8.3 · MariaDB 11.4', 'storageUsed' => $used, 'storageQuota' => $quota, 'seats' => __('billing.seats_count', ['count' => $contract?->seats ?? 0]), diff --git a/app/Livewire/Dashboard.php b/app/Livewire/Dashboard.php index b9d416f..a1f087e 100644 --- a/app/Livewire/Dashboard.php +++ b/app/Livewire/Dashboard.php @@ -3,12 +3,27 @@ namespace App\Livewire; use App\Livewire\Concerns\ResolvesCustomer; +use App\Models\Customer; +use App\Models\Datacenter; +use App\Models\Instance; +use App\Models\InstanceMetric; +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 +31,290 @@ 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, + // The measured series behind the template's ring and trend. Null + // where nothing has been sampled yet — a new instance has no + // fortnight, and drawing one at zero would tell its owner their + // data had vanished. + 'disk' => $instance !== null ? $this->disk($instance) : null, + 'availability' => $instance !== null ? InstanceMetric::availability($instance) : null, + 'availabilityTrend' => $instance !== null ? InstanceMetric::availabilitySeries($instance) : [], + 'seatBreakdown' => $this->seatBreakdown($customer), + 'trend' => $instance !== null + ? InstanceMetric::series($instance)->map(fn (InstanceMetric $m) => $m->rx_bytes + $m->tx_bytes)->all() + : [], + '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(), ]); } + + /** + * Who holds the seats, so the card can say something true underneath the + * figure rather than repeating it. + * + * @return array role => count, roles with nobody omitted + */ + private function seatBreakdown(?Customer $customer): array + { + if ($customer === null) { + return []; + } + + return Seat::query() + ->where('customer_id', $customer->id) + ->whereIn('status', ['active', 'invited']) + ->selectRaw('role, COUNT(*) as n') + ->groupBy('role') + ->pluck('n', 'role') + ->filter() + ->all(); + } + + /** + * How full the instance is, as measured — not as sold. + * + * Null until the sampler has managed a reading. The card then states the + * contractual allowance, which is true, rather than a ring at a level + * nobody measured. + * + * @return array{used: int, total: int, percent: float, week_delta: int|null}|null + */ + private function disk(Instance $instance): ?array + { + $metric = InstanceMetric::latestDisk($instance); + + if ($metric === null || ! $metric->disk_total_bytes) { + return null; + } + + // What it grew by this week, which is the line the template carries + // under the ring. Null when there is no reading a week back to compare + // against — an instance three days old has no weekly trend, and + // inventing "+0 GB" would read as "nothing happened". + $weekAgo = InstanceMetric::query() + ->where('instance_id', $instance->id) + ->whereNotNull('disk_used_bytes') + ->where('day', '<=', now()->subDays(7)->toDateString()) + ->orderByDesc('day') + ->first(); + + return [ + 'used' => $metric->disk_used_bytes, + 'total' => $metric->disk_total_bytes, + 'percent' => round($metric->disk_used_bytes / max(1, $metric->disk_total_bytes) * 100, 1), + 'week_delta' => $weekAgo !== null ? $metric->disk_used_bytes - $weekAgo->disk_used_bytes : null, + ]; + } + + /** 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: null}|null + */ + private function location(?Instance $instance): ?array + { + $code = $instance?->host?->datacenter; + + if ($code === null || $code === '') { + return null; + } + + $datacenter = Datacenter::query()->where('code', $code)->first(); + + // The country, not the site. "Falkenstein" is how an operator places an + // instance; a customer's processing record says which jurisdiction the + // data sits in, and naming the building means editing customer-facing + // copy every time the estate grows. + return [ + 'name' => $datacenter?->location ?: $code, + 'note' => null, + ]; + } + + /** + * 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/app/Livewire/Support.php b/app/Livewire/Support.php index 1652e16..ebccf30 100644 --- a/app/Livewire/Support.php +++ b/app/Livewire/Support.php @@ -2,7 +2,6 @@ namespace App\Livewire; -use Illuminate\Support\Carbon; use Livewire\Attributes\Layout; use Livewire\Component; @@ -11,15 +10,7 @@ class Support extends Component { public function render() { - $locale = app()->getLocale(); - $date = fn (string $iso) => Carbon::parse($iso)->locale($locale)->isoFormat('LL'); - return view('livewire.support', [ - 'tickets' => [ - ['subject' => __('support.ticket_migration'), 'ref' => '#4821', 'date' => $date('2026-07-22'), 'status' => 'open'], - ['subject' => __('support.ticket_training'), 'ref' => '#4790', 'date' => $date('2026-07-18'), 'status' => 'progress'], - ['subject' => __('support.ticket_smtp'), 'ref' => '#4712', 'date' => $date('2026-07-04'), 'status' => 'closed'], - ], 'faqs' => [ ['q' => __('support.faq_q1'), 'a' => __('support.faq_a1')], ['q' => __('support.faq_q2'), 'a' => __('support.faq_a2')], diff --git a/app/Livewire/Users.php b/app/Livewire/Users.php index dfc2c5c..29ae099 100644 --- a/app/Livewire/Users.php +++ b/app/Livewire/Users.php @@ -121,6 +121,38 @@ class Users extends Component } } + /** + * Pause a seat without destroying it. + * + * The action an owner actually needs when someone leaves: access stops now, + * and the record of who held it survives — which is the half a deletion + * throws away, on a product sold on being able to show who had access to + * what. + */ + public function suspend(string $uuid): void + { + $customer = $this->requireCustomer(); + + if ($customer === null) { + return; + } + + $seat = $customer->seats()->where('uuid', $uuid)->first(); + + if ($seat === null || $seat->role === 'owner') { + // The owner cannot lock themselves out of their own cloud. + $this->dispatch('notify', message: __('users.owner_locked')); + + return; + } + + $seat->update(['status' => $seat->status === 'suspended' ? 'active' : 'suspended']); + + $this->dispatch('notify', message: __( + $seat->status === 'suspended' ? 'users.suspended' : 'users.reactivated', + )); + } + public function revoke(string $uuid): void { $customer = $this->requireCustomer(); @@ -193,7 +225,13 @@ class Users extends Component $customer = $this->customer(); $seats = $customer ? $customer->seats()->orderByRaw("role = 'owner' desc")->orderBy('email')->get() : collect(); + // The owner can be neither re-invited nor revoked, so a table holding + // only the owner has nothing to act on and should not carry a column + // of empty cells. + $hasActions = $seats->contains(fn ($seat) => $seat->role !== 'owner'); + return view('livewire.users', [ + 'hasActions' => $hasActions, 'seats' => $seats, 'used' => $customer ? $this->usedSeats($customer) : 0, 'limit' => $customer ? $this->seatLimit($customer) : 0, diff --git a/app/Models/InstanceMetric.php b/app/Models/InstanceMetric.php new file mode 100644 index 0000000..584c2aa --- /dev/null +++ b/app/Models/InstanceMetric.php @@ -0,0 +1,107 @@ + 'date', + 'disk_used_bytes' => 'integer', + 'disk_total_bytes' => 'integer', + 'rx_bytes' => 'integer', + 'tx_bytes' => 'integer', + 'checks_total' => 'integer', + 'checks_ok' => 'integer', + ]; + } + + public function instance(): BelongsTo + { + return $this->belongsTo(Instance::class); + } + + /** + * The last $days days, oldest first, with gaps left as gaps. + * + * A missing day is not a zero. Filling it would draw a cliff into the chart + * on every day the sampler could not reach the host, and the customer would + * read an outage that never happened. + * + * @return Collection + */ + public static function series(Instance $instance, int $days = 14): Collection + { + return self::query() + ->where('instance_id', $instance->id) + ->where('day', '>=', Carbon::today()->subDays($days - 1)) + ->orderBy('day') + ->get(); + } + + /** + * Availability over the window, as a percentage, or null. + * + * Null when nothing was ever checked. Reporting an unmonitored instance as + * 100 % is the single most dishonest number this application could print — + * it is the figure a customer would quote to their own auditor. + */ + public static function availability(Instance $instance, int $days = 30): ?float + { + $row = self::query() + ->where('instance_id', $instance->id) + ->where('day', '>=', Carbon::today()->subDays($days - 1)) + ->selectRaw('SUM(checks_total) as total, SUM(checks_ok) as ok') + ->first(); + + $total = (int) ($row->total ?? 0); + + return $total === 0 ? null : round((int) $row->ok / $total * 100, 2); + } + + /** + * Daily availability for the trend, oldest first. + * + * Days without a single check are skipped rather than drawn at zero: our + * monitoring being down is not the customer's cloud being down. + * + * @return array + */ + public static function availabilitySeries(Instance $instance, int $days = 30): array + { + return self::query() + ->where('instance_id', $instance->id) + ->where('day', '>=', Carbon::today()->subDays($days - 1)) + ->where('checks_total', '>', 0) + ->orderBy('day') + ->get() + ->map(fn (self $m) => round($m->checks_ok / max(1, $m->checks_total) * 100, 2)) + ->all(); + } + + /** The most recent day that carries a disk reading, or null. */ + public static function latestDisk(Instance $instance): ?self + { + return self::query() + ->where('instance_id', $instance->id) + ->whereNotNull('disk_used_bytes') + ->orderByDesc('day') + ->first(); + } +} diff --git a/app/Provisioning/Jobs/CollectInstanceTraffic.php b/app/Provisioning/Jobs/CollectInstanceTraffic.php index 4bef001..f330edf 100644 --- a/app/Provisioning/Jobs/CollectInstanceTraffic.php +++ b/app/Provisioning/Jobs/CollectInstanceTraffic.php @@ -3,6 +3,7 @@ namespace App\Provisioning\Jobs; use App\Models\Instance; +use App\Models\InstanceMetric; use App\Models\InstanceTraffic; use App\Services\Proxmox\ProxmoxClient; use App\Services\Traffic\TrafficMeter; @@ -101,9 +102,14 @@ class CollectInstanceTraffic implements ShouldBeUnique, ShouldQueue // A fresh row starts from this sample: whatever the VM transferred // before we started counting this month is not ours to bill. + $rxDelta = 0; + $txDelta = 0; + if (! $row->wasRecentlyCreated) { - $row->rx_bytes += $this->delta($netin, $row->last_netin); - $row->tx_bytes += $this->delta($netout, $row->last_netout); + $rxDelta = $this->delta($netin, $row->last_netin); + $txDelta = $this->delta($netout, $row->last_netout); + $row->rx_bytes += $rxDelta; + $row->tx_bytes += $txDelta; $row->last_netin = $netin; $row->last_netout = $netout; $row->sampled_at = now(); @@ -111,6 +117,79 @@ class CollectInstanceTraffic implements ShouldBeUnique, ShouldQueue } $this->enforce($client, $instance, $row); + $this->recordDay($client, $instance, $rxDelta ?? 0, $txDelta ?? 0); + } + + /** + * Today's row for the customer panel's chart. + * + * Kept apart from the billing row on purpose: instance_traffic answers "how + * much of the allowance is gone this period", which must not be disturbed + * by anything drawn on a screen. This answers "what did the last fortnight + * look like", and a failure here must never cost someone their allowance — + * hence the try/catch around a job that has already done its real work. + */ + private function recordDay($client, Instance $instance, int $rxDelta, int $txDelta): void + { + try { + $disk = $this->diskUsage($client, $instance); + + $metric = InstanceMetric::query()->firstOrCreate( + ['instance_id' => $instance->id, 'day' => now()->toDateString()], + ); + + // Accumulated, because the sampler runs several times a day. + $metric->rx_bytes += max(0, $rxDelta); + $metric->tx_bytes += max(0, $txDelta); + + // Overwritten rather than accumulated: a fill level is a state, not + // a flow. And left untouched when the reading failed, so yesterday's + // figure stands instead of the chart dropping to zero. + if ($disk !== null) { + $metric->disk_used_bytes = $disk['used']; + $metric->disk_total_bytes = $disk['total']; + } + + $metric->save(); + } catch (Throwable $e) { + Log::warning('daily metric failed', [ + 'instance' => $instance->uuid, 'error' => $e->getMessage(), + ]); + } + } + + /** + * How full the instance's data disk is, via the guest agent. + * + * Proxmox's own `disk` figure for a VM is the allocated image, not what is + * used inside it — it would show a customer 500 GB from the first day. + * `df` in the guest is the number they recognise. + * + * @return array{used: int, total: int}|null + */ + private function diskUsage($client, Instance $instance): ?array + { + $node = $instance->host->node ?? 'pve'; + + if (! $client->guestAgentPing($node, $instance->vmid)) { + return null; + } + + // -B1 so the numbers are bytes and no locale can reinterpret them. + $result = $client->guestExec($node, $instance->vmid, 'df -B1 --output=size,used /var/www 2>/dev/null || df -B1 --output=size,used /'); + $out = trim((string) ($result['out-data'] ?? $result['output'] ?? '')); + + // Header line, then one line of two integers. Anything else is a shell + // that answered something we did not ask for, and is discarded rather + // than parsed hopefully. + $lines = preg_split('/\r?\n/', $out) ?: []; + $last = trim((string) end($lines)); + + if (! preg_match('/^(\d+)\s+(\d+)$/', $last, $m)) { + return null; + } + + return ['total' => (int) $m[1], 'used' => (int) $m[2]]; } private function releasePreviousPeriodThrottle($client, Instance $instance): void diff --git a/app/Provisioning/Jobs/SyncMonitoringStatus.php b/app/Provisioning/Jobs/SyncMonitoringStatus.php index 4eb5b29..3139a1e 100644 --- a/app/Provisioning/Jobs/SyncMonitoringStatus.php +++ b/app/Provisioning/Jobs/SyncMonitoringStatus.php @@ -2,6 +2,7 @@ namespace App\Provisioning\Jobs; +use App\Models\InstanceMetric; use App\Models\MonitoringTarget; use App\Services\Monitoring\MonitoringClient; use Illuminate\Bus\Queueable; @@ -29,6 +30,30 @@ class SyncMonitoringStatus implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + /** + * One tick of the availability figure the customer panel shows. + * + * Written per day so thirty days is thirty rows, and so a gap in our own + * monitoring shows up as a day with fewer checks rather than as downtime + * that never happened. + */ + private function countCheck(MonitoringTarget $target, ?bool $healthy): void + { + if ($healthy === null || $target->instance_id === null) { + return; + } + + $metric = InstanceMetric::query()->firstOrCreate( + ['instance_id' => $target->instance_id, 'day' => now()->toDateString()], + ); + + $metric->increment('checks_total'); + + if ($healthy) { + $metric->increment('checks_ok'); + } + } + public function handle(MonitoringClient $monitoring): void { MonitoringTarget::query() @@ -60,6 +85,12 @@ class SyncMonitoringStatus implements ShouldQueue return; } + // Counted before the early return below, so a day's denominator grows + // only when a real answer arrived. An unanswered check is not a failed + // one — folding the two together turns a monitoring outage into the + // customer's outage, on the one figure they are asked to trust. + $this->countCheck($target, $healthy); + if ($healthy === null) { // No answer. The previous verdict stays and is allowed to go stale, // which readers show as "not monitored" rather than as health. diff --git a/app/Services/Billing/DowngradeCheck.php b/app/Services/Billing/DowngradeCheck.php new file mode 100644 index 0000000..9d333f3 --- /dev/null +++ b/app/Services/Billing/DowngradeCheck.php @@ -0,0 +1,70 @@ + */ + public array $blockers, + ) {} + + /** + * @param array $target the catalogue entry being moved to + */ + public static function for(Customer $customer, ?Instance $instance, array $target): self + { + $blockers = []; + + $seats = Seat::query() + ->where('customer_id', $customer->id) + ->whereIn('status', ['active', 'invited']) + ->count(); + + $seatLimit = (int) ($target['seats'] ?? 0); + + if ($seatLimit > 0 && $seats > $seatLimit) { + $blockers[] = [ + 'key' => 'seats', + 'current' => (string) $seats, + 'limit' => (string) $seatLimit, + ]; + } + + // Storage is checked against the last real reading. Falling back to the + // contractual allowance would refuse a downgrade to anyone who has ever + // bought a large plan, however empty their instance is. + $quotaGb = (int) ($target['quota_gb'] ?? 0); + $metric = $instance !== null ? InstanceMetric::latestDisk($instance) : null; + + if ($quotaGb > 0 && $metric !== null && $metric->disk_used_bytes > $quotaGb * 1024 ** 3) { + $blockers[] = [ + 'key' => 'storage', + 'current' => Bytes::human($metric->disk_used_bytes), + 'limit' => $quotaGb.' GB', + ]; + } + + return new self($blockers === [], $blockers); + } +} diff --git a/app/Support/Navigation.php b/app/Support/Navigation.php new file mode 100644 index 0000000..7b5ea8f --- /dev/null +++ b/app/Support/Navigation.php @@ -0,0 +1,87 @@ +}> */ + public static function portal(): array + { + return [ + ['label' => null, 'items' => [ + ['dashboard', 'gauge', 'overview', null], + ['cloud', 'cloud', 'cloud', null], + ['users', 'users', 'users', null], + ['backups', 'database', 'backups', null], + ]], + ['label' => __('dashboard.nav_group.contract'), 'items' => [ + ['billing', 'box', 'billing', null], + ['invoices', 'receipt', 'invoices', null], + ['settings', 'settings', 'settings', null], + ['support', 'life-buoy', 'support', null], + ]], + ]; + } + + /** @return array}> */ + public static function console(): array + { + return [ + ['label' => null, 'items' => [ + ['admin.overview', 'gauge', 'overview', null], + ['admin.customers', 'users', 'customers', null], + ['admin.instances', 'box', 'instances', null], + ['admin.hosts', 'server', 'hosts', null], + ['admin.datacenters', 'database', 'datacenters', null], + ['admin.plans', 'tag', 'plans', 'plans.manage'], + ]], + ['label' => __('admin.nav_group.operations'), 'items' => [ + ['admin.provisioning', 'activity', 'provisioning', null], + ['admin.maintenance', 'alert-triangle', 'maintenance', null], + ['admin.vpn', 'shield', 'vpn', null], + ['admin.revenue', 'trending-up', 'revenue', null], + ]], + ['label' => __('admin.nav_group.system'), 'items' => [ + ['admin.secrets', 'lock', 'secrets', 'secrets.manage'], + ['admin.settings', 'settings', 'settings', null], + ]], + ]; + } + + /** + * The label of the entry the current request belongs to. + * + * Matched on the route rather than the path, because the console's path + * changes between host-bound and fallback mode while its route names do + * not. Null when the page is not in the navigation at all — a detail view, + * a modal — and the caller then supplies its own title. + */ + public static function currentLabel(bool $console = false): ?string + { + $prefix = $console ? 'admin.nav.' : 'dashboard.nav.'; + + foreach ($console ? self::console() : self::portal() as $group) { + foreach ($group['items'] as [$route, , $key]) { + $matches = $console + ? AdminArea::routeIs($route) + : request()->routeIs($route); + + if ($matches) { + return __($prefix.$key); + } + } + } + + return null; + } +} diff --git a/database/migrations/2026_07_27_150000_create_instance_metrics_table.php b/database/migrations/2026_07_27_150000_create_instance_metrics_table.php new file mode 100644 index 0000000..56a23da --- /dev/null +++ b/database/migrations/2026_07_27_150000_create_instance_metrics_table.php @@ -0,0 +1,48 @@ +id(); + $table->foreignId('instance_id')->constrained()->cascadeOnDelete(); + $table->date('day'); + + // Nullable, and deliberately: a sample that could not be taken must + // be distinguishable from a sample that read zero. Charting a failed + // read as 0 GB is how a panel tells a customer their data is gone. + $table->unsignedBigInteger('disk_used_bytes')->nullable(); + $table->unsignedBigInteger('disk_total_bytes')->nullable(); + $table->unsignedBigInteger('rx_bytes')->default(0); + $table->unsignedBigInteger('tx_bytes')->default(0); + $table->timestamps(); + + // The sampler runs more often than daily and accumulates into the + // current day's row; the constraint is what makes that safe under + // two workers. + $table->unique(['instance_id', 'day']); + }); + } + + public function down(): void + { + Schema::dropIfExists('instance_metrics'); + } +}; diff --git a/database/migrations/2026_07_27_160000_add_checks_to_instance_metrics.php b/database/migrations/2026_07_27_160000_add_checks_to_instance_metrics.php new file mode 100644 index 0000000..8d19116 --- /dev/null +++ b/database/migrations/2026_07_27_160000_add_checks_to_instance_metrics.php @@ -0,0 +1,32 @@ +unsignedInteger('checks_total')->default(0); + $table->unsignedInteger('checks_ok')->default(0); + }); + } + + public function down(): void + { + Schema::table('instance_metrics', function (Blueprint $table) { + $table->dropColumn(['checks_total', 'checks_ok']); + }); + } +}; diff --git a/database/seeders/DemoCustomerSeeder.php b/database/seeders/DemoCustomerSeeder.php new file mode 100644 index 0000000..c5fe913 --- /dev/null +++ b/database/seeders/DemoCustomerSeeder.php @@ -0,0 +1,209 @@ +first()?->delete()" + * + * Safe to run twice: every row is matched on a natural key first. + */ +class DemoCustomerSeeder extends Seeder +{ + private const EMAIL = 'demo@clupilot.com'; + + public function run(): void + { + // The demo account is a real login on a panel that is reachable from the + // public internet, so it never gets a password written in a source file. + // DEMO_PASSWORD if the operator set one, otherwise a fresh random one + // printed once — a weak default would sit on production forever, and + // "it is only the demo" is how the first account gets taken. + $password = (string) (env('DEMO_PASSWORD') ?: Str::password(24)); + + $user = User::firstOrCreate( + ['email' => self::EMAIL], + ['name' => 'Kanzlei Muster', 'password' => Hash::make($password)], + ); + + if ($user->wasRecentlyCreated) { + $this->command?->warn('Demo password (shown once): '.$password); + } + + $customer = Customer::firstOrCreate( + ['user_id' => $user->id], + ['name' => 'Kanzlei Muster', 'email' => self::EMAIL, 'contact_name' => 'Dr. M. Muster', 'status' => 'active'], + ); + + $datacenter = Datacenter::firstOrCreate( + ['code' => 'fsn'], + ['name' => 'Falkenstein', 'location' => 'DE', 'active' => true], + ); + + $host = Host::query()->where('datacenter', $datacenter->code)->first() + ?? Host::factory()->create(['datacenter' => $datacenter->code, 'name' => 'pve-fsn-01', 'status' => 'active']); + + $order = Order::firstOrCreate( + ['customer_id' => $customer->id, 'type' => 'plan'], + [ + 'plan' => 'team', + 'amount_cents' => 17900, + 'currency' => 'EUR', + 'datacenter' => $datacenter->code, + 'status' => 'paid', + ], + ); + + $instance = Instance::firstOrCreate( + ['customer_id' => $customer->id], + [ + 'order_id' => $order->id, + 'host_id' => $host->id, + 'vmid' => 9001, + 'plan' => 'team', + 'quota_gb' => 500, + 'disk_gb' => 500, + 'ram_mb' => 8192, + 'cores' => 4, + 'traffic_addons' => 0, + 'guest_ip' => '10.20.0.31', + 'subdomain' => 'kanzlei-muster', + 'status' => 'active', + 'cert_ok' => true, + 'route_written' => true, + ], + ); + + Subscription::firstOrCreate( + ['customer_id' => $customer->id], + [ + 'order_id' => $order->id, + 'instance_id' => $instance->id, + 'plan' => 'team', + 'tier' => 2, + 'term' => 'monthly', + 'price_cents' => 17900, + 'currency' => 'EUR', + 'quota_gb' => 500, + 'traffic_gb' => 3072, + 'seats' => 25, + 'ram_mb' => 8192, + 'cores' => 4, + 'disk_gb' => 500, + 'performance' => 'enhanced', + 'status' => 'active', + 'started_at' => now()->subMonths(4), + 'current_period_start' => now()->startOfMonth(), + 'current_period_end' => now()->addMonth()->startOfMonth(), + ], + ); + + $this->seats($customer); + $this->backups($instance); + $this->traffic($instance); + $this->metrics($instance); + + $this->command?->info('Demo customer ready: '.self::EMAIL); + } + + private function seats(Customer $customer): void + { + $people = [ + ['Dr. M. Muster', 'demo@clupilot.com', 'owner', 'active'], + ['S. Berger', 'berger@kanzlei-muster.test', 'admin', 'active'], + ['A. Wimmer', 'wimmer@kanzlei-muster.test', 'member', 'active'], + ['T. Klein', 'klein@kanzlei-muster.test', 'member', 'active'], + ['P. Hofer', 'hofer@kanzlei-muster.test', 'member', 'invited'], + ['Steuerberatung Ost', 'extern@partner.test', 'readonly', 'active'], + ]; + + foreach ($people as [$name, $email, $role, $status]) { + Seat::firstOrCreate( + ['customer_id' => $customer->id, 'email' => $email], + ['name' => $name, 'role' => $role, 'status' => $status, 'invited_at' => now()->subDays(30)], + ); + } + } + + private function backups(Instance $instance): void + { + Backup::firstOrCreate( + ['instance_id' => $instance->id, 'schedule' => 'täglich 03:00'], + ['external_job_id' => 'demo-backup', 'status' => 'ok', 'last_ok_at' => now()->setTime(3, 12)], + ); + } + + private function traffic(Instance $instance): void + { + InstanceTraffic::firstOrCreate( + ['instance_id' => $instance->id, 'period' => InstanceTraffic::currentPeriod()], + [ + 'rx_bytes' => (int) (340 * 1024 ** 3), + 'tx_bytes' => (int) (72 * 1024 ** 3), + 'last_netin' => 0, + 'last_netout' => 0, + 'sampled_at' => now(), + ], + ); + } + + /** + * Thirty days of samples: a disk that fills slowly, transfer that varies, + * and one day where two checks out of 288 failed. + * + * Deliberately not a smooth curve — a straight line is the first thing that + * gives a demo away, and a chart that never wobbles teaches nobody what a + * real one looks like. + */ + private function metrics(Instance $instance): void + { + $total = (int) (500 * 1024 ** 3); + $used = (int) (198 * 1024 ** 3); + $wobble = [1.0, 0.6, 1.4, 0.9, 1.7, 0.4, 0.2, 1.1, 1.5, 0.8]; + + foreach (range(29, 0) as $offset) { + $day = Carbon::today()->subDays($offset); + $i = 29 - $offset; + $used += (int) ($wobble[$i % 10] * 0.42 * 1024 ** 3); + + InstanceMetric::updateOrCreate( + ['instance_id' => $instance->id, 'day' => $day->toDateString()], + [ + 'disk_used_bytes' => $used, + 'disk_total_bytes' => $total, + 'rx_bytes' => (int) ((9 + $wobble[$i % 10] * 4) * 1024 ** 3), + 'tx_bytes' => (int) ((2 + $wobble[($i + 3) % 10]) * 1024 ** 3), + // 288 checks a day is one every five minutes. + 'checks_total' => 288, + 'checks_ok' => $offset === 12 ? 286 : 288, + ], + ); + } + } +} 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/billing.php b/lang/de/billing.php index ade0875..5dee999 100644 --- a/lang/de/billing.php +++ b/lang/de/billing.php @@ -1,6 +1,18 @@ 'Kleineres Paket', + 'downgrade_hint' => 'Sie können jederzeit herunterwechseln, solange Ihr Bestand in das kleinere Paket passt.', + 'downgrade_to' => 'Auf :plan wechseln', + 'downgrade_blocked' => 'Derzeit nicht möglich', + 'downgrade_blocker' => [ + 'seats' => 'Sie haben :current Benutzer, dieses Paket erlaubt :limit. Entfernen Sie Zugänge unter „Benutzer“.', + 'storage' => 'Sie belegen :current, dieses Paket bietet :limit. Löschen Sie Daten oder leeren Sie den Papierkorb.', + ], + 'per_month' => 'netto pro Monat', + 'net_reverse_charge' => 'netto — Reverse Charge', 'net_per_month' => 'netto pro Monat', 'net_once' => 'netto, einmalig', diff --git a/lang/de/cloud.php b/lang/de/cloud.php index 96d9a39..8461d82 100644 --- a/lang/de/cloud.php +++ b/lang/de/cloud.php @@ -4,8 +4,9 @@ return [ 'title' => 'Meine Cloud', 'subtitle' => 'Details und Ressourcen Ihrer Instanz.', 'plan_line' => ':plan · :price €/Monat', - 'datacenter' => 'EU · Rechenzentrum Falkenstein', + 'datacenter' => 'EU', '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..c13fec7 100644 --- a/lang/de/dashboard.php +++ b/lang/de/dashboard.php @@ -3,12 +3,104 @@ return [ 'traffic' => [ 'title' => 'Datenvolumen diesen Monat', + 'label' => 'Datenvolumen', + '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', + 'last_backup' => 'Letzte Sicherung', + 'availability' => 'Verfügbarkeit', + 'availability_note' => 'letzte 30 Tage · gemessen', + 'location' => 'Serverstandort', + 'users' => 'Benutzer', + 'address' => 'Adresse', + 'storage' => 'Speicher', + 'storage_used' => ':percent % belegt', + 'storage_week' => ':amount diese Woche', + 'storage_note' => 'vertraglich zugesichert', + 'location_note' => 'kein Drittlandtransfer', + 'gb' => ':n GB', + 'of_seats' => 'von :total Plätzen', + '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 +118,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/de/support.php b/lang/de/support.php index 54938ac..6fff1ac 100644 --- a/lang/de/support.php +++ b/lang/de/support.php @@ -9,7 +9,7 @@ return [ 'contact_hours_val' => 'Mo–Fr, 08–18 Uhr', 'contact_email' => 'E-Mail', 'contact_sla' => 'Reaktionszeit', - 'contact_sla_val' => 'Team-Paket · binnen 4 Std.', + 'contact_sla_val' => 'Antwort am selben Werktag', 'tickets' => 'Ihre Anfragen', 'status_open' => 'Offen', 'status_progress' => 'In Bearbeitung', diff --git a/lang/de/users.php b/lang/de/users.php index 04bbec8..425dbff 100644 --- a/lang/de/users.php +++ b/lang/de/users.php @@ -16,10 +16,27 @@ return [ 'role_member' => 'Mitglied', 'role_readonly' => 'Nur Lesen', + // Counted next to the seat figure, so the card says something true + // underneath the number instead of repeating it. + 'role_count' => [ + 'owner' => '{1} :count Inhaber|[2,*] :count Inhaber', + 'admin' => '{1} :count Administrator|[2,*] :count Administratoren', + 'member' => '{1} :count Mitglied|[2,*] :count Mitglieder', + 'readonly' => '{1} :count Nur Lesen|[2,*] :count Nur Lesen', + 'guest' => '{1} :count Gastzugang|[2,*] :count Gastzugänge', + ], + 'col_person' => 'Person', 'col_status' => 'Status', + 'owner_no_actions' => 'Der Inhaber kann nicht entfernt werden.', 'col_actions' => 'Aktionen', + 'suspend' => 'Sperren', + 'reactivate' => 'Entsperren', + 'suspended' => 'Zugang gesperrt.', + 'reactivated' => 'Zugang wieder freigegeben.', + 'owner_locked' => 'Der Inhaber kann sich nicht selbst sperren.', + 'status_suspended' => 'Gesperrt', 'status_active' => 'Aktiv', 'status_invited' => 'Eingeladen', 'status_revoked' => 'Entfernt', 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/billing.php b/lang/en/billing.php index 3aa73a5..3ee9184 100644 --- a/lang/en/billing.php +++ b/lang/en/billing.php @@ -1,6 +1,18 @@ 'Smaller package', + 'downgrade_hint' => 'You can move down at any time, as long as what you hold fits the smaller package.', + 'downgrade_to' => 'Move to :plan', + 'downgrade_blocked' => 'Not possible right now', + 'downgrade_blocker' => [ + 'seats' => 'You have :current users, this package allows :limit. Remove access under "Users".', + 'storage' => 'You are using :current, this package offers :limit. Delete data or empty the trash.', + ], + 'per_month' => 'net per month', + 'net_reverse_charge' => 'net — reverse charge', 'net_per_month' => 'net per month', 'net_once' => 'net, one-off', diff --git a/lang/en/cloud.php b/lang/en/cloud.php index 42ebfa1..9286254 100644 --- a/lang/en/cloud.php +++ b/lang/en/cloud.php @@ -4,8 +4,9 @@ return [ 'title' => 'My cloud', 'subtitle' => 'Details and resources of your instance.', 'plan_line' => ':plan · €:price/mo', - 'datacenter' => 'EU · Falkenstein data center', + 'datacenter' => 'EU', '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..31b438c 100644 --- a/lang/en/dashboard.php +++ b/lang/en/dashboard.php @@ -3,12 +3,104 @@ return [ 'traffic' => [ 'title' => 'Data transfer this month', + 'label' => 'Data transfer', + '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', + 'last_backup' => 'Last backup', + 'availability' => 'Availability', + 'availability_note' => 'last 30 days · measured', + 'location' => 'Server location', + 'users' => 'Users', + 'address' => 'Address', + 'storage' => 'Storage', + 'storage_used' => ':percent % used', + 'storage_week' => ':amount this week', + 'storage_note' => 'contractually guaranteed', + 'location_note' => 'no third-country transfer', + 'gb' => ':n GB', + 'of_seats' => 'of :total seats', + '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 +118,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/lang/en/support.php b/lang/en/support.php index 9c76129..cda6218 100644 --- a/lang/en/support.php +++ b/lang/en/support.php @@ -9,7 +9,7 @@ return [ 'contact_hours_val' => 'Mon–Fri, 8am–6pm', 'contact_email' => 'Email', 'contact_sla' => 'Response time', - 'contact_sla_val' => 'Team plan · within 4 hrs', + 'contact_sla_val' => 'Answer on the same working day', 'tickets' => 'Your requests', 'status_open' => 'Open', 'status_progress' => 'In progress', diff --git a/lang/en/users.php b/lang/en/users.php index 705b31a..2a94415 100644 --- a/lang/en/users.php +++ b/lang/en/users.php @@ -16,10 +16,27 @@ return [ 'role_member' => 'Member', 'role_readonly' => 'Read-only', + // Counted next to the seat figure, so the card says something true + // underneath the number instead of repeating it. + 'role_count' => [ + 'owner' => '{1} :count owner|[2,*] :count owner', + 'admin' => '{1} :count administrator|[2,*] :count administrators', + 'member' => '{1} :count member|[2,*] :count members', + 'readonly' => '{1} :count read-only|[2,*] :count read-only', + 'guest' => '{1} :count guest|[2,*] :count guests', + ], + 'col_person' => 'Person', 'col_status' => 'Status', + 'owner_no_actions' => 'The owner cannot be removed.', 'col_actions' => 'Actions', + 'suspend' => 'Suspend', + 'reactivate' => 'Reactivate', + 'suspended' => 'Access suspended.', + 'reactivated' => 'Access restored.', + 'owner_locked' => 'The owner cannot lock themselves out.', + 'status_suspended' => 'Suspended', 'status_active' => 'Active', 'status_invited' => 'Invited', 'status_revoked' => 'Removed', 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..c670c8b 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. * @@ -124,3 +81,43 @@ transition-duration: 0.001ms !important; } } + +/* ── Metric visuals ─────────────────────────────────────────────────────── + * The ring and the sparkline from the approved template. They live here + * rather than as utilities because both need keyframes and a per-element + * custom property, and Tailwind expresses neither. + * + * Every var() carries a fallback: an undefined custom property does not make + * a browser skip the declaration, it computes to `unset` — which for a + * stroke-dashoffset silently draws a full ring at every value. + */ +.metric-ring { transform: rotate(-90deg); } +.metric-ring .track { fill: none; stroke: var(--surface-hover); stroke-width: 7; } +.metric-ring .value { + fill: none; + stroke: var(--accent); + stroke-width: 7; + stroke-linecap: round; + stroke-dasharray: var(--c, 157); + stroke-dashoffset: var(--c, 157); + animation: ring-sweep 1.1s cubic-bezier(.2, .7, .2, 1) .2s forwards; +} +@keyframes ring-sweep { to { stroke-dashoffset: var(--o, 0); } } + +.spark path { fill: none; stroke-width: 1.75; stroke-linecap: round; stroke-linejoin: round; } +.spark .line { + stroke: var(--accent); + stroke-dasharray: 400; + stroke-dashoffset: 400; + animation: spark-draw 1.3s cubic-bezier(.4, .1, .2, 1) .2s forwards; +} +.spark.muted .line { stroke: var(--text-muted); } +.spark .area { fill: var(--accent); stroke: none; opacity: 0; animation: spark-fade .8s ease .9s forwards; } +@keyframes spark-draw { to { stroke-dashoffset: 0; } } +@keyframes spark-fade { to { opacity: .09; } } + +@media (prefers-reduced-motion: reduce) { + .metric-ring .value { stroke-dashoffset: var(--o, 0) !important; } + .spark .line { stroke-dashoffset: 0 !important; } + .spark .area { opacity: .09 !important; } +} diff --git a/resources/css/portal-tokens.css b/resources/css/portal-tokens.css index 690f294..a20dafa 100644 --- a/resources/css/portal-tokens.css +++ b/resources/css/portal-tokens.css @@ -1,75 +1,119 @@ -/* 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); } + +/* + * The small-caps label: the template calls it "the typographic signature, + * carried across all three surfaces". It is written once here so the size + * cannot drift apart between the panel, the console and the site — which is + * exactly how it ended up at 11px in one place and 11.5px in another. + */ +.lbl { + font-family: var(--font-mono); + font-size: 11.5px; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--muted); +} 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..16fe336 --- /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..ff414e8 100644 --- a/resources/views/components/ui/button.blade.php +++ b/resources/views/components/ui/button.blade.php @@ -3,13 +3,18 @@ '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'; + $base = 'inline-flex items-center justify-center gap-2 rounded-[10px] font-semibold leading-none transition select-none disabled:opacity-50 disabled:pointer-events-none'; $sizes = [ - 'sm' => 'px-3 py-1.5 text-sm', - 'md' => 'px-4 py-2.5 text-sm', + 'sm' => 'min-h-8 px-3 text-[13px]', + 'md' => 'min-h-10 px-[18px] text-[14px]', + 'lg' => 'min-h-[46px] px-[22px] text-[15px]', ]; // Accent buttons use the AA-safe darker fill (#c2560a) so white text passes @@ -19,21 +24,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/metric.blade.php b/resources/views/components/ui/metric.blade.php new file mode 100644 index 0000000..3a81a62 --- /dev/null +++ b/resources/views/components/ui/metric.blade.php @@ -0,0 +1,48 @@ +@props([ + 'label', + // The figure, and the unit that follows it in lighter weight — "235" and + // "/ 500 GB" read as one measurement rather than as two numbers. + 'value', + 'unit' => null, + 'foot' => null, + // A chevron to the page that explains the figure. Omitted where there is + // no such page: a chevron that goes nowhere is worse than none. + 'href' => null, +]) +{{-- + One metric card, exactly as the approved template draws it: small caps + label with an optional chevron, the figure in tabular monospace with its + unit beside it, a line of context underneath, and the visual on the right. + + The visual is a slot so the card does not have to know whether it is a + ring, a bar or a sparkline — and so a metric with nothing to draw simply + passes none instead of getting a decorative one. +--}} +
merge(['class' => 'overflow-hidden rounded-lg border border-line bg-surface px-5 py-[18px] shadow-xs']) }}> +
+ {{ $label }} + @if ($href) + + + + @endif +
+ +
+
+

+ {{ $value }} + @if ($unit){{ $unit }}@endif +

+ @if ($foot) +

{{ $foot }}

+ @endif +
+ + @isset($visual) +
{{ $visual }}
+ @endisset +
+ + {{ $slot }} +
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/components/ui/ring.blade.php b/resources/views/components/ui/ring.blade.php new file mode 100644 index 0000000..f0fcde4 --- /dev/null +++ b/resources/views/components/ui/ring.blade.php @@ -0,0 +1,14 @@ +@props(['percent' => 0, 'size' => 62]) +@php + // Circumference of r=25 in the 62-unit box. The offset is what is NOT + // filled, so a full ring is 0 and an empty one is the whole circumference. + $c = 157; + $o = (int) round($c * (1 - min(100, max(0, (float) $percent)) / 100)); +@endphp +
+ + {{ (int) round($percent) }}% +
diff --git a/resources/views/components/ui/spark.blade.php b/resources/views/components/ui/spark.blade.php new file mode 100644 index 0000000..97246b3 --- /dev/null +++ b/resources/views/components/ui/spark.blade.php @@ -0,0 +1,31 @@ +@props([ + /** @var array the series, in the order it happened */ + 'points' => [], + // muted where the figure is observed, accent where it can be acted on. + // Four accent charts in a row is how an accent stops meaning anything. + 'tone' => 'accent', + 'area' => false, +]) +@php + $values = array_values(array_filter($points, 'is_numeric')); + $min = $values ? min($values) : 0; + $max = $values ? max($values) : 0; + $span = max(0.0001, $max - $min); + $step = count($values) > 1 ? 96 / (count($values) - 1) : 96; + + $path = ''; + foreach ($values as $i => $v) { + $x = round($i * $step, 1); + $y = round(30 - (($v - $min) / $span) * 26, 1); + $path .= ($i === 0 ? 'M' : ' ').$x.' '.$y; + } +@endphp +@if ($path !== '') + +@endif 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..0741e60 100644 --- a/resources/views/layouts/admin.blade.php +++ b/resources/views/layouts/admin.blade.php @@ -1,103 +1,48 @@ - - - - - - {{ $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 -
+
+
-
- - -
- + @auth
- -
+
@csrf -
@endauth -
+ -
+
{{ $slot }}
@@ -114,13 +59,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..aa46312 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,20 @@ @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 +87,15 @@ @auth

- -
+
@csrf - @@ -146,9 +103,9 @@
@endauth -
+ -
+
{{ $slot }}
@@ -168,7 +125,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/admin/confirm-delete-datacenter.blade.php b/resources/views/livewire/admin/confirm-delete-datacenter.blade.php index c94cb06..42ea7da 100644 --- a/resources/views/livewire/admin/confirm-delete-datacenter.blade.php +++ b/resources/views/livewire/admin/confirm-delete-datacenter.blade.php @@ -1,4 +1,4 @@ -
+
@if ($hostCount > 0)
diff --git a/resources/views/livewire/admin/confirm-remove-host.blade.php b/resources/views/livewire/admin/confirm-remove-host.blade.php index ab62e05..b5a39bb 100644 --- a/resources/views/livewire/admin/confirm-remove-host.blade.php +++ b/resources/views/livewire/admin/confirm-remove-host.blade.php @@ -1,4 +1,4 @@ -
+
diff --git a/resources/views/livewire/admin/customers.blade.php b/resources/views/livewire/admin/customers.blade.php index 91e4551..4411603 100644 --- a/resources/views/livewire/admin/customers.blade.php +++ b/resources/views/livewire/admin/customers.blade.php @@ -1,15 +1,15 @@
-

{{ __('admin.nav.customers') }}

+

{{ __('admin.nav.customers') }}

{{ __('admin.customers_sub') }}

-
+
- + @@ -68,7 +68,7 @@ -
+

{{ __('admin.by_plan') }}

diff --git a/resources/views/livewire/admin/datacenters.blade.php b/resources/views/livewire/admin/datacenters.blade.php index 52064b9..78eb838 100644 --- a/resources/views/livewire/admin/datacenters.blade.php +++ b/resources/views/livewire/admin/datacenters.blade.php @@ -1,18 +1,18 @@
-

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

+

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

{{ __('datacenters.subtitle') }}

{{-- List --}} -
+
@if ($datacenters->isEmpty())

{{ __('datacenters.empty') }}

@else
{{ __('admin.col.customer') }} {{ __('admin.col.plan') }} {{ __('admin.col.mrr') }}
- + @@ -25,7 +25,7 @@
{{ __('datacenters.code') }} {{ __('datacenters.name') }} {{ __('datacenters.hosts') }}
{{ $dc->code }} {{ $dc->name }} - @if ($dc->location)· {{ $countries[$dc->location] ?? $dc->location }}@endif + @if ($dc->location)· {{ $countries[$dc->location] ?? $dc->location }}@endif {{ $dc->hosts_count }} @@ -58,7 +58,7 @@ {{-- Add form --}} - +

{{ __('datacenters.add') }}

diff --git a/resources/views/livewire/admin/edit-datacenter.blade.php b/resources/views/livewire/admin/edit-datacenter.blade.php index 5165335..9b1e949 100644 --- a/resources/views/livewire/admin/edit-datacenter.blade.php +++ b/resources/views/livewire/admin/edit-datacenter.blade.php @@ -1,4 +1,4 @@ -
+

{{ __('datacenters.edit_title') }}

{{ $code }} diff --git a/resources/views/livewire/admin/host-create.blade.php b/resources/views/livewire/admin/host-create.blade.php index 62c6737..bf35e82 100644 --- a/resources/views/livewire/admin/host-create.blade.php +++ b/resources/views/livewire/admin/host-create.blade.php @@ -3,11 +3,11 @@ {{ __('hosts.back') }} -

{{ __('hosts.create_title') }}

+

{{ __('hosts.create_title') }}

{{ __('hosts.create_sub') }}

- +
diff --git a/resources/views/livewire/admin/host-detail.blade.php b/resources/views/livewire/admin/host-detail.blade.php index 63bf2bd..31a0d96 100644 --- a/resources/views/livewire/admin/host-detail.blade.php +++ b/resources/views/livewire/admin/host-detail.blade.php @@ -9,7 +9,7 @@ {{ __('hosts.back') }}
-

{{ $host->name }}

+

{{ $host->name }}

{{ __('hosts.status.'.$host->status) }}

{{ $host->public_ip }} · {{ $host->datacenter }}

@@ -41,8 +41,8 @@ @endphp
{{-- Health --}} -
-

{{ __('hosts.detail.health') }}

+
+

{{ __('hosts.detail.health') }}

{{ __('hosts.health.'.$health) }} @@ -53,8 +53,8 @@

{{-- Storage capacity --}} -
-

{{ __('hosts.detail.storage') }}

+
+

{{ __('hosts.detail.storage') }}

@if ($host->total_gb)

{{ $host->availableGb() }} / {{ $host->freeGb() }} GB {{ __('hosts.free') }}

@@ -73,8 +73,8 @@
{{-- Compute --}} -
-

{{ __('hosts.detail.compute') }}

+
+

{{ __('hosts.detail.compute') }}

{{ $host->cpu_cores ?? '—' }}

@@ -96,7 +96,7 @@ ['meta.version', $host->pve_version ?? __('hosts.unknown')], ['detail.instances', (string) $instances->count()], ] as [$key, $value]) -
+

{{ __('hosts.'.$key) }}

{{ $value }}

@@ -104,7 +104,7 @@
{{-- Hosted instances --}} -
+

{{ __('hosts.detail.hosted') }}

@if ($instances->isEmpty())

{{ __('hosts.detail.no_instances') }}

@@ -128,7 +128,7 @@
@if ($run && $run->status === 'failed') -
+

{{ __('hosts.error_title') }}

@@ -138,7 +138,7 @@ @endif
-
+

{{ __('hosts.progress') }}

@if ($run) @@ -147,7 +147,7 @@ @endif
-
+

{{ __('hosts.events') }}

@if ($events->isEmpty())

{{ __('hosts.no_run') }}

@@ -161,7 +161,7 @@

{{ __('hosts.step.'.$event->step) }}

-

+

{{ __('hosts.outcome.'.$event->outcome) }} · {{ __('hosts.attempt') }} {{ $event->attempt }} @if ($event->message) · {{ $event->message }} @endif diff --git a/resources/views/livewire/admin/hosts.blade.php b/resources/views/livewire/admin/hosts.blade.php index 3a8aef1..01f2112 100644 --- a/resources/views/livewire/admin/hosts.blade.php +++ b/resources/views/livewire/admin/hosts.blade.php @@ -1,7 +1,7 @@

-

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

+

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

{{ __('hosts.subtitle') }}

@@ -13,7 +13,7 @@
+ class="w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-ink placeholder:text-muted" />
- + @@ -86,7 +86,7 @@ {{ $host->availableGb() }} / {{ $host->freeGb() }} GB @else - {{ __('hosts.unknown') }} + {{ __('hosts.unknown') }} @endif diff --git a/resources/views/livewire/admin/instance-admin-access.blade.php b/resources/views/livewire/admin/instance-admin-access.blade.php index 9370fb5..51ffbb2 100644 --- a/resources/views/livewire/admin/instance-admin-access.blade.php +++ b/resources/views/livewire/admin/instance-admin-access.blade.php @@ -20,7 +20,7 @@ -

{{ __('instances.admin_once') }}

+

{{ __('instances.admin_once') }}

{{ __('common.close') }} diff --git a/resources/views/livewire/admin/instances.blade.php b/resources/views/livewire/admin/instances.blade.php index 68a1d41..46ef0fc 100644 --- a/resources/views/livewire/admin/instances.blade.php +++ b/resources/views/livewire/admin/instances.blade.php @@ -1,14 +1,14 @@
-

{{ __('admin.nav.instances') }}

+

{{ __('admin.nav.instances') }}

{{ __('admin.instances_sub') }}

-
+
{{ __('hosts.col.host') }} {{ __('hosts.col.datacenter') }} {{ __('hosts.col.health') }} {{ __('hosts.status.'.$host->status) }}
- + @@ -30,7 +30,7 @@ @empty - + @endforelse
{{ __('admin.col.address') }} {{ __('admin.col.customer') }} {{ __('admin.col.host') }}{{ $r['status_label'] }}
{{ __('admin.instances_empty') }}
{{ __('admin.instances_empty') }}
diff --git a/resources/views/livewire/admin/maintenance.blade.php b/resources/views/livewire/admin/maintenance.blade.php index 29cbf8d..ca1e3c5 100644 --- a/resources/views/livewire/admin/maintenance.blade.php +++ b/resources/views/livewire/admin/maintenance.blade.php @@ -1,19 +1,19 @@
-

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

+

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

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

{{-- Windows list --}} -
+
@if ($windows->isEmpty())

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

@else
- + @@ -29,7 +29,7 @@ @@ -69,7 +69,7 @@ {{-- Create form --}} - +

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

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

@@ -81,14 +81,14 @@
-

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

+ class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-ink placeholder:text-muted focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"> +

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

+ class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-ink placeholder:text-muted focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30">
@@ -97,7 +97,7 @@
- +
@@ -109,7 +109,7 @@ {{-- Typing an end time by hand is the fiddliest part of this form; these set it from the start time. --}}
- {{ __('maintenance.duration') }} + {{ __('maintenance.duration') }} @foreach ([30, 60, 120, 240] as $minutes)
@error('hostIds')

{{ $message }}

@enderror
@@ -139,7 +139,7 @@ @if ($dcHosts->isNotEmpty())
-

+

{{ $dc->name }} @if ($dcSelected) {{ $dcSelected }}/{{ $dcHosts->count() }} @@ -158,7 +158,7 @@ {{ $host->name }} - {{ $host->public_ip }} + {{ $host->public_ip }} @endforeach

@@ -170,7 +170,7 @@
-

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

+

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

{{ __('maintenance.save_draft') }} {{ __('maintenance.publish_notify') }}
diff --git a/resources/views/livewire/admin/overview.blade.php b/resources/views/livewire/admin/overview.blade.php index 86f5d44..8d10f64 100644 --- a/resources/views/livewire/admin/overview.blade.php +++ b/resources/views/livewire/admin/overview.blade.php @@ -1,6 +1,6 @@
-

{{ __('admin.overview_title') }}

+

{{ __('admin.overview_title') }}

{{-- Derived from the notice list below, never asserted: a green badge that is not computed is the most expensive kind of decoration. --}} @if ($noticeCount === 0) @@ -19,8 +19,8 @@ @php $kd = ['[animation-delay:40ms]', '[animation-delay:80ms]', '[animation-delay:120ms]', '[animation-delay:160ms]']; @endphp
@foreach ($kpis as $i => $kpi) -
-

{{ $kpi['label'] }}

+
+

{{ $kpi['label'] }}

{{ $kpi['value'] }}

@if (($kpi['sub'] ?? null) !== null)

{{ $kpi['sub'] }}

@@ -31,18 +31,18 @@
{{-- New instances per month --}} -
+

{{ __('admin.new_instances') }}

{{ __('admin.new_instances_sub') }}

@if ($newInstances['empty']) -

{{ __('admin.no_data_yet') }}

+

{{ __('admin.no_data_yet') }}

@else
@endif
{{-- Committed storage per host --}} -
+

{{ __('admin.host_load') }}

{{ __('admin.host_load_sub') }}

    @@ -58,7 +58,7 @@
@empty -
  • {{ __('admin.no_hosts_yet') }}
  • +
  • {{ __('admin.no_hosts_yet') }}
  • @endforelse
    @@ -66,7 +66,7 @@
    {{-- Open provisioning runs --}} -
    +

    {{ __('admin.active_runs') }}

    {{ __('admin.view_all') }} › @@ -82,13 +82,13 @@ {{ __('admin.run_status.'.$r['status']) }} @empty -
  • {{ __('admin.no_open_runs') }}
  • +
  • {{ __('admin.no_open_runs') }}
  • @endforelse
    {{-- Notices --}} -
    +

    {{ __('admin.alerts') }}

      @forelse ($notices as $a) @@ -97,7 +97,7 @@ {{ $a['text'] }} @empty -
    • {{ __('admin.no_notices') }}
    • +
    • {{ __('admin.no_notices') }}
    • @endforelse
    diff --git a/resources/views/livewire/admin/plan-versions.blade.php b/resources/views/livewire/admin/plan-versions.blade.php index 4bd5f9c..39b6424 100644 --- a/resources/views/livewire/admin/plan-versions.blade.php +++ b/resources/views/livewire/admin/plan-versions.blade.php @@ -7,7 +7,7 @@ {{ __('plans.back') }} -

    {{ $family->name }}

    +

    {{ $family->name }}

    {{ __('plans.versions_subtitle') }}

    @@ -30,7 +30,7 @@ $live = $windowOpen && $family->sales_enabled; $scheduled = $version->isPublished() && $version->available_from->greaterThan($now); @endphp -
    uuid }}" class="rounded-lg border border-line bg-surface p-5 shadow-xs {{ $live ? 'border-success-border' : '' }}">
    @@ -105,7 +105,7 @@ ['plans.f_template', $version->template_vmid ?? '—'], ] as [$label, $value])
    -
    {{ __($label) }}
    +
    {{ __($label) }}
    {{ $value }}
    @endforeach @@ -114,9 +114,9 @@
    @foreach ($version->prices->sortBy('term') as $price) - {{ __('plans.term_'.$price->term) }} + {{ __('plans.term_'.$price->term) }} {{ $eur($price->amount_cents) }} - {{ __('plans.net') }} + {{ __('plans.net') }} @endforeach
    @@ -132,14 +132,14 @@ @endif
    @empty -

    +

    {{ __('plans.no_versions') }}

    @endforelse
    {{-- New draft --}} -
    +

    {{ __('plans.draft_title') }}

    {{ __('plans.draft_body') }}

    @@ -162,7 +162,7 @@
    -

    {{ __('plans.infra') }}

    +

    {{ __('plans.infra') }}

    @@ -170,13 +170,13 @@
    -

    {{ __('plans.pricing') }}

    +

    {{ __('plans.pricing') }}

    -

    {{ __('plans.features') }}

    +

    {{ __('plans.features') }}

    @foreach ($featureKeys as $key)
    diff --git a/resources/views/livewire/admin/plans.blade.php b/resources/views/livewire/admin/plans.blade.php index 588e1dd..4bb1134 100644 --- a/resources/views/livewire/admin/plans.blade.php +++ b/resources/views/livewire/admin/plans.blade.php @@ -1,19 +1,19 @@
    -

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

    +

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

    {{ __('plans.subtitle') }}

    {{-- Plan lines --}} -
    +
    @if ($families->isEmpty())

    {{ __('plans.empty') }}

    @else
    {{ __('maintenance.col_window') }} {{ __('maintenance.col_when') }} {{ __('maintenance.col_impact') }}{{ $w['starts_at']->isoFormat('DD.MM. HH:mm') }} – {{ $w['ends_at']->isoFormat('HH:mm') }} {{ trans_choice('maintenance.impact_hosts', $w['hosts'], ['count' => $w['hosts']]) }} - · + · {{ trans_choice('maintenance.impact_customers', $w['affected'], ['count' => $w['affected']]) }} {{ __('maintenance.state_'.$w['state']) }}
    - + @@ -26,7 +26,7 @@
    {{ __('plans.plan') }} {{ __('plans.tier') }} {{ __('plans.live_version') }}
    {{ $family['name'] }} - {{ $family['key'] }} + {{ $family['key'] }} @if ($family['drafts'] > 0) {{ trans_choice('plans.drafts', $family['drafts'], ['count' => $family['drafts']]) }} @@ -38,12 +38,12 @@ @if ($family['live']) v{{ $family['live']->version }} @if ($family['next']) - + {{ __('plans.next_from', ['version' => $family['next']->version, 'date' => $family['next']->available_from->isoFormat('L')]) }} @endif @else - {{ __('plans.no_live_version') }} + {{ __('plans.no_live_version') }} @endif @@ -71,7 +71,7 @@ {{-- New plan line --}} -
    +

    {{ __('plans.new_title') }}

    {{ __('plans.new_body') }}

    diff --git a/resources/views/livewire/admin/provisioning.blade.php b/resources/views/livewire/admin/provisioning.blade.php index ef2ba1e..9ca28cf 100644 --- a/resources/views/livewire/admin/provisioning.blade.php +++ b/resources/views/livewire/admin/provisioning.blade.php @@ -1,18 +1,18 @@
    -

    {{ __('admin.nav.provisioning') }}

    +

    {{ __('admin.nav.provisioning') }}

    {{ __('admin.provisioning_sub') }}

    -
    +
    @if (count($rows) === 0)

    {{ __('admin.no_runs') }}

    @else
    - + @@ -25,17 +25,17 @@ @php $tone = ['running' => 'provisioning', 'done' => 'active', 'failed' => 'failed'][$r['state']] ?? 'info'; @endphp + {{ $r['pipeline'] }} @@ -49,7 +49,7 @@ {{ __('admin.retry') }} @else - + @endif @@ -60,9 +60,9 @@ @endif -
    +
    -

    {{ __('admin.run_detail') }}

    +

    {{ __('admin.run_detail') }}

    @if ($panel) {{ $panel['percent'] }}% @endif @@ -72,7 +72,7 @@

    {{ __('admin.no_runs') }}

    @else

    {{ $panel['subject'] }}

    -

    {{ $panel['pipeline'] }} · {{ __('admin.run_attempt', ['n' => $panel['attempt']]) }}

    +

    {{ $panel['pipeline'] }} · {{ __('admin.run_attempt', ['n' => $panel['attempt']]) }}

    {{-- Progress bar (width is the one allowed inline style, R4) --}}
    @@ -83,9 +83,9 @@
    @if ($panel['started']) -
    {{ __('admin.run_started') }}
    {{ $panel['started'] }}
    +
    {{ __('admin.run_started') }}
    {{ $panel['started'] }}
    @endif -
    {{ __('admin.run_activity') }}
    {{ $panel['activity'] }}
    +
    {{ __('admin.run_activity') }}
    {{ $panel['activity'] }}
    @if ($panel['stale'] && ! $panel['failed']) @@ -106,7 +106,7 @@
    @elseif ($panel['current'])
    -

    {{ __('admin.run_current') }}

    +

    {{ __('admin.run_current') }}

    {{ $panel['current'] }} @@ -116,7 +116,7 @@ @if ($panel['next'] && ! $panel['failed'])
    -

    {{ __('admin.run_next') }}

    +

    {{ __('admin.run_next') }}

    {{ $panel['next'] }} diff --git a/resources/views/livewire/admin/revenue.blade.php b/resources/views/livewire/admin/revenue.blade.php index e19ef69..530bdaa 100644 --- a/resources/views/livewire/admin/revenue.blade.php +++ b/resources/views/livewire/admin/revenue.blade.php @@ -1,14 +1,14 @@
    -

    {{ __('admin.nav.revenue') }}

    +

    {{ __('admin.nav.revenue') }}

    {{ __('admin.revenue_sub') }}

    @php $rd = ['[animation-delay:40ms]', '[animation-delay:80ms]', '[animation-delay:120ms]', '[animation-delay:160ms]']; @endphp
    @foreach ($kpis as $i => $kpi) -
    -

    {{ $kpi['label'] }}

    +
    +

    {{ $kpi['label'] }}

    {{ $kpi['value'] }}

    {{ $kpi['sub'] }}

    @@ -16,32 +16,32 @@
    -
    +

    {{ __('admin.mrr_by_plan') }}

    @forelse ($planCharts as $chart) {{-- One ring per currency: a doughnut sums its slices, and a total mixing euros with francs is not an amount. --}} @if (count($planCharts) > 1) -

    {{ $chart['currency'] }}

    +

    {{ $chart['currency'] }}

    @endif
    @empty -

    {{ __('admin.no_data_yet') }}

    +

    {{ __('admin.no_data_yet') }}

    @endforelse
    -
    +

    {{ __('admin.recent_payments') }}

      @forelse ($payments as $p)
    • {{ $p['customer'] }} - {{ $p['when'] }} + {{ $p['when'] }} {{ $p['amount'] }}
    • @empty -
    • {{ __('admin.no_payments_yet') }}
    • +
    • {{ __('admin.no_payments_yet') }}
    • @endforelse
    diff --git a/resources/views/livewire/admin/secrets.blade.php b/resources/views/livewire/admin/secrets.blade.php index b24a08d..5c258fd 100644 --- a/resources/views/livewire/admin/secrets.blade.php +++ b/resources/views/livewire/admin/secrets.blade.php @@ -1,6 +1,6 @@
    -

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

    +

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

    {{ __('secrets.subtitle') }}

    @@ -12,7 +12,7 @@ {{-- The second gate. Being signed in is not enough to read a payment key: the realistic threat is an unlocked machine, and a session is exactly what that hands over. --}} - +

    {{ __('secrets.locked_title') }}

    {{ __('secrets.locked_body') }}

    @@ -34,7 +34,7 @@
    @foreach ($entries as $entry) -
    +

    {{ $entry['label'] }}

    @@ -99,7 +99,7 @@ @endforeach @if ($check !== null) -
    +

    {{ __('secrets.check_title') }}

    @if (! $check['ok']) diff --git a/resources/views/livewire/admin/settings.blade.php b/resources/views/livewire/admin/settings.blade.php index 61c76db..c607afa 100644 --- a/resources/views/livewire/admin/settings.blade.php +++ b/resources/views/livewire/admin/settings.blade.php @@ -1,6 +1,6 @@
    -

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

    +

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

    {{ __('admin_settings.subtitle') }}

    @@ -9,7 +9,7 @@ {{-- Polls itself: an operator watching an update run should not have to reload the page to find out whether it finished. Fast while something is happening, slow when nothing is. --}} -
    @@ -130,7 +130,7 @@ {{-- My account --}} @if ($canManageSite) -
    +
    @@ -144,7 +144,7 @@

    {{ $sitePublic ? __('admin_settings.site_public_body') : __('admin_settings.site_hidden_body') }}

    -

    +

    {{ __('admin_settings.site_your_view', ['ip' => request()->ip()]) }} {{ $viewerOnVpn ? __('admin_settings.site_via_vpn') : __('admin_settings.site_not_vpn') }} @@ -161,7 +161,7 @@ {{-- Who may reach this console --}} @if ($canManageSite) -

    +
    @@ -175,7 +175,7 @@

    {{ $consoleRestricted ? __('admin_settings.console_locked_body') : __('admin_settings.console_open_body') }}

    -

    +

    {{ __('admin_settings.console_your_ip', ['ip' => $viewerIp]) }}

    @@ -187,15 +187,15 @@
    -

    {{ __('admin_settings.console_always') }}

    +

    {{ __('admin_settings.console_always') }}

    @foreach ($consoleVpnRanges as $range)
    {{ $range }} - {{ __('admin_settings.console_vpn_note') }} + {{ __('admin_settings.console_vpn_note') }}
    @endforeach -

    {{ __('admin_settings.console_extra') }}

    +

    {{ __('admin_settings.console_extra') }}

    @forelse ($consoleIps as $ip)
    {{ $ip }} @@ -217,7 +217,7 @@
    {{ __('admin_settings.console_add') }}
    -

    {{ __('admin_settings.console_ip_hint') }}

    +

    {{ __('admin_settings.console_ip_hint') }}

    @@ -226,7 +226,7 @@ {{-- Own password. There was no way to change one at all: an account created with a generated password kept it until someone opened a shell. --}} -
    +

    {{ __('admin_settings.password_title') }}

    {{ __('admin_settings.password_sub') }}

    @@ -246,13 +246,13 @@
    -
    +

    {{ __('admin_settings.account_title') }}

    -

    {{ __('admin_settings.twofa_hint') }}

    +

    {{ __('admin_settings.twofa_hint') }}

    {{ __('admin_settings.save') }}
    @@ -260,7 +260,7 @@ {{-- Staff & access (Owner-only) --}} @if ($canManageStaff) -
    +

    {{ __('admin_settings.staff_title') }}

    {{ __('admin_settings.staff_sub') }}

    @@ -293,7 +293,7 @@ {{ __('admin_settings.invite') }} -

    {{ __('admin_settings.invite_hint') }}

    +

    {{ __('admin_settings.invite_hint') }}

    @if ($invitedPassword)
    @@ -307,11 +307,11 @@ @endif {{-- Staff list --}} -
    +
    {{ __('admin.col.customer') }} {{ __('admin.col.step') }} {{ __('admin.col.attempt') }}
    {{ $r['customer'] }} - {{ $r['pipeline'] }}
    {{ $r['step'] }} - {{ $r['n'] }} + {{ $r['n'] }}
    - {{ $r['activity'] }} + {{ $r['activity'] }}
    {{ $r['attempt'] }}
    - + @@ -322,7 +322,7 @@ @@ -344,7 +344,7 @@ yourself — and an empty cell reads as a bug rather than as a rule. --}} @if ($s['self']) - + @endif @unless ($s['self']) diff --git a/resources/views/livewire/admin/vpn.blade.php b/resources/views/livewire/admin/vpn.blade.php index b188f5c..15d3e09 100644 --- a/resources/views/livewire/admin/vpn.blade.php +++ b/resources/views/livewire/admin/vpn.blade.php @@ -3,12 +3,12 @@ seconds. Traffic figures can wait the minute someone needs to copy a key. --}}
    -

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

    +

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

    {{ __('vpn.subtitle') }}

    @if ($hubEndpoint === '' || $hubPublicKey === '') -
    +

    {{ __('vpn.hub_incomplete_title') }}

    @@ -19,7 +19,7 @@ {{-- Shown once. The private key exists only in this response; a reload loses it. --}} @if ($newConfig) -
    +

    {{ __('vpn.config_ready', ['name' => $newConfigName]) }}

    @@ -61,10 +61,10 @@
    {{-- Peer list --}} -
    +

    {{ __('vpn.peers') }}

    - + {{ $lastSync ? __('vpn.last_sync', ['time' => \Illuminate\Support\Carbon::parse($lastSync)->diffForHumans()]) : __('vpn.never_synced') }}
    @@ -120,7 +120,7 @@ {{ __('vpn.you') }} @endif @elseif (! $peer->host) - {{ __('vpn.no_owner') }} + {{ __('vpn.no_owner') }} @endif
    @@ -129,7 +129,7 @@
    {{ $peer->allowed_ip }} @if ($peer->endpoint) - {{ $peer->endpoint }} + {{ $peer->endpoint }} @endif ↓ {{ \App\Support\Bytes::human($peer->rx_bytes) }}  ↑ {{ \App\Support\Bytes::human($peer->tx_bytes) }} {{ __('vpn.handshake') }}: {{ $peer->last_handshake_at?->diffForHumans() ?? __('vpn.never') }} @@ -147,7 +147,7 @@ nothing to hand out, and saying so beats an absent button. --}} @if (! $peer->hasStoredConfig() && $peer->kind === \App\Models\VpnPeer::KIND_STAFF && $peer->user_id === auth()->id()) + class="grid size-8 cursor-help place-items-center rounded-md border border-dashed border-line text-muted"> @endif @@ -193,7 +193,7 @@
    {{-- New access --}} @if ($canManage) -
    +

    {{ __('vpn.add') }}

    @@ -231,18 +231,18 @@ @endif {{-- Hub facts an operator needs when configuring a peer by hand --}} -
    +

    {{ __('vpn.hub') }}

    -
    {{ __('vpn.endpoint') }}
    +
    {{ __('vpn.endpoint') }}
    {{ $hubEndpoint ?: '—' }}
    -
    {{ __('vpn.hub_key') }}
    +
    {{ __('vpn.hub_key') }}
    {{ $hubPublicKey ?: '—' }}
    -
    {{ __('vpn.subnet') }}
    +
    {{ __('vpn.subnet') }}
    {{ config('provisioning.wireguard.subnet') }}
    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/backups.blade.php b/resources/views/livewire/backups.blade.php index 076ee55..0af90ab 100644 --- a/resources/views/livewire/backups.blade.php +++ b/resources/views/livewire/backups.blade.php @@ -1,24 +1,24 @@
    -

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

    +

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

    {{ __('backups.subtitle') }}

    {{ __('backups.schedule') }}
    -
    +

    {{ __('backups.size_trend') }}

    -
    +
    {{ __('admin_settings.col_person') }} {{ __('admin_settings.role') }} {{ __('admin_settings.col_actions') }}

    {{ $s['name'] }} - @if ($s['self'])({{ __('admin_settings.you') }})@endif + @if ($s['self'])({{ __('admin_settings.you') }})@endif

    {{ $s['email'] }}

    - + diff --git a/resources/views/livewire/billing.blade.php b/resources/views/livewire/billing.blade.php index 73988da..da4532b 100644 --- a/resources/views/livewire/billing.blade.php +++ b/resources/views/livewire/billing.blade.php @@ -3,15 +3,15 @@ @php $loc = app()->getLocale(); $eur = fn ($c) => Number::currency($c / 100, in: 'EUR', locale: $loc); @endphp
    -

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

    +

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

    {{ __('billing.subtitle') }}

    {{-- Current plan --}} -
    +
    -

    {{ __('billing.current_plan') }}

    +

    {{ __('billing.current_plan') }}

    {{ __('billing.plan.'.$currentKey) }}

    @@ -44,7 +44,7 @@ {{-- Cart: "5 purchases pending" told nobody what they had ordered, and offered no way to change their mind. --}} @if ($pending->isNotEmpty()) -
    +

    {{ __('billing.cart.title') }}

    @@ -56,13 +56,13 @@
  • {{ $order->label() }}

    -

    +

    {{ __('billing.cart.added', ['when' => $order->created_at->isoFormat('LL')]) }}

    {{ $eur($order->amount_cents) }}

    -

    +

    {{ __('billing.cart.net') }} · {{ $order->isRecurring() ? __('billing.cart.per_month') : __('billing.cart.once') }}

    @@ -103,7 +103,7 @@ @if ($recurringGross > 0) {{-- The number that actually matters every month, kept apart from a one-off traffic top-up in the same cart. --}} -

    +

    {{ __('billing.cart.recurring_note', ['amount' => $eur($recurringGross)]) }}

    @endif @@ -118,7 +118,7 @@
    @foreach ($upgrades as $key) @php $p = $plans[$key]; @endphp -
    +

    {{ __('billing.plan.'.$key) }}

    {{ __('billing.perf.'.($p['performance'] ?? 'standard')) }} @@ -133,7 +133,7 @@ @endforeach

    {{ $eur($p['price_cents']) }} / {{ __('billing.month_short') }}

    -

    +

    {{ $tax->reverseCharge ? __('billing.net_reverse_charge') : __('billing.net_hint', ['percent' => $tax->percentLabel()]) }} @@ -147,29 +147,82 @@

    @endif + {{-- ── Downgrade ────────────────────────────────────────────────────── + Smaller plans are listed even when they cannot be taken, with the + reason in the customer's own numbers. A plan that quietly disappears + reads as "no longer offered"; a greyed-out button that does not say + what is in the way is the thing people ring about. --}} + @if ($downgrades !== []) +
    +

    {{ __('billing.downgrade') }}

    +

    {{ __('billing.downgrade_hint') }}

    + +
    + @foreach ($downgrades as $key => $plan) +
    +
    +
    +

    {{ $plan['name'] ?? $key }}

    +

    + {{ $plan['quota_gb'] ?? 0 }} GB · {{ __('billing.seats_count', ['count' => $plan['seats'] ?? 0]) }} +

    +
    +

    + {{ $eur($plan['price_cents'] ?? 0) }} + {{ __('billing.per_month') }} +

    +
    + + @if ($plan['check']->allowed) + + {{ __('billing.downgrade_to', ['plan' => $plan['name'] ?? $key]) }} + + @else +
    +

    {{ __('billing.downgrade_blocked') }}

    +
      + @foreach ($plan['check']->blockers as $blocker) +
    • + {{ __('billing.downgrade_blocker.'.$blocker['key'], [ + 'current' => $blocker['current'], + 'limit' => $blocker['limit'], + ]) }} +
    • + @endforeach +
    +
    + @endif +
    + @endforeach +
    +
    + @endif + {{-- Extra storage + Add-ons --}}
    {{-- Storage --}} -
    +

    {{ __('billing.storage_title') }}

    {{ __('billing.storage_body', ['gb' => $storage['gb'], 'price' => $eur($storage['price_cents'])]) }}

    -

    {{ __('billing.net_per_month') }}

    +

    {{ __('billing.net_per_month') }}

    {{ __('billing.storage_cta', ['gb' => $storage['gb']]) }}
    {{-- Traffic --}} -
    +

    {{ __('billing.traffic_title') }}

    {{ __('billing.traffic_body', ['gb' => $trafficAddon['gb'], 'price' => $eur($trafficAddon['price_cents'])]) }}

    -

    {{ __('billing.net_once') }}

    +

    {{ __('billing.net_once') }}

    @if ($trafficMeter)

    {{ __('billing.traffic_used', [ @@ -186,7 +239,7 @@ {{-- Add-ons --}} @foreach ($addons as $key => $addon) -

    +

    {{ __('billing.addon.'.$key.'.name') }}

    @@ -200,7 +253,7 @@ · {{ __('billing.addon_packs', ['count' => $addon['quantity']]) }} @endif

    -

    +

    {{ $tax->reverseCharge ? __('billing.net_reverse_charge') : __('billing.net_hint', ['percent' => $tax->percentLabel()]) }} @@ -221,5 +274,5 @@ @endforeach

    -

    {{ __('billing.mock_note') }}

    +

    {{ __('billing.mock_note') }}

    diff --git a/resources/views/livewire/cloud.blade.php b/resources/views/livewire/cloud.blade.php index 70741b5..1150f29 100644 --- a/resources/views/livewire/cloud.blade.php +++ b/resources/views/livewire/cloud.blade.php @@ -1,12 +1,11 @@
    -

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

    +

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

    {{ __('cloud.subtitle') }}

    -
    +
    - B

    {{ $instance['name'] }}

    {{ $instance['domain'] }} @@ -43,19 +42,17 @@ ['cloud.seats', $instance['seats']], ['cloud.performance', $instance['performance']], ['cloud.location', $instance['location']], - ['cloud.version', $instance['version'].' · '.__('cloud.current')], - ['cloud.runtime', $instance['php']], ['cloud.storage', $storageLabel], ] as [$key, $val])
    -
    {{ __($key) }}
    +
    {{ __($key) }}
    {{ $val }}
    @endforeach
    -
    +

    {{ __('cloud.storage_growth') }}

    {{ __('cloud.storage_growth_sub') }}

    diff --git a/resources/views/livewire/confirm-cancel-package.blade.php b/resources/views/livewire/confirm-cancel-package.blade.php index 4312e08..841ff0f 100644 --- a/resources/views/livewire/confirm-cancel-package.blade.php +++ b/resources/views/livewire/confirm-cancel-package.blade.php @@ -1,4 +1,4 @@ -
    +
    diff --git a/resources/views/livewire/confirm-close-account.blade.php b/resources/views/livewire/confirm-close-account.blade.php index a57fd67..dec22d8 100644 --- a/resources/views/livewire/confirm-close-account.blade.php +++ b/resources/views/livewire/confirm-close-account.blade.php @@ -1,4 +1,4 @@ -
    +
    @if ($blocked)
    diff --git a/resources/views/livewire/customer-provisioning.blade.php b/resources/views/livewire/customer-provisioning.blade.php index 16293af..6ae2efa 100644 --- a/resources/views/livewire/customer-provisioning.blade.php +++ b/resources/views/livewire/customer-provisioning.blade.php @@ -1,6 +1,6 @@
    @if ($active) -
    +

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

    diff --git a/resources/views/livewire/dashboard.blade.php b/resources/views/livewire/dashboard.blade.php index 6ce715d..6903ff3 100644 --- a/resources/views/livewire/dashboard.blade.php +++ b/resources/views/livewire/dashboard.blade.php @@ -1,272 +1,300 @@ -
    - {{-- 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 + {{-- ── The four cards, in the template's order and form ────────── + Speicher · Benutzer · Datenvolumen · Verfügbarkeit. Figure, unit + beside it, a line of context, the visual — ring, bar, sparkline. + Every number below is measured; nothing is drawn at a level nobody + read. --}} +
    + @if ($disk) + + + + @elseif ($instance->quota_gb) + + @endif -
    - {{-- R4: width is data, not decoration — the only inline style allowed. --}} -
    -
    + + @if ($seats['total']) +
    + {{-- R4: width is data, not decoration. --}} +
    +
    + @endif + @if ($seatBreakdown !== []) +

    + {{ collect($seatBreakdown) + ->map(fn (int $n, string $role) => trans_choice('users.role_count.'.$role, $n, ['count' => $n])) + ->join(' · ') }} +

    + @endif +
    -
    -

    - @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 + + @if (count($trend) > 2) + {{-- Orange because this is the metric with an action + attached: when it runs out, something can be done + about it. Observed figures stay grey. --}} + @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') }} - + + @if ($state !== 'ok') + + {{ __('dashboard.traffic.buy') }} + + @endif + + @endif + + {{-- Availability, counted from our own monitoring answers. Absent + rather than 100 % when nothing has been checked: reporting an + unmonitored instance as perfect is the single most dishonest + number this page could carry. --}} + @if ($availability !== null) + + @if (count($availabilityTrend) > 2) + + @endif + + @elseif ($location) + + @endif +
    + +
    + {{-- ── The proof register ────────────────────────────────────── --}} +
  • {{ __('backups.col_when') }} {{ __('backups.col_size') }} {{ __('backups.col_status') }}
    + + @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/livewire/invoices.blade.php b/resources/views/livewire/invoices.blade.php index 778444e..555387c 100644 --- a/resources/views/livewire/invoices.blade.php +++ b/resources/views/livewire/invoices.blade.php @@ -1,15 +1,15 @@
    -

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

    +

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

    {{ __('invoices.subtitle') }}

    -
    +
    - + @@ -33,12 +33,12 @@
    -
    -

    {{ __('invoices.next_charge') }}

    +
    +

    {{ __('invoices.next_charge') }}

    {{ $nextAmount }}

    {{ __('invoices.on_date', ['date' => $nextCharge]) }}

    -
    +

    {{ __('invoices.spend') }}

    diff --git a/resources/views/livewire/settings.blade.php b/resources/views/livewire/settings.blade.php index 32f6d8d..7ea22b9 100644 --- a/resources/views/livewire/settings.blade.php +++ b/resources/views/livewire/settings.blade.php @@ -1,11 +1,11 @@
    -

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

    +

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

    {{ __('settings.subtitle') }}

    {{-- Company / billing profile --}} - +

    {{ __('settings.company_title') }}

    @@ -28,7 +28,7 @@ {{-- Own password. There was no way to change one at all — Fortify's updatePasswords feature was switched off. --}} - +

    {{ __('admin_settings.password_title') }}

    {{ __('admin_settings.password_sub') }}

    @@ -52,7 +52,7 @@ {{-- Two-factor. Every action is re-checked server-side against a recent password confirmation — hiding the buttons stops nobody who can post to /livewire/update. --}} -
    +
    @@ -126,7 +126,7 @@ @endif
    - +

    {{ __('settings.branding_title') }}

    {{ __('settings.branding_sub') }}

    @@ -162,12 +162,12 @@ @elseif ($logoUrl) @else - {{ __('settings.brand_default') }} + {{ __('settings.brand_default') }} @endif
    -

    {{ __('settings.brand_logo_hint') }}

    +

    {{ __('settings.brand_logo_hint') }}

    @if ($logoUrl) @endif @@ -177,7 +177,7 @@
    @if ($branding['is_default'] ?? false) -

    {{ __('settings.brand_using_default') }}

    +

    {{ __('settings.brand_using_default') }}

    @endif
    @@ -186,7 +186,7 @@ {{-- Package & account lifecycle --}} -
    +

    {{ __('settings.package_title') }}

    @if ($cancellationScheduled) diff --git a/resources/views/livewire/support.blade.php b/resources/views/livewire/support.blade.php index 8e0ea96..16c7a82 100644 --- a/resources/views/livewire/support.blade.php +++ b/resources/views/livewire/support.blade.php @@ -1,7 +1,7 @@
    -

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

    +

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

    {{ __('support.subtitle') }}

    @@ -17,10 +17,10 @@ ['external-link', __('support.contact_email'), 'support@clupilot.com'], ['shield-check', __('support.contact_sla'), __('support.contact_sla_val')], ] as $i => [$icon, $label, $val]) -
    +
    -

    {{ $label }}

    +

    {{ $label }}

    {{ $val }}

    @@ -28,29 +28,7 @@
    {{-- Tickets --}} -
    -

    {{ __('support.tickets') }}

    -
    -
    {{ __('invoices.col_no') }} {{ __('invoices.col_date') }} {{ __('invoices.col_amount') }}
    - - @foreach ($tickets as $t) - - - - - @endforeach - -
    -

    {{ $t['subject'] }}

    -

    {{ $t['ref'] }} · {{ $t['date'] }}

    -
    - {{ __('support.status_'.$t['status']) }} -
    -
    -
    - - {{-- FAQ accordion --}} -
    +

    {{ __('support.faq') }}

      @foreach ($faqs as $i => $f) diff --git a/resources/views/livewire/users.blade.php b/resources/views/livewire/users.blade.php index 0ccd533..3d7e2c6 100644 --- a/resources/views/livewire/users.blade.php +++ b/resources/views/livewire/users.blade.php @@ -1,18 +1,18 @@
      -

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

      +

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

      {{ __('users.subtitle') }}

      {{ $used }} / {{ $limit }}

      {{ __('users.seats_used') }}

      -

      {{ __('users.seats_note') }}

      +

      {{ __('users.seats_note') }}

      {{-- Invite --}} -
      +
      @@ -36,15 +36,17 @@ {{-- Seats --}} -
      +
      - + - + @if ($hasActions) + + @endif @@ -69,19 +71,36 @@ @php $sb = ['active' => 'active', 'invited' => 'provisioning', 'revoked' => 'suspended'][$seat->status] ?? 'info'; @endphp {{ __('users.status_'.$seat->status) }} + @if ($hasActions) + @endif @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
      {{ __('users.col_person') }} {{ __('users.role') }} {{ __('users.col_status') }}{{ __('users.col_actions') }}{{ __('users.col_actions') }}
      + @if ($seat->role === 'owner') + + @endif @if ($seat->status === 'invited') @endif @if ($seat->role !== 'owner') - + @endif