Merge the panel design conversion into main
tests / pest (push) Successful in 12m28s Details
tests / assets (push) Successful in 22s Details
tests / release (push) Successful in 4s Details

feat/mailboxes tested-20260727-1530-186c2e4
nexxo 2026-07-27 17:10:35 +02:00
commit 186c2e46d1
83 changed files with 2676 additions and 1031 deletions

View File

@ -7,6 +7,7 @@ use App\Models\Customer;
use App\Models\Order; use App\Models\Order;
use App\Models\Subscription; use App\Models\Subscription;
use App\Services\Billing\AddonCatalogue; use App\Services\Billing\AddonCatalogue;
use App\Services\Billing\DowngradeCheck;
use App\Services\Billing\PlanCatalogue; use App\Services\Billing\PlanCatalogue;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Layout; use Livewire\Attributes\Layout;
@ -34,7 +35,7 @@ class Billing extends Component
$addons = (array) config('provisioning.addons'); $addons = (array) config('provisioning.addons');
[$plan, $amount, $addonKey] = match ($type) { [$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], 'storage' => [$currentPlan, (int) config('provisioning.storage_addon.price_cents', 0), null],
'traffic' => [$currentPlan, (int) config('provisioning.traffic.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], 'addon' => [$currentPlan, (int) ($addons[$key]['price_cents'] ?? 0), $key],
@ -48,6 +49,13 @@ class Billing extends Component
// one and charge for the privilege. // one and charge for the privilege.
'upgrade' => isset($plans[$key]) 'upgrade' => isset($plans[$key])
&& (int) ($plans[$key]['tier'] ?? 0) > (int) ($instance?->subscription?->tier ?? $plans[$currentPlan]['tier'] ?? 0), && (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, 'storage' => true,
// Always available: running out of traffic is exactly when someone // Always available: running out of traffic is exactly when someone
// needs to be able to buy more, whatever plan they are on. // 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) { $replaced = DB::transaction(function () use ($customer, $type, $plan, $addonKey, $amount, $datacenter) {
Customer::query()->whereKey($customer->id)->lockForUpdate()->first(); 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() ? Order::query()
->where('customer_id', $customer->id) ->where('customer_id', $customer->id)
->where('status', 'pending') ->where('status', 'pending')
->where('type', 'upgrade') ->whereIn('type', ['upgrade', 'downgrade'])
->delete() ->delete()
: 0; : 0;
@ -147,6 +157,16 @@ class Billing extends Component
// customer immediately for losing resources. // customer immediately for losing resources.
$currentTier = (int) ($instance?->subscription?->tier ?? $plans[$currentKey]['tier'] ?? 0); $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) $upgrades = collect($plans)
->filter(fn ($p) => (int) ($p['tier'] ?? 0) > $currentTier) ->filter(fn ($p) => (int) ($p['tier'] ?? 0) > $currentTier)
->keys()->all(); ->keys()->all();
@ -162,6 +182,7 @@ class Billing extends Component
'plans' => $plans, 'plans' => $plans,
'features' => collect($plans)->map(fn ($p) => $p['features'] ?? [])->all(), 'features' => collect($plans)->map(fn ($p) => $p['features'] ?? [])->all(),
'upgrades' => $upgrades, 'upgrades' => $upgrades,
'downgrades' => $downgrades,
'storage' => (array) config('provisioning.storage_addon'), 'storage' => (array) config('provisioning.storage_addon'),
'trafficAddon' => (array) config('provisioning.traffic.addon'), 'trafficAddon' => (array) config('provisioning.traffic.addon'),
// Resolved once: the cart and the plan cards must not state // Resolved once: the cart and the plan cards must not state

View File

@ -54,8 +54,6 @@ class Cloud extends Component
'price' => (int) round(($contract?->monthlyPriceCents() ?? 0) / 100), 'price' => (int) round(($contract?->monthlyPriceCents() ?? 0) / 100),
]), ]),
'location' => __('cloud.datacenter'), 'location' => __('cloud.datacenter'),
'version' => 'Nextcloud 31.0.4',
'php' => 'PHP 8.3 · MariaDB 11.4',
'storageUsed' => $used, 'storageUsed' => $used,
'storageQuota' => $quota, 'storageQuota' => $quota,
'seats' => __('billing.seats_count', ['count' => $contract?->seats ?? 0]), 'seats' => __('billing.seats_count', ['count' => $contract?->seats ?? 0]),

View File

@ -3,12 +3,27 @@
namespace App\Livewire; namespace App\Livewire;
use App\Livewire\Concerns\ResolvesCustomer; 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 App\Services\Traffic\TrafficMeter;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Number;
use Livewire\Attributes\Layout; use Livewire\Attributes\Layout;
use Livewire\Component; 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')] #[Layout('layouts.portal-app')]
class Dashboard extends Component class Dashboard extends Component
{ {
@ -16,118 +31,290 @@ class Dashboard extends Component
public function render() public function render()
{ {
// Real numbers where they exist: traffic is metered, unlike the $customer = $this->customer();
// 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;
// Locale-aware date / number formatting for the fixture values below. // The instance that is actually in service. A cancelled one still has
$locale = app()->getLocale(); // rows, and would otherwise keep filling this page after it is gone.
$date = fn (string $iso, string $fmt) => Carbon::parse($iso)->locale($locale)->isoFormat($fmt); $instance = $customer?->instances()
$gb = fn (float $v) => Number::format($v, precision: 1, locale: $locale).' GB'; ->whereIn('status', ['active', 'provisioning', 'cancellation_scheduled'])
// Fixture data until the provisioning engine (separate handoff) supplies ->latest('id')
// real instances / metrics. Tenant-specific values stand in for DB rows. ->first();
$storageUsed = 235;
$storageQuota = 500;
$storagePercent = (int) round($storageUsed / max(1, $storageQuota) * 100);
// ~30 days of availability, gently trending near 100%. $contract = $instance?->subscription;
$availability = [99.95, 99.97, 99.96, 99.98, 99.99, 99.97, 99.98, 99.99, 99.99, 99.98, $maintenance = MaintenanceWindow::forInstance($instance)->first();
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];
return view('livewire.dashboard', [ return view('livewire.dashboard', [
'traffic' => $traffic, 'customer' => $customer,
'storageUsed' => $storageUsed, 'instance' => $instance,
'storageQuota' => $storageQuota, 'contract' => $contract,
'storagePercent' => $storagePercent, 'domain' => $this->domain($instance),
'usersActive' => 8, 'location' => $this->location($instance),
'usersQuota' => 20, 'seats' => $this->seats($customer, $contract),
'lastBackup' => '03:12', 'traffic' => $instance !== null ? TrafficMeter::for($instance) : null,
'availability' => Number::format(99.98, precision: 2, locale: $locale), // The measured series behind the template's ring and trend. Null
// where nothing has been sampled yet — a new instance has no
'storageChart' => [ // fortnight, and drawing one at zero would tell its owner their
'type' => 'doughnut', // data had vanished.
'data' => [ 'disk' => $instance !== null ? $this->disk($instance) : null,
'datasets' => [[ 'availability' => $instance !== null ? InstanceMetric::availability($instance) : null,
'data' => [$storagePercent, 100 - $storagePercent], 'availabilityTrend' => $instance !== null ? InstanceMetric::availabilitySeries($instance) : [],
'backgroundColor' => ['token:accent', 'token:surface-2'], 'seatBreakdown' => $this->seatBreakdown($customer),
'borderWidth' => 0, 'trend' => $instance !== null
]], ? InstanceMetric::series($instance)->map(fn (InstanceMetric $m) => $m->rx_bytes + $m->tx_bytes)->all()
], : [],
'options' => [ 'proofs' => $this->proofs($instance, $maintenance),
'cutout' => '76%', 'openTasks' => $instance?->onboardingTasks()->where('done', false)->count() ?? 0,
'plugins' => [ // What the plan costs is true whether or not another invoice is
'legend' => ['display' => false], // coming — a customer who has given notice still pays until the
'tooltip' => ['enabled' => false], // 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,
'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'],
], ],
'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<string, int> 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<int, array{key: string, at: \Illuminate\Support\Carbon|null, state: string, note: string|null}>
*/
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,
];
}
} }

View File

@ -2,7 +2,6 @@
namespace App\Livewire; namespace App\Livewire;
use Illuminate\Support\Carbon;
use Livewire\Attributes\Layout; use Livewire\Attributes\Layout;
use Livewire\Component; use Livewire\Component;
@ -11,15 +10,7 @@ class Support extends Component
{ {
public function render() public function render()
{ {
$locale = app()->getLocale();
$date = fn (string $iso) => Carbon::parse($iso)->locale($locale)->isoFormat('LL');
return view('livewire.support', [ 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' => [ 'faqs' => [
['q' => __('support.faq_q1'), 'a' => __('support.faq_a1')], ['q' => __('support.faq_q1'), 'a' => __('support.faq_a1')],
['q' => __('support.faq_q2'), 'a' => __('support.faq_a2')], ['q' => __('support.faq_q2'), 'a' => __('support.faq_a2')],

View File

@ -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 public function revoke(string $uuid): void
{ {
$customer = $this->requireCustomer(); $customer = $this->requireCustomer();
@ -193,7 +225,13 @@ class Users extends Component
$customer = $this->customer(); $customer = $this->customer();
$seats = $customer ? $customer->seats()->orderByRaw("role = 'owner' desc")->orderBy('email')->get() : collect(); $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', [ return view('livewire.users', [
'hasActions' => $hasActions,
'seats' => $seats, 'seats' => $seats,
'used' => $customer ? $this->usedSeats($customer) : 0, 'used' => $customer ? $this->usedSeats($customer) : 0,
'limit' => $customer ? $this->seatLimit($customer) : 0, 'limit' => $customer ? $this->seatLimit($customer) : 0,

View File

@ -0,0 +1,107 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
/**
* A day in the life of one instance: how full it was, and what it moved.
*
* Read by the customer panel to draw the storage ring and the transfer trend.
* Written by CollectInstanceTraffic, which already visits every active
* instance a second scheduler entry hitting the same Proxmox API would only
* double the load and the failure modes.
*/
class InstanceMetric extends Model
{
protected $fillable = ['instance_id', 'day', 'disk_used_bytes', 'disk_total_bytes', 'rx_bytes', 'tx_bytes', 'checks_total', 'checks_ok'];
protected function casts(): array
{
return [
'day' => '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<int, self>
*/
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<int, float>
*/
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();
}
}

View File

@ -3,6 +3,7 @@
namespace App\Provisioning\Jobs; namespace App\Provisioning\Jobs;
use App\Models\Instance; use App\Models\Instance;
use App\Models\InstanceMetric;
use App\Models\InstanceTraffic; use App\Models\InstanceTraffic;
use App\Services\Proxmox\ProxmoxClient; use App\Services\Proxmox\ProxmoxClient;
use App\Services\Traffic\TrafficMeter; 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 // A fresh row starts from this sample: whatever the VM transferred
// before we started counting this month is not ours to bill. // before we started counting this month is not ours to bill.
$rxDelta = 0;
$txDelta = 0;
if (! $row->wasRecentlyCreated) { if (! $row->wasRecentlyCreated) {
$row->rx_bytes += $this->delta($netin, $row->last_netin); $rxDelta = $this->delta($netin, $row->last_netin);
$row->tx_bytes += $this->delta($netout, $row->last_netout); $txDelta = $this->delta($netout, $row->last_netout);
$row->rx_bytes += $rxDelta;
$row->tx_bytes += $txDelta;
$row->last_netin = $netin; $row->last_netin = $netin;
$row->last_netout = $netout; $row->last_netout = $netout;
$row->sampled_at = now(); $row->sampled_at = now();
@ -111,6 +117,79 @@ class CollectInstanceTraffic implements ShouldBeUnique, ShouldQueue
} }
$this->enforce($client, $instance, $row); $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 private function releasePreviousPeriodThrottle($client, Instance $instance): void

View File

@ -2,6 +2,7 @@
namespace App\Provisioning\Jobs; namespace App\Provisioning\Jobs;
use App\Models\InstanceMetric;
use App\Models\MonitoringTarget; use App\Models\MonitoringTarget;
use App\Services\Monitoring\MonitoringClient; use App\Services\Monitoring\MonitoringClient;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
@ -29,6 +30,30 @@ class SyncMonitoringStatus implements ShouldQueue
{ {
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; 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 public function handle(MonitoringClient $monitoring): void
{ {
MonitoringTarget::query() MonitoringTarget::query()
@ -60,6 +85,12 @@ class SyncMonitoringStatus implements ShouldQueue
return; 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) { if ($healthy === null) {
// No answer. The previous verdict stays and is allowed to go stale, // No answer. The previous verdict stays and is allowed to go stale,
// which readers show as "not monitored" rather than as health. // which readers show as "not monitored" rather than as health.

View File

@ -0,0 +1,70 @@
<?php
namespace App\Services\Billing;
use App\Models\Customer;
use App\Models\Instance;
use App\Models\InstanceMetric;
use App\Models\Seat;
use App\Support\Bytes;
/**
* Whether a customer may move down to a smaller plan, and if not, why.
*
* A downgrade is not the mirror of an upgrade. Going up always fits; going
* down can ask an instance to hold more than the target plan allows, and the
* failure has to be explained in the customer's own numbers "you have 31
* users, Start allows 10" — rather than as a disabled button. A greyed-out
* control that does not say what is in the way is the thing people ring about.
*
* Checked against what is MEASURED, not what was sold: a customer sitting on
* 480 GB of a 500 GB plan cannot move to a 200 GB one, whatever their contract
* says the allowance is.
*/
final readonly class DowngradeCheck
{
private function __construct(
public bool $allowed,
/** @var array<int, array{key: string, current: string, limit: string}> */
public array $blockers,
) {}
/**
* @param array<string, mixed> $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);
}
}

View File

@ -0,0 +1,87 @@
<?php
namespace App\Support;
/**
* The navigation of both shells, in one place.
*
* The sidebar and the breadcrumb describe the same structure, and when each
* built its own copy the breadcrumb quietly said "Übersicht" on every page
* the layout had no way to know which entry was current, so it fell back to the
* first one. One definition, two readers.
*
* Each entry is [route name, icon, translation key, capability or null].
*/
final class Navigation
{
/** @return array<int, array{label: ?string, items: array<int, array{0:string,1:string,2:string,3:?string}>}> */
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<int, array{label: ?string, items: array<int, array{0:string,1:string,2:string,3:?string}>}> */
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;
}
}

View File

@ -0,0 +1,48 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* One row per instance per day.
*
* instance_traffic keeps a single row per BILLING period, which is what the
* allowance needs but it means there is no series to draw, and the customer
* panel could only ever show a number. Storage consumption was not sampled at
* all, so the panel stated the contractual allowance and nothing about what is
* actually in use.
*
* A day is the right grain: fine enough for a fortnight of trend, coarse enough
* that a year of an instance is 365 rows.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('instance_metrics', function (Blueprint $table) {
$table->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');
}
};

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Availability, counted from our own checks.
*
* Uptime Kuma knows the figure but the REST bridge in front of it only answers
* "healthy, yes or no" and waiting for the bridge to grow an endpoint would
* leave the panel without the number indefinitely. The monitoring sync already
* asks that question on a schedule; counting the answers gives a percentage
* that is ours, explainable, and available today.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('instance_metrics', function (Blueprint $table) {
$table->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']);
});
}
};

View File

@ -0,0 +1,209 @@
<?php
namespace Database\Seeders;
use App\Models\Backup;
use App\Models\Customer;
use App\Models\Datacenter;
use App\Models\Host;
use App\Models\Instance;
use App\Models\InstanceMetric;
use App\Models\InstanceTraffic;
use App\Models\Order;
use App\Models\Seat;
use App\Models\Subscription;
use App\Models\User;
use Illuminate\Database\Seeder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/**
* One complete customer, written as real rows.
*
* The panel used to be judged against fixtures baked into the views, and every
* round of that hid a different gap: a card with no data source, a figure that
* came from a translation string, a chart drawn from an array in a Blade file.
* A real customer with real rows makes the panel tell the truth about itself
* whatever is missing shows up as missing.
*
* Everything hangs off one User, so removing the demo is one deletion:
*
* php artisan db:seed --class=DemoCustomerSeeder --force
* php artisan tinker --execute="App\Models\User::where('email','demo@clupilot.com')->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,
],
);
}
}
}

View File

@ -5,6 +5,11 @@ return [
'badge' => 'Admin', 'badge' => 'Admin',
'to_portal' => 'Zum Kundenportal', 'to_portal' => 'Zum Kundenportal',
'nav_group' => [
'operations' => 'Betrieb',
'system' => 'System',
],
'nav' => [ 'nav' => [
'overview' => 'Übersicht', 'overview' => 'Übersicht',
'customers' => 'Kunden', 'customers' => 'Kunden',

View File

@ -1,6 +1,18 @@
<?php <?php
return [ return [
// Kleinere Pakete werden auch dann gezeigt, wenn sie gerade nicht gehen —
// mit dem Grund in den Zahlen des Kunden.
'downgrade' => '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_reverse_charge' => 'netto — Reverse Charge',
'net_per_month' => 'netto pro Monat', 'net_per_month' => 'netto pro Monat',
'net_once' => 'netto, einmalig', 'net_once' => 'netto, einmalig',

View File

@ -4,8 +4,9 @@ return [
'title' => 'Meine Cloud', 'title' => 'Meine Cloud',
'subtitle' => 'Details und Ressourcen Ihrer Instanz.', 'subtitle' => 'Details und Ressourcen Ihrer Instanz.',
'plan_line' => ':plan · :price €/Monat', 'plan_line' => ':plan · :price €/Monat',
'datacenter' => 'EU · Rechenzentrum Falkenstein', 'datacenter' => 'EU',
'now' => 'jetzt', 'now' => 'jetzt',
'instance_label' => 'Instanz',
'open' => 'Cloud öffnen', 'open' => 'Cloud öffnen',
'status_active' => 'Aktiv', 'status_active' => 'Aktiv',
'status_provisioning' => 'Bereitstellung', 'status_provisioning' => 'Bereitstellung',

View File

@ -3,12 +3,104 @@
return [ return [
'traffic' => [ 'traffic' => [
'title' => 'Datenvolumen diesen Monat', 'title' => 'Datenvolumen diesen Monat',
'label' => 'Datenvolumen',
'of' => 'von :total',
'resets' => 'setzt sich in :days Tagen zurück', 'resets' => 'setzt sich in :days Tagen zurück',
'remaining' => ':amount übrig', 'remaining' => ':amount übrig',
'almost' => ':percent % verbraucht — es wird knapp.', 'almost' => ':percent % verbraucht — es wird knapp.',
'exhausted' => 'Kontingent aufgebraucht. Ihre Nextcloud läuft weiter, aber langsamer, bis Sie nachbuchen oder der Monat wechselt.', 'exhausted' => 'Kontingent aufgebraucht. Ihre Nextcloud läuft weiter, aber langsamer, bis Sie nachbuchen oder der Monat wechselt.',
'buy' => 'Volumen nachbuchen', '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', 'title' => 'Übersicht',
'subtitle' => 'Status Ihrer Cloud auf einen Blick.', 'subtitle' => 'Status Ihrer Cloud auf einen Blick.',
'greeting' => 'Guten Morgen, :name', 'greeting' => 'Guten Morgen, :name',
@ -26,6 +118,13 @@ return [
'support' => 'Support', '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', 'open_nav' => 'Navigation öffnen',
'close_nav' => 'Navigation schließen', 'close_nav' => 'Navigation schließen',
'logout' => 'Abmelden', 'logout' => 'Abmelden',

View File

@ -9,7 +9,7 @@ return [
'contact_hours_val' => 'MoFr, 0818 Uhr', 'contact_hours_val' => 'MoFr, 0818 Uhr',
'contact_email' => 'E-Mail', 'contact_email' => 'E-Mail',
'contact_sla' => 'Reaktionszeit', 'contact_sla' => 'Reaktionszeit',
'contact_sla_val' => 'Team-Paket · binnen 4 Std.', 'contact_sla_val' => 'Antwort am selben Werktag',
'tickets' => 'Ihre Anfragen', 'tickets' => 'Ihre Anfragen',
'status_open' => 'Offen', 'status_open' => 'Offen',
'status_progress' => 'In Bearbeitung', 'status_progress' => 'In Bearbeitung',

View File

@ -16,10 +16,27 @@ return [
'role_member' => 'Mitglied', 'role_member' => 'Mitglied',
'role_readonly' => 'Nur Lesen', '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_person' => 'Person',
'col_status' => 'Status', 'col_status' => 'Status',
'owner_no_actions' => 'Der Inhaber kann nicht entfernt werden.',
'col_actions' => 'Aktionen', '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_active' => 'Aktiv',
'status_invited' => 'Eingeladen', 'status_invited' => 'Eingeladen',
'status_revoked' => 'Entfernt', 'status_revoked' => 'Entfernt',

View File

@ -5,6 +5,11 @@ return [
'badge' => 'Admin', 'badge' => 'Admin',
'to_portal' => 'To customer portal', 'to_portal' => 'To customer portal',
'nav_group' => [
'operations' => 'Operations',
'system' => 'System',
],
'nav' => [ 'nav' => [
'overview' => 'Overview', 'overview' => 'Overview',
'customers' => 'Customers', 'customers' => 'Customers',

View File

@ -1,6 +1,18 @@
<?php <?php
return [ return [
// Smaller plans are listed even when they cannot be taken, with the reason
// in the customer's own numbers.
'downgrade' => '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_reverse_charge' => 'net — reverse charge',
'net_per_month' => 'net per month', 'net_per_month' => 'net per month',
'net_once' => 'net, one-off', 'net_once' => 'net, one-off',

View File

@ -4,8 +4,9 @@ return [
'title' => 'My cloud', 'title' => 'My cloud',
'subtitle' => 'Details and resources of your instance.', 'subtitle' => 'Details and resources of your instance.',
'plan_line' => ':plan · €:price/mo', 'plan_line' => ':plan · €:price/mo',
'datacenter' => 'EU · Falkenstein data center', 'datacenter' => 'EU',
'now' => 'now', 'now' => 'now',
'instance_label' => 'Instance',
'open' => 'Open cloud', 'open' => 'Open cloud',
'status_active' => 'Active', 'status_active' => 'Active',
'status_provisioning' => 'Provisioning', 'status_provisioning' => 'Provisioning',

View File

@ -3,12 +3,104 @@
return [ return [
'traffic' => [ 'traffic' => [
'title' => 'Data transfer this month', 'title' => 'Data transfer this month',
'label' => 'Data transfer',
'of' => 'of :total',
'resets' => 'resets in :days days', 'resets' => 'resets in :days days',
'remaining' => ':amount left', 'remaining' => ':amount left',
'almost' => ':percent % used — getting tight.', 'almost' => ':percent % used — getting tight.',
'exhausted' => 'Allowance used up. Your Nextcloud keeps running, but slower, until you top up or the month rolls over.', 'exhausted' => 'Allowance used up. Your Nextcloud keeps running, but slower, until you top up or the month rolls over.',
'buy' => 'Add data volume', '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', 'title' => 'Overview',
'subtitle' => 'Your cloud status at a glance.', 'subtitle' => 'Your cloud status at a glance.',
'greeting' => 'Good morning, :name', 'greeting' => 'Good morning, :name',
@ -26,6 +118,13 @@ return [
'support' => 'Support', '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', 'open_nav' => 'Open navigation',
'close_nav' => 'Close navigation', 'close_nav' => 'Close navigation',
'logout' => 'Sign out', 'logout' => 'Sign out',

View File

@ -9,7 +9,7 @@ return [
'contact_hours_val' => 'MonFri, 8am6pm', 'contact_hours_val' => 'MonFri, 8am6pm',
'contact_email' => 'Email', 'contact_email' => 'Email',
'contact_sla' => 'Response time', 'contact_sla' => 'Response time',
'contact_sla_val' => 'Team plan · within 4 hrs', 'contact_sla_val' => 'Answer on the same working day',
'tickets' => 'Your requests', 'tickets' => 'Your requests',
'status_open' => 'Open', 'status_open' => 'Open',
'status_progress' => 'In progress', 'status_progress' => 'In progress',

View File

@ -16,10 +16,27 @@ return [
'role_member' => 'Member', 'role_member' => 'Member',
'role_readonly' => 'Read-only', '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_person' => 'Person',
'col_status' => 'Status', 'col_status' => 'Status',
'owner_no_actions' => 'The owner cannot be removed.',
'col_actions' => 'Actions', '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_active' => 'Active',
'status_invited' => 'Invited', 'status_invited' => 'Invited',
'status_revoked' => 'Removed', 'status_revoked' => 'Removed',

View File

@ -1,33 +1,40 @@
/* CluPilot admin console "Tactical Terminal": dark graphite, signal-orange, /* CluPilot operator console density, not a second palette.
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. */ * 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 { .theme-admin {
--bg: #0b0e13; /* One step down across the board. The scale is the same scale a console
--surface: #12161d; that used different type sizes as well as different spacing stops being
--surface-2: #1a2029; recognisably the same application. */
--surface-hover: #1c232d; --text-base: 13.5px;
--text-md: 15px;
--text-2xl: 26px;
--text-3xl: 32px;
--border: #232b37; /* Tighter corners on a denser grid: a 16px radius around a 40px-high row
--border-strong: #313b4a; looks inflated when there are twenty of them. */
--radius-lg: 13px;
--text-strong: #eef1f6; --radius-xl: 16px;
--text: #c2cad6; }
--text-muted: #8b95a5;
--text-faint: #626d7e; /* Table rows an operator scans rather than reads: 4044px, against the 4856px
the portal uses. Written here rather than in the views so a future table
/* signal orange — brighter for dark; dark text sits on the fill (AA) */ inherits it instead of restating it. */
--accent: #ff7a2f; .theme-admin table td {
--accent-hover: #ff8f4d; padding-top: 11px;
--accent-active: #ff7a2f; padding-bottom: 11px;
--accent-press: #e8630f; }
--accent-subtle: rgba(255, 122, 47, .14);
--accent-border: rgba(255, 122, 47, .32); .theme-admin table th {
--accent-ring: rgba(255, 122, 47, .45); padding-bottom: 10px;
--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);
} }

View File

@ -7,9 +7,8 @@
@import '@fontsource/ibm-plex-mono/400.css'; @import '@fontsource/ibm-plex-mono/400.css';
@import '@fontsource/ibm-plex-mono/500.css'; @import '@fontsource/ibm-plex-mono/500.css';
@import '@fontsource/ibm-plex-mono/600.css'; @import '@fontsource/ibm-plex-mono/600.css';
/* Serif is the display voice — headlines only, matching the marketing site. */ /* No serif: a serif headline made every page read as a document, which is
@import '@fontsource/ibm-plex-serif/400.css'; exactly the impression the redesign removes. One family, used assertively. */
@import '@fontsource/ibm-plex-serif/600.css';
/* Design tokens, then Tailwind v3 layers. */ /* Design tokens, then Tailwind v3 layers. */
@import './portal-tokens.css'; @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 /* 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. * then leaves the page zoomed every tap into a form throws the layout about.
* *
@ -124,3 +81,43 @@
transition-duration: 0.001ms !important; 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; }
}

View File

@ -1,75 +1,119 @@
/* CluPilot customer-portal design tokens refined enterprise, light, single /* CluPilot design tokens one set for every surface.
orange accent. Framework-neutral CSS custom properties (work in Tailwind v3 *
or v4). Values calibrated for AA contrast. See design handoff §6. */ * 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 { :root {
/* Surfaces (light, slightly cool) */ /* Surfaces
--bg: #f6f7f9; 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: #ffffff;
--surface-2: #f1f3f5; --surface-2: #fafafb;
--surface-hover: #f8f9fb; --surface-hover: #f2f2f5;
/* Lines */ /* Lines carry the structure. Depth is used sparingly and warmly. */
--border: #e4e7ec; --border: #e9e9ee;
--border-strong: #d0d5dd; --border-strong: #dcdce4;
/* Text */ /* Text
--text-strong: #101828; Near-black rather than pure black: on a light ground pure black reads
--text: #344054; as a printing error, not as emphasis. */
--text-muted: #667085; --text-strong: #17171c; /* headings — 16.4:1 on white */
--text-faint: #98a2b3; --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
--accent: #f97316; /* brand tone: decorative fills, borders, tints, focus */ Orange appears in FEW places, but one of them is allowed to be large
--accent-hover: #ea6a0c; a whole closing panel. Frequency low, area high; the reverse reads as
--accent-active: #c2560a; /* AA-safe fill for white text on orange (~5:1) */ editorial annotation rather than as brand. */
--accent-press: #a8480a; /* darker press/hover for accent-fill buttons */ --accent: #f97316; /* rings, bars, chart strokes, the logo mark */
--accent-subtle: #fff4ec; --accent-hover: #d95f0c;
--accent-border: #fed7aa; --accent-active: #b8500a; /* solid fills and text — 5.0:1 with white */
--accent-ring: rgba(249, 115, 22, .35); --accent-press: #974208;
--accent-subtle: #fff3e9;
--accent-border: #e8cdb2;
--accent-ring: rgba(249, 115, 22, .38);
--on-accent: #ffffff; --on-accent: #ffffff;
/* Accent as TEXT on light surfaces (links, small labels) must be dark enough /* #f97316 as TEXT on white is only 2.9:1, so anything read uses the
for AA #f97316 on white is only ~2.9:1, so text uses --accent-active. */ darker tone. This is the single most common way the palette gets
--accent-text: #c2560a; 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 /* Ink fields
the product the sign-in panel, the closing block of the public site. The dark surfaces: the site's contrast bands, the sign-in panel, the
Warm rather than blue-black, so it reads as ink, not as chrome. */ operator sidebar. Neutral-warm, never pure black. */
--plate: #17140f; --plate: #16151b;
--plate-2: #211d16; --plate-2: #201f27;
--plate-rule: #39332a; --plate-rule: #33323d;
--plate-text: #ded7c9; --plate-text: #c9c7d2;
--plate-muted: #93897a; --plate-muted: #918f9d; /* 4.6:1 on --plate */
/* Status semantics — only in badges/alerts, never a second decorative tone */ /* Status
--success: #067a48; --success-bg: #ecfdf3; --success-border: #abefc6; Independent of the brand. Amber must keep meaning "this needs you", so
--success-bright: #30b15c; /* dots/rings/charts only (large marks, not small text) */ "scheduled" and "informational" are blue next to an orange brand the
--warning: #b54708; --warning-bg: #fffaeb; --warning-border: #fedf89; two are otherwise indistinguishable. */
--danger: #b42318; --danger-bg: #fef3f2; --danger-border: #fecdca; --success: #1c7c50; --success-bg: #eaf6f0; --success-border: #c3e3d3;
--info: #175cd3; --info-bg: #eff8ff; --info-border: #b2ddff; --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-sans: "IBM Plex Sans", ui-sans-serif, system-ui, sans-serif;
--font-mono: "IBM Plex Mono", ui-monospace, monospace; --font-mono: "IBM Plex Mono", ui-monospace, monospace;
/* Display voice for headlines, shared with the public site so the two do /* Kept for compatibility; the display voice is now the sans at weight 700.
not look like two different companies. Never used for body copy. */ A serif headline made every page read as a document. */
--font-serif: "IBM Plex Serif", Georgia, "Times New Roman", serif; --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-xs: 12px; --text-sm: 13px; --text-base: 14.5px; --text-md: 16px;
--text-lg: 18px; --text-xl: 22px; --text-2xl: 28px; --text-3xl: 34px; --text-lg: 18px; --text-xl: 22px; --text-2xl: 30px; --text-3xl: 40px;
--lh-tight: 1.2; --lh-normal: 1.5; --lh-tight: 1.12; --lh-normal: 1.55;
--tracking-tight: -0.02em; --tracking-tight: -0.03em;
--tracking-label: 0.06em; --tracking-label: 0.07em;
/* Radius / shadow / motion / focus */ /* Radius
--radius-sm: 6px; --radius: 8px; --radius-lg: 12px; --radius-xl: 20px; --radius-pill: 999px; 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); /* Warm, broad and low a neutral grey drop shadow belongs to a different
--shadow-sm: 0 1px 3px rgba(16, 24, 40, .10), 0 1px 2px rgba(16, 24, 40, .06); design language and reads as cold against this ground. */
--shadow-md: 0 4px 8px -2px rgba(16, 24, 40, .10), 0 2px 4px -2px rgba(16, 24, 40, .06); --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; --dur-fast: 120ms; --dur: 180ms; --dur-slow: 260ms;
--ease: cubic-bezier(.2, .6, .2, 1); --ease: cubic-bezier(.2, .7, .2, 1);
--focus-ring: 0 0 0 3px var(--accent-ring); --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);
}

View File

@ -19,66 +19,69 @@
--}} --}}
@verbatim @verbatim
<style> <style>
@font-face{font-family:"Plex Serif";src:url("/fonts/ibm-plex-serif-latin-600-normal.woff2") format("woff2");font-weight:600;font-display:swap}
@font-face{font-family:"Plex Sans";src:url("/fonts/ibm-plex-sans-latin-400-normal.woff2") format("woff2");font-weight:400;font-display:swap} @font-face{font-family:"Plex Sans";src:url("/fonts/ibm-plex-sans-latin-400-normal.woff2") format("woff2");font-weight:400;font-display:swap}
@font-face{font-family:"Plex Sans";src:url("/fonts/ibm-plex-sans-latin-600-normal.woff2") format("woff2");font-weight:600;font-display:swap}
@font-face{font-family:"Plex Mono";src:url("/fonts/ibm-plex-mono-latin-400-normal.woff2") format("woff2");font-weight:400;font-display:swap} @font-face{font-family:"Plex Mono";src:url("/fonts/ibm-plex-mono-latin-400-normal.woff2") format("woff2");font-weight:400;font-display:swap}
:root{ :root{
--paper:#f7f5f1; --card:#fffefb; --ink:#17140f; --ink-soft:#413a31; --bg:#f6f6f8; --card:#fff; --card-2:#fafafb;
--muted:#7b7367; --rule:#e3ded3; --rule-mid:#cbc3b4; --ink:#17171c; --body:#43434e; --muted:#6e6e7a;
--accent:#f97316; --accent-text:#b8500a; --line:#e9e9ee; --line-2:#dcdce4;
} --accent:#f97316; --accent-ink:#b8500a; --accent-wash:#fff3e9;
@media (prefers-color-scheme: dark){ --plate:#16151b;
/* The accent has to lighten too: #b8500a is chosen for contrast against
paper, and on the ink plate it is the small text that stops being
readable first. */
:root{--paper:#17140f; --card:#211d16; --ink:#fffefb; --ink-soft:#ded7c9;
--muted:#93897a; --rule:#39332a; --rule-mid:#4a4238;
--accent-text:#f0a06a;}
} }
*{margin:0;padding:0;box-sizing:border-box} *{margin:0;padding:0;box-sizing:border-box}
html{-webkit-text-size-adjust:100%}
body{ body{
font-family:"Plex Sans",-apple-system,BlinkMacSystemFont,sans-serif; font-family:"Plex Sans",-apple-system,BlinkMacSystemFont,sans-serif;
background:var(--paper);color:var(--ink-soft);line-height:1.6; background:var(--bg);color:var(--body);line-height:1.55;
min-height:100vh;display:grid;place-items:center;padding:clamp(24px,6vw,64px); min-height:100vh;display:grid;place-items:center;padding:clamp(24px,6vw,64px);
-webkit-font-smoothing:antialiased; -webkit-font-smoothing:antialiased;
} }
main{max-width:520px;width:100%} main{max-width:560px;width:100%}
.plate{border:1px solid var(--rule-mid);border-radius:3px;background:var(--card);position:relative} .card{border:1px solid var(--line);border-radius:16px;background:var(--card);
/* Registration marks, as on the rest of the site. */ box-shadow:0 16px 50px rgba(40,24,15,.10);overflow:hidden}
.plate::before,.plate::after{content:"+";position:absolute;font-family:"Plex Mono",monospace;font-size:.8rem;color:var(--rule-mid);line-height:1} .head{display:flex;align-items:center;justify-content:space-between;gap:14px;
.plate::before{top:-6px;left:-6px} padding:16px 24px;border-bottom:1px solid var(--line);background:var(--card-2);
.plate::after{bottom:-6px;right:-6px} font-size:13px;font-weight:600;color:var(--muted)}
.head{ .head b{color:var(--accent-ink);font-family:"Plex Mono",monospace;font-weight:500}
display:flex;justify-content:space-between;align-items:center;gap:14px; .body{padding:30px 24px 28px}
padding:13px 20px;border-bottom:1px solid var(--rule); .brand{display:inline-flex;align-items:center;gap:9px;font-size:17px;font-weight:700;
font-family:"Plex Mono",monospace;font-size:.7rem;text-transform:uppercase;letter-spacing:.15em;color:var(--muted); letter-spacing:-.02em;color:var(--ink)}
} .brand i{font-style:normal;color:var(--accent-ink)}
.head b{color:var(--accent-text);font-weight:400;display:inline-flex;align-items:center;gap:7px} .mark{width:26px;height:26px;border-radius:7px;flex:none;
.dot{width:6px;height:6px;border-radius:50%;background:var(--accent);animation:pulse 1.8s ease-in-out infinite} background:linear-gradient(135deg,#fb923c,#f97316 55%,#c2560a)}
h1{margin-top:18px;font-weight:700;font-size:clamp(1.6rem,4vw,2.1rem);
letter-spacing:-.032em;line-height:1.1;color:var(--ink)}
p{margin-top:12px;color:var(--muted);font-size:15px}
.hint{margin-top:18px;padding-top:16px;border-top:1px solid var(--line);font-size:14px}
.acts{display:flex;flex-wrap:wrap;gap:10px;margin-top:26px}
.btn{display:inline-flex;align-items:center;min-height:46px;padding:0 22px;border-radius:12px;
font-size:15px;font-weight:600;text-decoration:none;border:1.5px solid transparent;
transition:background .18s,border-color .18s}
.ink{background:var(--accent-ink);color:#fff}
.ink:hover{background:#974208}
.line{border-color:var(--line-2);color:var(--ink)}
.line:hover{border-color:var(--ink)}
.foot{margin-top:12px;font-size:14px}
.foot a{color:var(--accent-ink);font-weight:600;text-decoration:none}
.foot a:hover{text-decoration:underline}
.mark-b{margin-top:22px;font-size:12.5px;color:var(--muted);text-align:center}
.dot{width:7px;height:7px;border-radius:50%;background:var(--accent);animation:pulse 1.8s ease-in-out infinite}
@keyframes pulse{50%{opacity:.35}} @keyframes pulse{50%{opacity:.35}}
@media (prefers-reduced-motion: reduce){.dot{animation:none}} @media (prefers-reduced-motion: reduce){.dot{animation:none}}
.body{padding:30px 20px 28px}
.brand{font-family:"Plex Serif",Georgia,serif;font-weight:600;font-size:1.1rem;letter-spacing:-.02em;color:var(--ink)}
.brand i{font-style:normal;color:var(--accent-text)}
h1{margin-top:16px;font-family:"Plex Serif",Georgia,serif;font-weight:600;
font-size:clamp(1.5rem,4vw,2rem);letter-spacing:-.025em;line-height:1.15;color:var(--ink)}
p{margin-top:12px;color:var(--muted);font-size:.95rem}
.foot{margin-top:18px;padding-top:16px;border-top:1px solid var(--rule);font-size:.86rem}
.foot a{color:var(--accent-text);font-weight:500;text-decoration:none}
.foot a:hover{text-decoration:underline}
</style> </style>
@endverbatim @endverbatim
</head> </head>
<body> <body>
<main> <main>
<div class="plate"> <div class="card">
<div class="head"> <div class="head">
<span>{{ __('coming_soon.label') }}</span> <span>{{ __('coming_soon.label') }}</span>
<b><span class="dot" aria-hidden="true"></span>{{ __('coming_soon.state') }}</b> <b style="display:inline-flex;align-items:center;gap:8px"><span class="dot" aria-hidden="true"></span>{{ __('coming_soon.state') }}</b>
</div> </div>
<div class="body"> <div class="body">
<span class="brand">Clu<i>Pilot</i> Cloud</span> <span class="brand"><span class="mark" aria-hidden="true"></span>Clu<i>Pilot</i> Cloud</span>
<h1>{{ __('coming_soon.title') }}</h1> <h1>{{ __('coming_soon.title') }}</h1>
<p>{{ __('coming_soon.body') }}</p> <p>{{ __('coming_soon.body') }}</p>
<p class="foot">{{ __('coming_soon.contact') }} <p class="foot">{{ __('coming_soon.contact') }}

View File

@ -14,24 +14,20 @@
would only push it under the fold. would only push it under the fold.
--}} --}}
<aside class="relative hidden w-[44%] shrink-0 overflow-hidden bg-plate lg:flex lg:flex-col lg:justify-between lg:p-12 xl:p-14"> <aside class="relative hidden w-[44%] shrink-0 overflow-hidden bg-plate lg:flex lg:flex-col lg:justify-between lg:p-12 xl:p-14">
{{-- Survey grid, faded out towards the bottom. --}} {{-- One warm glow, the same one the public site's dark bands carry. --}}
<div aria-hidden="true" class="pointer-events-none absolute inset-0 opacity-60" <div aria-hidden="true" class="pointer-events-none absolute inset-0"
style="background-image:linear-gradient(var(--plate-rule) 1px,transparent 1px),linear-gradient(90deg,var(--plate-rule) 1px,transparent 1px); style="background:radial-gradient(ellipse 52% 46% at 24% 18%,rgba(249,115,22,.20),transparent 68%);"></div>
background-size:76px 76px;
mask-image:radial-gradient(ellipse 78% 62% at 30% 8%,#000 0%,transparent 72%);
-webkit-mask-image:radial-gradient(ellipse 78% 62% at 30% 8%,#000 0%,transparent 72%);"></div>
<div class="relative flex items-center gap-2.5"> <div class="relative flex items-center gap-2.5">
<x-ui.logo class="size-8" /> <x-ui.logo class="size-8" />
<span class="font-serif text-xl font-semibold tracking-tight text-white">CluPilot</span> <span class="text-xl font-bold tracking-tight text-white">CluPilot</span>
</div> </div>
<div class="relative"> <div class="relative">
<p class="mb-6 flex items-center gap-3 font-mono text-[11px] uppercase tracking-[0.16em] text-plate-muted"> <p class="mb-7 inline-flex items-center gap-2 rounded-pill px-3.5 py-1.5 text-xs font-semibold"
<span class="h-px w-6 bg-accent"></span>{{ __('auth.brand_eyebrow') }} style="background:rgba(249,115,22,.16);color:#ffb27a">{{ __('auth.brand_eyebrow') }}</p>
</p>
<h2 class="max-w-md font-serif text-4xl font-semibold leading-[1.08] tracking-tight text-white xl:text-[2.9rem]">{{ $headline }}</h2> <h2 class="max-w-md text-4xl font-bold leading-[1.08] tracking-tight text-white xl:text-[2.9rem]">{{ $headline }}</h2>
<p class="mt-5 max-w-sm leading-relaxed text-plate-text">{{ $sub }}</p> <p class="mt-5 max-w-sm leading-relaxed text-plate-text">{{ $sub }}</p>
@if ($facts !== []) @if ($facts !== [])
@ -46,5 +42,5 @@
@endif @endif
</div> </div>
<p class="relative font-mono text-[11px] uppercase tracking-[0.14em] text-plate-muted">{{ __('auth.brand_footer') }}</p> <p class="relative text-xs text-plate-muted">{{ __('auth.brand_footer') }}</p>
</aside> </aside>

View File

@ -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.
--}}
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="apple-touch-icon" href="/favicon.svg">
<title>{{ $title ?? config('app.name') }}</title>
@vite(['resources/css/app.css', 'resources/js/app.js'])
@livewireStyles

View File

@ -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.
--}}
<aside
class="fixed inset-y-0 left-0 z-50 flex w-[15.5rem] flex-col overflow-y-auto border-r border-line bg-surface px-3 py-4 shadow-md transition-transform lg:static lg:h-screen lg:translate-x-0 lg:shadow-none"
:class="nav ? 'translate-x-0' : '-translate-x-full'"
>
<div class="flex items-center justify-between gap-2 px-2.5 pb-5">
<a href="{{ $console ? \App\Support\AdminArea::home() : route('dashboard') }}" class="flex items-center gap-2.5">
<x-ui.logo class="size-7" />
{{-- One element: as separate flex children the gap lands inside the
word and reads as "Clu Pilot". --}}
<span class="whitespace-nowrap text-lg font-bold tracking-tight text-ink">Clu<span class="text-accent-text">Pilot</span></span>
@if ($console)
<span class="rounded-sm bg-accent-subtle px-1.5 py-0.5 font-mono text-[9.5px] font-semibold uppercase tracking-[0.1em] text-accent-text">{{ __('admin.badge') }}</span>
@endif
</a>
<button type="button" class="lg:hidden -mr-1 inline-flex min-h-11 min-w-11 items-center justify-center rounded text-muted"
@click="nav = false" aria-label="{{ __('dashboard.close_nav') }}">
<x-ui.icon name="x" />
</button>
</div>
@foreach ($groups as $group)
@if (! empty($group['label']))
<p class="px-3 pb-2 pt-5 lbl">{{ $group['label'] }}</p>
@endif
<nav class="space-y-0.5">
@foreach ($group['items'] as [$route, $icon, $key, $capability])
@continue($capability !== null && ! auth()->user()?->can($capability))
<x-ui.nav-item
:href="route($route)"
:active="$console ? \App\Support\AdminArea::routeIs($route) : request()->routeIs($route)"
@click="nav = false"
>
<x-slot:icon><x-ui.icon :name="$icon" class="size-[1.15rem]" /></x-slot:icon>
{{ __($prefix.'.'.$key) }}
</x-ui.nav-item>
@endforeach
</nav>
@endforeach
@if ($footer)
<p class="mt-auto px-3 pt-6 font-mono text-[11px] leading-relaxed text-muted">{{ $footer }}</p>
@endif
</aside>
{{-- Backdrop. Tapping it closes the drawer, which is the behaviour every
phone user tries first. --}}
<div x-show="nav" x-cloak @click="nav = false" class="fixed inset-0 z-40 bg-[color-mix(in_srgb,var(--plate)_42%,transparent)] lg:hidden"></div>

View File

@ -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.
--}}
<header class="sticky top-0 z-30 flex h-[3.75rem] shrink-0 items-center gap-3 border-b border-line bg-[color-mix(in_srgb,var(--surface)_82%,transparent)] px-4 backdrop-blur-lg backdrop-saturate-150 lg:px-6">
<button type="button" class="lg:hidden inline-flex min-h-11 min-w-11 items-center justify-center rounded text-muted hover:bg-surface-hover"
@click="nav = true" aria-label="{{ __('dashboard.open_nav') }}">
<x-ui.icon name="menu" />
</button>
{{-- On a phone only the leaf survives: the trail is a desktop affordance,
and the intermediate crumbs push the page name off screen. --}}
<nav class="flex min-w-0 items-center gap-2 text-sm text-muted" aria-label="Breadcrumb">
@foreach ($crumbs as $i => $crumb)
@if ($i > 0)
<x-ui.icon name="chevron-down" class="size-3.5 -rotate-90 text-faint {{ $loop->last ? 'hidden sm:block' : 'hidden sm:block' }}" />
@endif
<span @class([
'min-w-0 truncate',
'font-semibold text-ink' => $loop->last,
'hidden sm:inline' => ! $loop->last,
])>{{ $crumb }}</span>
@endforeach
</nav>
<div class="flex-1"></div>
{{ $slot }}
</header>

View File

@ -7,6 +7,6 @@
'danger' => 'bg-danger-bg border-danger-border text-danger', 'danger' => 'bg-danger-bg border-danger-border text-danger',
]; ];
@endphp @endphp
<div role="alert" {{ $attributes->merge(['class' => 'rounded border px-3 py-2 text-sm '.($variants[$variant] ?? $variants['info'])]) }}> <div role="alert" {{ $attributes->merge(['class' => 'rounded border px-4 py-3 text-sm '.($variants[$variant] ?? $variants['info'])]) }}>
{{ $slot }} {{ $slot }}
</div> </div>

View File

@ -24,7 +24,7 @@
'info' => 'bg-info-bg border-info-border text-info', 'info' => 'bg-info-bg border-info-border text-info',
][$tone]; ][$tone];
@endphp @endphp
<span {{ $attributes->merge(['class' => 'inline-flex items-center gap-1.5 rounded-pill border px-2.5 py-0.5 text-xs font-medium '.$classes]) }}> <span {{ $attributes->merge(['class' => 'inline-flex items-center gap-1.5 rounded-pill border px-2.5 py-1 text-xs font-semibold '.$classes]) }}>
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span> <span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>
{{ $slot }} {{ $slot }}
</span> </span>

View File

@ -3,13 +3,18 @@
'size' => 'md', 'size' => 'md',
'type' => 'button', 'type' => 'button',
'loading' => false, '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 @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 = [ $sizes = [
'sm' => 'px-3 py-1.5 text-sm', 'sm' => 'min-h-8 px-3 text-[13px]',
'md' => 'px-4 py-2.5 text-sm', '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 // 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', 'secondary' => 'border border-line-strong bg-surface text-body hover:bg-surface-hover',
'ghost' => 'text-body hover:bg-surface-hover', 'ghost' => 'text-body hover:bg-surface-hover',
'danger' => 'bg-danger text-on-accent hover:opacity-90', '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']); $classes = $base.' '.($sizes[$size] ?? $sizes['md']).' '.($variants[$variant] ?? $variants['primary']);
@endphp @endphp
<button @if ($href !== null)
type="{{ $type }}" <a href="{{ $href }}" {{ $attributes->merge(['class' => $classes]) }}>{{ $slot }}</a>
{{ $attributes->merge(['class' => $classes]) }} @else
@disabled($loading) <button
@if ($loading) aria-busy="true" @endif type="{{ $type }}"
> {{ $attributes->merge(['class' => $classes]) }}
@if ($loading) @disabled($loading)
<svg class="size-4 animate-spin" viewBox="0 0 24 24" fill="none" aria-hidden="true"> @if ($loading) aria-busy="true" @endif
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> >
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 0 1 8-8V0C5.4 0 0 5.4 0 12h4z"></path> @if ($loading)
</svg> <svg class="size-4 animate-spin" viewBox="0 0 24 24" fill="none" aria-hidden="true">
@endif <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
{{ $slot }} <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 0 1 8-8V0C5.4 0 0 5.4 0 12h4z"></path>
</button> </svg>
@endif
{{ $slot }}
</button>
@endif

View File

@ -1,11 +1,19 @@
@props([ @props([
'title' => null, '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.
--}}
<div {{ $attributes->merge(['class' => 'rounded-lg border border-line bg-surface shadow-xs']) }}> <div {{ $attributes->merge(['class' => 'rounded-lg border border-line bg-surface shadow-xs']) }}>
@if ($title) @if ($title)
<div class="border-b border-line px-6 py-3"> {{-- A real heading, not a small-caps label. It was set in --text-faint,
<h2 class="text-xs font-semibold uppercase tracking-wide text-faint">{{ $title }}</h2> which is 2.8:1 on white: a decoration token used for something a
reader has to read. --}}
<div class="border-b border-line px-5 py-4">
<h2 class="text-md font-semibold text-ink">{{ $title }}</h2>
</div> </div>
@endif @endif
<div class="{{ $padding }}"> <div class="{{ $padding }}">

View File

@ -10,7 +10,7 @@
$errors ??= new \Illuminate\Support\ViewErrorBag; $errors ??= new \Illuminate\Support\ViewErrorBag;
$id = $attributes->get('id', $name); $id = $attributes->get('id', $name);
$hasError = $errors->has($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'); .($hasError ? 'border-danger bg-danger-bg' : 'border-line');
@endphp @endphp
<div class="space-y-1.5"> <div class="space-y-1.5">
@ -27,7 +27,7 @@
> >
@if ($hint && ! $hasError) @if ($hint && ! $hasError)
<p class="text-xs text-faint">{{ $hint }}</p> <p class="text-xs text-muted">{{ $hint }}</p>
@endif @endif
@error($name) @error($name)

View File

@ -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.
--}}
<article {{ $attributes->merge(['class' => 'overflow-hidden rounded-lg border border-line bg-surface px-5 py-[18px] shadow-xs']) }}>
<div class="flex items-center justify-between gap-2.5">
<span class="lbl">{{ $label }}</span>
@if ($href)
<a href="{{ $href }}" wire:navigate class="text-muted transition hover:text-ink" aria-label="{{ $label }}">
<x-ui.icon name="chevron-down" class="size-3.5 -rotate-90" />
</a>
@endif
</div>
<div class="mt-1.5 flex items-end justify-between gap-3.5">
<div class="min-w-0">
<p class="mt-[9px] flex flex-wrap items-baseline gap-x-1.5 font-mono text-[25px] font-semibold leading-tight tracking-[-0.02em] text-ink">
{{ $value }}
@if ($unit)<span class="text-[13px] font-[450] text-muted">{{ $unit }}</span>@endif
</p>
@if ($foot)
<p class="mt-1.5 text-[12px] leading-snug text-muted">{{ $foot }}</p>
@endif
</div>
@isset($visual)
<div class="shrink-0">{{ $visual }}</div>
@endisset
</div>
{{ $slot }}
</article>

View File

@ -6,21 +6,26 @@
@php @php
// items-center, not baseline: the icon is a block-level svg (Tailwind's // 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. // 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 // The label is a flex row of its own so an icon handed to the default slot
// slot instead of the icon slot still lands next to the text. Passing it in // instead of the icon slot still lands next to the text. Passing it in the
// the default slot put a display:block svg into an inline span and pushed // default slot put a display:block svg into an inline span and pushed the
// the label onto a second line — the sidebar entry then stood twice as tall // label onto a second line — the entry then stood twice as tall as every
// as every other one. Layout must not depend on which slot was used. // other one. Layout must not depend on which slot was used.
$label = 'flex min-w-0 items-center gap-3'; $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 $state = $active
? 'bg-accent-subtle text-accent-text border-accent' ? '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-[\'\']'
: 'text-muted border-transparent hover:bg-surface-hover hover:text-body'; : 'font-medium text-muted hover:bg-surface-hover hover:text-ink';
@endphp @endphp
@if ($disabled) @if ($disabled)
{{-- Section not built yet: render a non-interactive item, never a dead link. --}} {{-- Section not built yet: a non-interactive item, never a dead link. --}}
<span aria-disabled="true" {{ $attributes->merge(['class' => $base.' border-transparent text-faint cursor-not-allowed']) }}> <span aria-disabled="true" {{ $attributes->merge(['class' => $base.' cursor-not-allowed font-medium text-faint']) }}>
@isset($icon)<span class="shrink-0">{{ $icon }}</span>@endisset @isset($icon)<span class="shrink-0">{{ $icon }}</span>@endisset
<span class="{{ $label }}">{{ $slot }}</span> <span class="{{ $label }}">{{ $slot }}</span>
</span> </span>

View File

@ -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
<div class="relative grid place-items-center">
<svg class="metric-ring" viewBox="0 0 62 62" style="--c:{{ $c }};--o:{{ $o }};width:{{ $size }}px;height:{{ $size }}px" aria-hidden="true">
<circle class="track" cx="31" cy="31" r="25" />
<circle class="value" cx="31" cy="31" r="25" />
</svg>
<b class="absolute font-mono text-[13px] font-medium text-ink">{{ (int) round($percent) }}%</b>
</div>

View File

@ -0,0 +1,31 @@
@props([
/** @var array<int, int|float> 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 !== '')
<svg class="spark {{ $tone === 'accent' ? '' : 'muted' }}" viewBox="0 0 96 34" preserveAspectRatio="none"
style="width:80px;height:32px" aria-hidden="true">
@if ($area)
<path class="area" d="{{ $path }} L96 34 L0 34 Z" />
@endif
<path class="line" d="{{ $path }}" />
</svg>
@endif

View File

@ -37,62 +37,66 @@
--}} --}}
@verbatim @verbatim
<style> <style>
@font-face{font-family:"Plex Serif";src:url("/fonts/ibm-plex-serif-latin-600-normal.woff2") format("woff2");font-weight:600;font-display:swap}
@font-face{font-family:"Plex Sans";src:url("/fonts/ibm-plex-sans-latin-400-normal.woff2") format("woff2");font-weight:400;font-display:swap} @font-face{font-family:"Plex Sans";src:url("/fonts/ibm-plex-sans-latin-400-normal.woff2") format("woff2");font-weight:400;font-display:swap}
@font-face{font-family:"Plex Sans";src:url("/fonts/ibm-plex-sans-latin-600-normal.woff2") format("woff2");font-weight:600;font-display:swap}
@font-face{font-family:"Plex Mono";src:url("/fonts/ibm-plex-mono-latin-400-normal.woff2") format("woff2");font-weight:400;font-display:swap} @font-face{font-family:"Plex Mono";src:url("/fonts/ibm-plex-mono-latin-400-normal.woff2") format("woff2");font-weight:400;font-display:swap}
:root{ :root{
--paper:#f7f5f1; --card:#fffefb; --ink:#17140f; --ink-soft:#413a31; --bg:#f6f6f8; --card:#fff; --card-2:#fafafb;
--muted:#7b7367; --rule:#e3ded3; --rule-mid:#cbc3b4; --ink:#17171c; --body:#43434e; --muted:#6e6e7a;
--accent:#f97316; --accent-text:#b8500a; --line:#e9e9ee; --line-2:#dcdce4;
} --accent:#f97316; --accent-ink:#b8500a; --accent-wash:#fff3e9;
@media (prefers-color-scheme: dark){ --plate:#16151b;
/* The accent has to lighten too: #b8500a is chosen for contrast against
paper, and on the ink plate it is the small text that stops being
readable first. */
:root{--paper:#17140f; --card:#211d16; --ink:#fffefb; --ink-soft:#ded7c9;
--muted:#93897a; --rule:#39332a; --rule-mid:#4a4238;
--accent-text:#f0a06a;}
} }
*{margin:0;padding:0;box-sizing:border-box} *{margin:0;padding:0;box-sizing:border-box}
html{-webkit-text-size-adjust:100%}
body{ body{
font-family:"Plex Sans",-apple-system,BlinkMacSystemFont,sans-serif; font-family:"Plex Sans",-apple-system,BlinkMacSystemFont,sans-serif;
background:var(--paper);color:var(--ink-soft);line-height:1.6; background:var(--bg);color:var(--body);line-height:1.55;
min-height:100vh;display:grid;place-items:center;padding:clamp(24px,6vw,64px); min-height:100vh;display:grid;place-items:center;padding:clamp(24px,6vw,64px);
-webkit-font-smoothing:antialiased; -webkit-font-smoothing:antialiased;
} }
main{max-width:520px;width:100%} main{max-width:560px;width:100%}
.plate{border:1px solid var(--rule-mid);border-radius:3px;background:var(--card);position:relative} .card{border:1px solid var(--line);border-radius:16px;background:var(--card);
/* Registration marks, as on the rest of the site. */ box-shadow:0 16px 50px rgba(40,24,15,.10);overflow:hidden}
.plate::before,.plate::after{content:"+";position:absolute;font-family:"Plex Mono",monospace;font-size:.8rem;color:var(--rule-mid);line-height:1} .head{display:flex;align-items:center;justify-content:space-between;gap:14px;
.plate::before{top:-6px;left:-6px} padding:16px 24px;border-bottom:1px solid var(--line);background:var(--card-2);
.plate::after{bottom:-6px;right:-6px} font-size:13px;font-weight:600;color:var(--muted)}
.head{ .head b{color:var(--accent-ink);font-family:"Plex Mono",monospace;font-weight:500}
display:flex;justify-content:space-between;align-items:center;gap:14px; .body{padding:30px 24px 28px}
padding:13px 20px;border-bottom:1px solid var(--rule); .brand{display:inline-flex;align-items:center;gap:9px;font-size:17px;font-weight:700;
font-family:"Plex Mono",monospace;font-size:.7rem;text-transform:uppercase;letter-spacing:.15em;color:var(--muted); letter-spacing:-.02em;color:var(--ink)}
} .brand i{font-style:normal;color:var(--accent-ink)}
.head b{color:var(--accent-text);font-weight:400} .mark{width:26px;height:26px;border-radius:7px;flex:none;
.body{padding:28px 20px 26px} background:linear-gradient(135deg,#fb923c,#f97316 55%,#c2560a)}
h1{font-family:"Plex Serif",Georgia,serif;font-weight:600;font-size:clamp(1.5rem,4vw,2rem); h1{margin-top:18px;font-weight:700;font-size:clamp(1.6rem,4vw,2.1rem);
letter-spacing:-.025em;line-height:1.15;color:var(--ink)} letter-spacing:-.032em;line-height:1.1;color:var(--ink)}
p{margin-top:12px;color:var(--muted);font-size:.95rem} p{margin-top:12px;color:var(--muted);font-size:15px}
.hint{margin-top:16px;padding-top:14px;border-top:1px solid var(--rule);font-size:.86rem} .hint{margin-top:18px;padding-top:16px;border-top:1px solid var(--line);font-size:14px}
.acts{display:flex;flex-wrap:wrap;gap:10px;margin-top:24px} .acts{display:flex;flex-wrap:wrap;gap:10px;margin-top:26px}
.btn{display:inline-flex;align-items:center;border-radius:3px;font-size:.88rem;font-weight:500;padding:10px 18px;text-decoration:none;transition:background .18s,border-color .18s} .btn{display:inline-flex;align-items:center;min-height:46px;padding:0 22px;border-radius:12px;
.ink{background:var(--ink);color:var(--paper)} font-size:15px;font-weight:600;text-decoration:none;border:1.5px solid transparent;
.ink:hover{background:var(--accent-text);color:#fff} transition:background .18s,border-color .18s}
.line{border:1px solid var(--rule-mid);color:var(--ink)} .ink{background:var(--accent-ink);color:#fff}
.ink:hover{background:#974208}
.line{border-color:var(--line-2);color:var(--ink)}
.line:hover{border-color:var(--ink)} .line:hover{border-color:var(--ink)}
.mark{margin-top:22px;font-family:"Plex Mono",monospace;font-size:.72rem;color:var(--muted);text-align:center} .foot{margin-top:12px;font-size:14px}
.foot a{color:var(--accent-ink);font-weight:600;text-decoration:none}
.foot a:hover{text-decoration:underline}
.mark-b{margin-top:22px;font-size:12.5px;color:var(--muted);text-align:center}
.dot{width:7px;height:7px;border-radius:50%;background:var(--accent);animation:pulse 1.8s ease-in-out infinite}
@keyframes pulse{50%{opacity:.35}}
@media (prefers-reduced-motion: reduce){.dot{animation:none}}
</style> </style>
@endverbatim @endverbatim
</head> </head>
<body> <body>
<main> <main>
<div class="plate"> <div class="card">
<div class="head"><span>{{ __('errors.heading') }}</span><b>{{ $code }}</b></div> <div class="head"><span>{{ __('errors.heading') }}</span><b>{{ $code }}</b></div>
<div class="body"> <div class="body">
<span class="brand"><span class="mark" aria-hidden="true"></span>Clu<i>Pilot</i></span>
<h1>{{ $title }}</h1> <h1>{{ $title }}</h1>
@if ($body) @if ($body)
<p>{{ $body }}</p> <p>{{ $body }}</p>
@ -108,7 +112,7 @@
</div> </div>
</div> </div>
</div> </div>
<p class="mark">CluPilot Cloud</p> <p class="mark-b">CluPilot Cloud</p>
</main> </main>
</body> </body>
</html> </html>

View File

@ -1,103 +1,48 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="h-full"> <html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="h-full">
<head> <head>
<meta charset="utf-8"> <x-shell.head :title="$title ?? __('admin.console')" />
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="apple-touch-icon" href="/favicon.svg">
<title>{{ $title ?? __('admin.console') }}</title>
@vite(['resources/css/app.css', 'resources/js/app.js'])
@livewireStyles
</head> </head>
{{-- theme-admin swaps every design token to the dark Tactical-Terminal set. --}} {{--
<body class="theme-admin min-h-full bg-bg text-body antialiased" x-data="{ nav: false }"> theme-admin no longer swaps the palette. It used to load a complete dark set
<div class="flex min-h-screen lg:h-screen lg:overflow-hidden"> its own surfaces, its own orange, its own status triad which made the
<aside console a second product and forced every shared component to work in two
class="fixed inset-y-0 left-0 z-40 flex w-60 flex-col overflow-y-auto border-r border-line bg-surface px-3 py-4 transition-transform lg:static lg:h-screen lg:translate-x-0" worlds. It now carries density only: smaller type, tighter rows, tighter
:class="nav ? 'translate-x-0' : '-translate-x-full'" corners. Colour comes from one place.
> --}}
<div class="flex items-center justify-between px-3 pb-4"> <body class="theme-admin min-h-full bg-bg text-body antialiased" x-data="{ nav: false }" :class="nav && 'overflow-hidden lg:overflow-auto'">
<div class="flex items-center gap-2"> @php $release = App\Services\Deployment\Release::current(); @endphp
<x-ui.logo class="size-7" />
<span class="font-mono text-lg font-semibold tracking-tight text-ink">CluPilot</span>
<span class="rounded bg-accent-subtle px-1.5 py-0.5 font-mono text-[0.6rem] font-semibold uppercase tracking-wider text-accent-text">{{ __('admin.badge') }}</span>
</div>
<button type="button" class="lg:hidden text-muted" @click="nav = false" aria-label="{{ __('dashboard.close_nav') }}">
<x-ui.icon name="chevron-down" class="size-5 rotate-90" />
</button>
</div>
<nav class="space-y-1">
@foreach ([
['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'],
['admin.provisioning', 'activity', 'provisioning', null],
['admin.maintenance', 'alert-triangle', 'maintenance', null],
['admin.vpn', 'shield', 'vpn', null],
['admin.revenue', 'trending-up', 'revenue', null],
] as [$route, $icon, $key, $capability])
@continue($capability !== null && ! auth()->user()?->can($capability))
<x-ui.nav-item :href="route($route)" :active="\App\Support\AdminArea::routeIs($route)">
<x-slot:icon><x-ui.icon :name="$icon" /></x-slot:icon>
{{ __('admin.nav.'.$key) }}
</x-ui.nav-item>
@endforeach
</nav>
<div class="mt-auto space-y-1 border-t border-line pt-4">
@can('secrets.manage')
{{-- Only where the capability is held: "can open the console"
must not mean "can read the payment key". --}}
<x-ui.nav-item :href="route('admin.secrets')" :active="\App\Support\AdminArea::routeIs('admin.secrets')">
<x-slot:icon><x-ui.icon name="lock" /></x-slot:icon>
{{ __('admin.nav.secrets') }}
</x-ui.nav-item>
@endcan
<x-ui.nav-item :href="route('admin.settings')" :active="\App\Support\AdminArea::routeIs('admin.settings')">
<x-slot:icon><x-ui.icon name="settings" /></x-slot:icon>
{{ __('admin.nav.settings') }}
</x-ui.nav-item>
{{-- What is actually deployed, so a bug report names the right
code. `-dev` means somewhere after the release, not on it. --}}
@php $release = App\Services\Deployment\Release::current(); @endphp
<p class="px-3 pt-2 font-mono text-[11px] leading-tight text-faint"
title="{{ __('admin.version_hint') }}">{{ $release->label() }}</p>
</div>
</aside>
<div x-show="nav" x-cloak @click="nav = false" class="fixed inset-0 z-30 bg-black/40 lg:hidden"></div> <div class="flex min-h-screen lg:h-screen lg:overflow-hidden">
<x-shell.nav
prefix="admin.nav"
console
:footer="$release->label()"
:groups="\App\Support\Navigation::console()"
/>
<div class="flex min-w-0 flex-1 flex-col lg:h-screen lg:overflow-hidden"> <div class="flex min-w-0 flex-1 flex-col lg:h-screen lg:overflow-hidden">
<header class="flex h-14 shrink-0 items-center justify-between border-b border-line bg-surface px-4"> <x-shell.topbar :crumbs="array_values(array_filter([__('admin.console'), $title ?? \App\Support\Navigation::currentLabel(console: true)]))">
<button type="button" class="lg:hidden inline-flex min-h-11 min-w-11 items-center justify-center rounded text-muted hover:bg-surface-hover" @click="nav = true" aria-label="{{ __('dashboard.open_nav') }}">
<x-ui.icon name="menu" />
</button>
<p class="hidden font-mono text-xs text-faint sm:block">{{ __('admin.console') }}</p>
<div class="flex-1"></div>
@auth @auth
<div class="relative" x-data="{ open: false }" @keydown.escape="open = false"> <div class="relative" x-data="{ open: false }" @keydown.escape="open = false">
<button type="button" class="flex items-center gap-2 rounded px-2 py-1.5 hover:bg-surface-hover" @click="open = !open" :aria-expanded="open" aria-haspopup="menu"> <button type="button" class="flex min-h-11 items-center gap-2.5 rounded px-2 hover:bg-surface-hover" @click="open = !open" :aria-expanded="open" aria-haspopup="menu">
<span class="flex size-8 items-center justify-center rounded-pill bg-accent-subtle text-sm font-semibold text-accent-text">{{ \Illuminate\Support\Str::upper(\Illuminate\Support\Str::substr(auth()->user()->name, 0, 1)) }}</span> <span class="grid size-8 place-items-center rounded-pill bg-accent-subtle text-sm font-bold text-accent-text">{{ \Illuminate\Support\Str::upper(\Illuminate\Support\Str::substr(auth()->user()->name, 0, 1)) }}</span>
<span class="hidden text-sm text-body sm:block">{{ auth()->user()->name }}</span> <span class="hidden text-sm font-medium text-ink sm:block">{{ auth()->user()->name }}</span>
<x-ui.icon name="chevron-down" class="size-4 text-muted" /> <x-ui.icon name="chevron-down" class="size-4 text-muted" />
</button> </button>
<div x-show="open" x-cloak @click.outside="open = false" class="absolute right-0 mt-1 w-48 rounded-lg border border-line bg-surface py-1 shadow-md"> <div x-show="open" x-cloak @click.outside="open = false" class="absolute right-0 mt-1 w-48 overflow-hidden rounded-lg border border-line bg-surface py-1 shadow-md">
<form method="POST" action="{{ route('logout') }}"> <form method="POST" action="{{ route('logout') }}">
@csrf @csrf
<button type="submit" class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-body hover:bg-surface-hover"> <button type="submit" class="flex min-h-11 w-full items-center gap-2.5 px-3.5 text-left text-sm text-body hover:bg-surface-hover">
<x-ui.icon name="log-out" class="size-4" />{{ __('dashboard.logout') }} <x-ui.icon name="log-out" class="size-4" />{{ __('dashboard.logout') }}
</button> </button>
</form> </form>
</div> </div>
</div> </div>
@endauth @endauth
</header> </x-shell.topbar>
<main class="mx-auto w-full max-w-[1240px] flex-1 p-6 lg:overflow-y-auto lg:p-8"> <main class="mx-auto w-full max-w-[1320px] flex-1 px-4 py-6 lg:overflow-y-auto lg:px-7 lg:py-7">
{{ $slot }} {{ $slot }}
</main> </main>
</div> </div>
@ -114,13 +59,12 @@
x-transition:leave-start="opacity-100 translate-y-0" x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 translate-y-12" x-transition:leave-end="opacity-0 translate-y-12"
role="status" aria-live="polite" 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"
> >
<span x-text="msg"></span> <span x-text="msg"></span>
</div> </div>
@livewire('wire-elements-modal') @livewire('wire-elements-modal')
@livewireScripts @livewireScripts
</body> </body>
</html> </html>

View File

@ -1,16 +1,9 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="h-full"> <html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="h-full">
<head> <head>
<meta charset="utf-8"> <x-shell.head :title="$title ?? config('app.name')" />
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="apple-touch-icon" href="/favicon.svg">
<title>{{ $title ?? config('app.name') }}</title>
@vite(['resources/css/app.css', 'resources/js/app.js'])
@livewireStyles
</head> </head>
<body class="min-h-full bg-bg text-body antialiased" x-data="{ nav: false }"> <body class="min-h-full bg-bg text-body antialiased" x-data="{ nav: false }" :class="nav && 'overflow-hidden lg:overflow-auto'">
@php @php
$maintenanceWindows = collect(); $maintenanceWindows = collect();
if (auth()->check()) { if (auth()->check()) {
@ -21,12 +14,13 @@
} }
} }
@endphp @endphp
@if (auth()->check() && ! ($portalCustomer ?? null)) @if (auth()->check() && ! ($portalCustomer ?? null))
{{-- No customer behind this login (e.g. an operator account): say so, or {{-- No customer behind this login (e.g. an operator account): say so, or
every customer action on these pages looks like a dead button. --}} every customer action on these pages looks like a dead button. --}}
<div class="flex flex-wrap items-center justify-center gap-x-3 gap-y-1 border-b border-warning-border bg-warning-bg px-4 py-2 text-center text-sm text-ink"> <div class="flex flex-wrap items-center justify-center gap-x-3 gap-y-1 border-b border-warning-border bg-warning-bg px-4 py-2.5 text-center text-sm text-ink">
<x-ui.icon name="alert-triangle" class="size-4 shrink-0 text-warning" /> <x-ui.icon name="alert-triangle" class="size-4 text-warning" />
<span class="font-medium">{{ __('dashboard.no_customer_title') }}</span> <span class="font-semibold">{{ __('dashboard.no_customer_title') }}</span>
<span class="text-muted">{{ __('dashboard.no_customer_hint') }}</span> <span class="text-muted">{{ __('dashboard.no_customer_hint') }}</span>
@if (auth()->user()->can('console.view')) @if (auth()->user()->can('console.view'))
<a href="{{ route('admin.customers') }}" class="font-semibold text-accent-text hover:underline">{{ __('dashboard.no_customer_cta') }}</a> <a href="{{ route('admin.customers') }}" class="font-semibold text-accent-text hover:underline">{{ __('dashboard.no_customer_cta') }}</a>
@ -35,54 +29,20 @@
@endif @endif
<div class="flex min-h-screen lg:h-screen lg:overflow-hidden"> <div class="flex min-h-screen lg:h-screen lg:overflow-hidden">
{{-- Sidebar: fixed drawer on small screens, fixed full-height column on large (R7). --}} <x-shell.nav
<aside prefix="dashboard.nav"
class="fixed inset-y-0 left-0 z-40 flex w-60 flex-col overflow-y-auto border-r border-line bg-surface px-3 py-4 transition-transform lg:static lg:h-screen lg:translate-x-0" :groups="\App\Support\Navigation::portal()"
:class="nav ? 'translate-x-0' : '-translate-x-full'" />
>
<div class="flex items-center justify-between px-3 pb-4">
<span class="flex items-center gap-2">
<x-ui.logo class="size-7" />
<span class="font-mono text-lg font-semibold tracking-tight text-ink">CluPilot</span>
</span>
<button type="button" class="lg:hidden text-muted" @click="nav = false" aria-label="{{ __('dashboard.close_nav') }}">
<x-ui.icon name="chevron-down" class="size-5 rotate-90" />
</button>
</div>
<nav class="space-y-1">
@foreach ([
['dashboard', 'gauge', 'overview'],
['cloud', 'cloud', 'cloud'],
['users', 'users', 'users'],
['backups', 'database', 'backups'],
['invoices', 'receipt', 'invoices'],
['billing', 'box', 'billing'],
['settings', 'settings', 'settings'],
['support', 'life-buoy', 'support'],
] as [$route, $icon, $key])
<x-ui.nav-item :href="route($route)" :active="request()->routeIs($route)">
<x-slot:icon><x-ui.icon :name="$icon" /></x-slot:icon>
{{ __('dashboard.nav.'.$key) }}
</x-ui.nav-item>
@endforeach
</nav>
</aside>
{{-- Mobile backdrop --}}
<div x-show="nav" x-cloak @click="nav = false" class="fixed inset-0 z-30 bg-ink/20 lg:hidden"></div>
<div class="flex min-w-0 flex-1 flex-col lg:h-screen lg:overflow-hidden"> <div class="flex min-w-0 flex-1 flex-col lg:h-screen lg:overflow-hidden">
<header class="flex h-14 shrink-0 items-center justify-between border-b border-line bg-surface px-4"> <x-shell.topbar :crumbs="array_values(array_filter([$portalCustomer?->name, $title ?? \App\Support\Navigation::currentLabel()]))">
<button type="button" class="lg:hidden inline-flex min-h-11 min-w-11 items-center justify-center rounded text-muted hover:bg-surface-hover" @click="nav = true" aria-label="{{ __('dashboard.open_nav') }}"> {{-- Impersonation is a MODE, not a notice: it stays visible
<x-ui.icon name="menu" /> rather than hiding in a dropdown, because acting as someone
</button> 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')) @if (session('impersonator_id'))
<div class="ml-2 flex min-w-0 items-center gap-2 rounded-pill border border-warning-border bg-warning-bg px-3 py-1"> <div class="flex min-w-0 items-center gap-2 rounded-pill border border-warning-border bg-warning-bg px-3 py-1">
<x-ui.icon name="users" class="size-4 shrink-0 text-warning" /> <x-ui.icon name="users" class="size-4 text-warning" />
<span class="truncate text-xs font-medium text-ink">{{ __('impersonate.banner', ['name' => auth()->user()->name]) }}</span> <span class="truncate text-xs font-semibold text-ink">{{ __('impersonate.banner', ['name' => auth()->user()->name]) }}</span>
<form method="POST" action="{{ route('impersonate.leave') }}" class="inline shrink-0"> <form method="POST" action="{{ route('impersonate.leave') }}" class="inline shrink-0">
@csrf @csrf
<button type="submit" class="inline-flex items-center gap-1 rounded-pill px-2 py-0.5 text-xs font-semibold text-warning hover:underline"> <button type="submit" class="inline-flex items-center gap-1 rounded-pill px-2 py-0.5 text-xs font-semibold text-warning hover:underline">
@ -93,28 +53,25 @@
</div> </div>
@endif @endif
<div class="flex-1"></div>
{{-- Notices --}}
@if ($maintenanceWindows->isNotEmpty()) @if ($maintenanceWindows->isNotEmpty())
<div class="relative mr-1" x-data="{ open: false }" @keydown.escape="open = false"> <div class="relative" x-data="{ open: false }" @keydown.escape="open = false">
<button type="button" @click="open = !open" :aria-expanded="open" aria-haspopup="menu" <button type="button" @click="open = !open" :aria-expanded="open" aria-haspopup="menu"
class="relative grid size-9 place-items-center rounded-md text-muted transition hover:bg-surface-hover hover:text-ink" class="relative grid size-10 place-items-center rounded text-muted transition hover:bg-surface-hover hover:text-ink"
aria-label="{{ __('maintenance.notices') }}"> aria-label="{{ __('maintenance.notices') }}">
<x-ui.icon name="bell" class="size-5" /> <x-ui.icon name="bell" />
<span class="absolute right-1 top-1 grid size-4 place-items-center rounded-pill bg-warning text-[10px] font-bold text-white">{{ $maintenanceWindows->count() }}</span> <span class="absolute right-1.5 top-1.5 grid size-4 place-items-center rounded-pill bg-accent text-[10px] font-bold text-white ring-2 ring-surface">{{ $maintenanceWindows->count() }}</span>
</button> </button>
<div x-show="open" x-cloak @click.outside="open = false" <div x-show="open" x-cloak @click.outside="open = false"
class="absolute right-0 z-50 mt-1 w-80 overflow-hidden rounded-lg border border-line bg-surface shadow-md"> class="absolute right-0 z-50 mt-1 w-80 overflow-hidden rounded-lg border border-line bg-surface shadow-md">
<p class="border-b border-line px-4 py-2.5 text-xs font-semibold uppercase tracking-wide text-faint">{{ __('maintenance.notices') }}</p> <p class="border-b border-line px-4 py-3 text-sm font-semibold text-ink">{{ __('maintenance.notices') }}</p>
<div class="max-h-80 divide-y divide-line overflow-y-auto"> <div class="max-h-80 divide-y divide-line overflow-y-auto">
@foreach ($maintenanceWindows as $mw) @foreach ($maintenanceWindows as $mw)
@php $isActive = now()->betweenIncluded($mw->starts_at, $mw->ends_at); @endphp @php $isActive = now()->betweenIncluded($mw->starts_at, $mw->ends_at); @endphp
<div class="flex gap-2.5 px-4 py-3"> <div class="flex gap-2.5 px-4 py-3">
<x-ui.icon name="alert-triangle" class="mt-0.5 size-4 shrink-0 {{ $isActive ? 'text-warning' : 'text-info' }}" /> <x-ui.icon name="alert-triangle" class="mt-0.5 size-4 {{ $isActive ? 'text-warning' : 'text-info' }}" />
<div class="min-w-0"> <div class="min-w-0">
<p class="text-sm font-medium text-ink">{{ $mw->title }}</p> <p class="text-sm font-semibold text-ink">{{ $mw->title }}</p>
<p class="mt-0.5 text-xs leading-relaxed text-muted"> <p class="mt-0.5 text-xs leading-relaxed text-muted">
{{ $isActive {{ $isActive
? __('maintenance.banner_active', ['end' => $mw->ends_at->isoFormat('LT')]) ? __('maintenance.banner_active', ['end' => $mw->ends_at->isoFormat('LT')])
@ -130,15 +87,15 @@
@auth @auth
<div class="relative" x-data="{ open: false }" @keydown.escape="open = false"> <div class="relative" x-data="{ open: false }" @keydown.escape="open = false">
<button type="button" class="flex items-center gap-2 rounded px-2 py-1.5 hover:bg-surface-hover" @click="open = !open" :aria-expanded="open" aria-haspopup="menu"> <button type="button" class="flex min-h-11 items-center gap-2.5 rounded px-2 hover:bg-surface-hover" @click="open = !open" :aria-expanded="open" aria-haspopup="menu">
<span class="flex size-8 items-center justify-center rounded-pill bg-accent-subtle text-sm font-semibold text-accent-text">{{ \Illuminate\Support\Str::upper(\Illuminate\Support\Str::substr(auth()->user()->name, 0, 1)) }}</span> <span class="grid size-8 place-items-center rounded-pill bg-accent-subtle text-sm font-bold text-accent-text">{{ \Illuminate\Support\Str::upper(\Illuminate\Support\Str::substr(auth()->user()->name, 0, 1)) }}</span>
<span class="hidden text-sm text-body sm:block">{{ auth()->user()->name }}</span> <span class="hidden text-sm font-medium text-ink sm:block">{{ auth()->user()->name }}</span>
<x-ui.icon name="chevron-down" class="size-4 text-muted" /> <x-ui.icon name="chevron-down" class="size-4 text-muted" />
</button> </button>
<div x-show="open" x-cloak @click.outside="open = false" class="absolute right-0 mt-1 w-48 rounded-lg border border-line bg-surface py-1 shadow-md"> <div x-show="open" x-cloak @click.outside="open = false" class="absolute right-0 mt-1 w-48 overflow-hidden rounded-lg border border-line bg-surface py-1 shadow-md">
<form method="POST" action="{{ route('logout') }}"> <form method="POST" action="{{ route('logout') }}">
@csrf @csrf
<button type="submit" class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-body hover:bg-surface-hover"> <button type="submit" class="flex min-h-11 w-full items-center gap-2.5 px-3.5 text-left text-sm text-body hover:bg-surface-hover">
<x-ui.icon name="log-out" class="size-4" /> <x-ui.icon name="log-out" class="size-4" />
{{ __('dashboard.logout') }} {{ __('dashboard.logout') }}
</button> </button>
@ -146,9 +103,9 @@
</div> </div>
</div> </div>
@endauth @endauth
</header> </x-shell.topbar>
<main class="mx-auto w-full max-w-[1200px] flex-1 p-6 lg:overflow-y-auto lg:p-8"> <main class="mx-auto w-full max-w-[1240px] flex-1 px-4 py-6 lg:overflow-y-auto lg:px-8 lg:py-8">
{{ $slot }} {{ $slot }}
</main> </main>
</div> </div>
@ -168,7 +125,7 @@
x-transition:leave-end="opacity-0 translate-y-12" x-transition:leave-end="opacity-0 translate-y-12"
role="status" role="status"
aria-live="polite" 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"
> >
<span x-text="msg"></span> <span x-text="msg"></span>
</div> </div>

View File

@ -1,4 +1,4 @@
<div class="rounded-xl bg-surface p-6"> <div class="rounded-lg bg-surface p-6">
@if ($hostCount > 0) @if ($hostCount > 0)
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<span class="grid size-10 shrink-0 place-items-center rounded-lg bg-warning-bg text-warning"> <span class="grid size-10 shrink-0 place-items-center rounded-lg bg-warning-bg text-warning">

View File

@ -1,4 +1,4 @@
<div class="rounded-xl bg-surface p-6"> <div class="rounded-lg bg-surface p-6">
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<span class="grid size-10 shrink-0 place-items-center rounded-lg bg-danger-bg text-danger"> <span class="grid size-10 shrink-0 place-items-center rounded-lg bg-danger-bg text-danger">
<x-ui.icon name="alert-triangle" class="size-5" /> <x-ui.icon name="alert-triangle" class="size-5" />

View File

@ -1,15 +1,15 @@
<div class="space-y-5"> <div class="space-y-5">
<div class="animate-rise"> <div class="animate-rise">
<h1 class="text-2xl font-semibold tracking-tight text-ink">{{ __('admin.nav.customers') }}</h1> <h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('admin.nav.customers') }}</h1>
<p class="mt-1 text-sm text-muted">{{ __('admin.customers_sub') }}</p> <p class="mt-1 text-sm text-muted">{{ __('admin.customers_sub') }}</p>
</div> </div>
<div class="grid grid-cols-1 gap-5 lg:grid-cols-[1fr_300px] lg:items-start"> <div class="grid grid-cols-1 gap-5 lg:grid-cols-[1fr_300px] lg:items-start">
<div class="overflow-hidden rounded-xl border border-line bg-surface shadow-xs animate-rise [animation-delay:60ms]"> <div class="overflow-hidden rounded-lg border border-line bg-surface shadow-xs animate-rise [animation-delay:60ms]">
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="w-full text-sm"> <table class="w-full text-sm">
<thead> <thead>
<tr class="border-b border-line bg-surface-2 text-left text-xs uppercase tracking-wide text-faint"> <tr class="border-b border-line bg-surface-2 text-left text-xs font-semibold text-muted">
<th class="px-4 py-3 font-semibold">{{ __('admin.col.customer') }}</th> <th class="px-4 py-3 font-semibold">{{ __('admin.col.customer') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('admin.col.plan') }}</th> <th class="px-4 py-3 font-semibold">{{ __('admin.col.plan') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('admin.col.mrr') }}</th> <th class="px-4 py-3 font-semibold">{{ __('admin.col.mrr') }}</th>
@ -68,7 +68,7 @@
</div> </div>
</div> </div>
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:120ms]"> <div class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:120ms]">
<h2 class="font-semibold text-ink">{{ __('admin.by_plan') }}</h2> <h2 class="font-semibold text-ink">{{ __('admin.by_plan') }}</h2>
<div class="mt-3 h-56" wire:ignore><x-ui.chart :config="$plansChart" class="h-56" :label="__('admin.by_plan')" /></div> <div class="mt-3 h-56" wire:ignore><x-ui.chart :config="$plansChart" class="h-56" :label="__('admin.by_plan')" /></div>
</div> </div>

View File

@ -1,18 +1,18 @@
<div class="space-y-5"> <div class="space-y-5">
<div class="animate-rise"> <div class="animate-rise">
<h1 class="text-2xl font-semibold tracking-tight text-ink">{{ __('datacenters.title') }}</h1> <h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('datacenters.title') }}</h1>
<p class="mt-1 text-sm text-muted">{{ __('datacenters.subtitle') }}</p> <p class="mt-1 text-sm text-muted">{{ __('datacenters.subtitle') }}</p>
</div> </div>
<div class="grid grid-cols-1 gap-5 lg:grid-cols-[1fr_320px] lg:items-start"> <div class="grid grid-cols-1 gap-5 lg:grid-cols-[1fr_320px] lg:items-start">
{{-- List --}} {{-- List --}}
<div class="overflow-hidden rounded-xl border border-line bg-surface shadow-xs animate-rise [animation-delay:60ms]"> <div class="overflow-hidden rounded-lg border border-line bg-surface shadow-xs animate-rise [animation-delay:60ms]">
@if ($datacenters->isEmpty()) @if ($datacenters->isEmpty())
<p class="p-8 text-center text-sm text-muted">{{ __('datacenters.empty') }}</p> <p class="p-8 text-center text-sm text-muted">{{ __('datacenters.empty') }}</p>
@else @else
<table class="w-full text-sm"> <table class="w-full text-sm">
<thead> <thead>
<tr class="border-b border-line bg-surface-2 text-left text-xs uppercase tracking-wide text-faint"> <tr class="border-b border-line bg-surface-2 text-left text-xs font-semibold text-muted">
<th class="px-4 py-3 font-semibold">{{ __('datacenters.code') }}</th> <th class="px-4 py-3 font-semibold">{{ __('datacenters.code') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('datacenters.name') }}</th> <th class="px-4 py-3 font-semibold">{{ __('datacenters.name') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('datacenters.hosts') }}</th> <th class="px-4 py-3 font-semibold">{{ __('datacenters.hosts') }}</th>
@ -25,7 +25,7 @@
<tr wire:key="dc-{{ $dc->uuid }}" class="border-b border-line last:border-0"> <tr wire:key="dc-{{ $dc->uuid }}" class="border-b border-line last:border-0">
<td class="px-4 py-3 font-mono font-semibold text-ink">{{ $dc->code }}</td> <td class="px-4 py-3 font-mono font-semibold text-ink">{{ $dc->code }}</td>
<td class="px-4 py-3 text-body">{{ $dc->name }} <td class="px-4 py-3 text-body">{{ $dc->name }}
@if ($dc->location)<span class="text-xs text-faint">· {{ $countries[$dc->location] ?? $dc->location }}</span>@endif @if ($dc->location)<span class="text-xs text-muted">· {{ $countries[$dc->location] ?? $dc->location }}</span>@endif
</td> </td>
<td class="px-4 py-3 font-mono text-xs text-muted">{{ $dc->hosts_count }}</td> <td class="px-4 py-3 font-mono text-xs text-muted">{{ $dc->hosts_count }}</td>
<td class="px-4 py-3"> <td class="px-4 py-3">
@ -58,7 +58,7 @@
</div> </div>
{{-- Add form --}} {{-- Add form --}}
<form wire:submit="save" class="space-y-4 rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:120ms]"> <form wire:submit="save" class="space-y-4 rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:120ms]">
<h2 class="font-semibold text-ink">{{ __('datacenters.add') }}</h2> <h2 class="font-semibold text-ink">{{ __('datacenters.add') }}</h2>
<x-ui.input name="code" wire:model="code" :label="__('datacenters.code')" :hint="__('datacenters.code_hint')" /> <x-ui.input name="code" wire:model="code" :label="__('datacenters.code')" :hint="__('datacenters.code_hint')" />
<x-ui.input name="name" wire:model="name" :label="__('datacenters.name')" placeholder="Falkenstein" /> <x-ui.input name="name" wire:model="name" :label="__('datacenters.name')" placeholder="Falkenstein" />

View File

@ -1,4 +1,4 @@
<div class="rounded-xl bg-surface p-6"> <div class="rounded-lg bg-surface p-6">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h3 class="text-base font-semibold text-ink">{{ __('datacenters.edit_title') }}</h3> <h3 class="text-base font-semibold text-ink">{{ __('datacenters.edit_title') }}</h3>
<span class="rounded bg-surface-2 px-2 py-0.5 font-mono text-xs text-muted">{{ $code }}</span> <span class="rounded bg-surface-2 px-2 py-0.5 font-mono text-xs text-muted">{{ $code }}</span>

View File

@ -3,11 +3,11 @@
<a href="{{ route('admin.hosts') }}" wire:navigate class="inline-flex items-center gap-1.5 text-xs font-medium text-muted hover:text-body"> <a href="{{ route('admin.hosts') }}" wire:navigate class="inline-flex items-center gap-1.5 text-xs font-medium text-muted hover:text-body">
<x-ui.icon name="arrow-left" class="size-4" />{{ __('hosts.back') }} <x-ui.icon name="arrow-left" class="size-4" />{{ __('hosts.back') }}
</a> </a>
<h1 class="mt-2 text-2xl font-semibold tracking-tight text-ink">{{ __('hosts.create_title') }}</h1> <h1 class="mt-2 text-2xl font-bold tracking-tight text-ink">{{ __('hosts.create_title') }}</h1>
<p class="mt-1 text-sm text-muted">{{ __('hosts.create_sub') }}</p> <p class="mt-1 text-sm text-muted">{{ __('hosts.create_sub') }}</p>
</div> </div>
<form wire:submit="save" class="space-y-5 rounded-xl border border-line bg-surface p-6 shadow-xs animate-rise"> <form wire:submit="save" class="space-y-5 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise">
<x-ui.input name="name" wire:model="name" :label="__('hosts.field.name')" :hint="__('hosts.field.name_hint')" autofocus /> <x-ui.input name="name" wire:model="name" :label="__('hosts.field.name')" :hint="__('hosts.field.name_hint')" autofocus />
<div class="space-y-1.5"> <div class="space-y-1.5">

View File

@ -9,7 +9,7 @@
<x-ui.icon name="arrow-left" class="size-4" />{{ __('hosts.back') }} <x-ui.icon name="arrow-left" class="size-4" />{{ __('hosts.back') }}
</a> </a>
<div class="mt-2 flex items-center gap-3"> <div class="mt-2 flex items-center gap-3">
<h1 class="font-mono text-2xl font-semibold tracking-tight text-ink">{{ $host->name }}</h1> <h1 class="font-mono text-2xl font-bold tracking-tight text-ink">{{ $host->name }}</h1>
<x-ui.badge :status="$badge">{{ __('hosts.status.'.$host->status) }}</x-ui.badge> <x-ui.badge :status="$badge">{{ __('hosts.status.'.$host->status) }}</x-ui.badge>
</div> </div>
<p class="mt-1 font-mono text-xs text-muted">{{ $host->public_ip }} · {{ $host->datacenter }}</p> <p class="mt-1 font-mono text-xs text-muted">{{ $host->public_ip }} · {{ $host->datacenter }}</p>
@ -41,8 +41,8 @@
@endphp @endphp
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3 animate-rise"> <div class="grid grid-cols-1 gap-4 lg:grid-cols-3 animate-rise">
{{-- Health --}} {{-- Health --}}
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs"> <div class="rounded-lg border border-line bg-surface p-5 shadow-xs">
<p class="text-xs font-semibold uppercase tracking-wide text-faint">{{ __('hosts.detail.health') }}</p> <p class="text-xs font-semibold text-muted">{{ __('hosts.detail.health') }}</p>
<p class="mt-2 flex items-center gap-2 text-lg font-semibold {{ $htone }}"> <p class="mt-2 flex items-center gap-2 text-lg font-semibold {{ $htone }}">
<span class="size-2.5 rounded-pill {{ $hdot }} {{ $health === 'online' ? 'animate-pulse' : '' }}" aria-hidden="true"></span> <span class="size-2.5 rounded-pill {{ $hdot }} {{ $health === 'online' ? 'animate-pulse' : '' }}" aria-hidden="true"></span>
{{ __('hosts.health.'.$health) }} {{ __('hosts.health.'.$health) }}
@ -53,8 +53,8 @@
</div> </div>
{{-- Storage capacity --}} {{-- Storage capacity --}}
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs"> <div class="rounded-lg border border-line bg-surface p-5 shadow-xs">
<p class="text-xs font-semibold uppercase tracking-wide text-faint">{{ __('hosts.detail.storage') }}</p> <p class="text-xs font-semibold text-muted">{{ __('hosts.detail.storage') }}</p>
@if ($host->total_gb) @if ($host->total_gb)
<p class="mt-2 font-mono text-lg font-semibold text-ink">{{ $host->availableGb() }} <span class="text-sm font-normal text-muted">/ {{ $host->freeGb() }} GB {{ __('hosts.free') }}</span></p> <p class="mt-2 font-mono text-lg font-semibold text-ink">{{ $host->availableGb() }} <span class="text-sm font-normal text-muted">/ {{ $host->freeGb() }} GB {{ __('hosts.free') }}</span></p>
<div class="mt-2 h-2 overflow-hidden rounded-pill bg-surface-2"> <div class="mt-2 h-2 overflow-hidden rounded-pill bg-surface-2">
@ -73,8 +73,8 @@
</div> </div>
{{-- Compute --}} {{-- Compute --}}
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs"> <div class="rounded-lg border border-line bg-surface p-5 shadow-xs">
<p class="text-xs font-semibold uppercase tracking-wide text-faint">{{ __('hosts.detail.compute') }}</p> <p class="text-xs font-semibold text-muted">{{ __('hosts.detail.compute') }}</p>
<div class="mt-2 grid grid-cols-2 gap-3"> <div class="mt-2 grid grid-cols-2 gap-3">
<div> <div>
<p class="font-mono text-lg font-semibold text-ink">{{ $host->cpu_cores ?? '—' }}</p> <p class="font-mono text-lg font-semibold text-ink">{{ $host->cpu_cores ?? '—' }}</p>
@ -96,7 +96,7 @@
['meta.version', $host->pve_version ?? __('hosts.unknown')], ['meta.version', $host->pve_version ?? __('hosts.unknown')],
['detail.instances', (string) $instances->count()], ['detail.instances', (string) $instances->count()],
] as [$key, $value]) ] as [$key, $value])
<div class="rounded-xl border border-line bg-surface p-4 shadow-xs"> <div class="rounded-lg border border-line bg-surface p-4 shadow-xs">
<p class="text-xs text-muted">{{ __('hosts.'.$key) }}</p> <p class="text-xs text-muted">{{ __('hosts.'.$key) }}</p>
<p class="mt-1 truncate font-mono text-sm font-semibold text-ink">{{ $value }}</p> <p class="mt-1 truncate font-mono text-sm font-semibold text-ink">{{ $value }}</p>
</div> </div>
@ -104,7 +104,7 @@
</div> </div>
{{-- Hosted instances --}} {{-- Hosted instances --}}
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:100ms]"> <div class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:100ms]">
<h2 class="mb-3 text-sm font-semibold text-ink">{{ __('hosts.detail.hosted') }}</h2> <h2 class="mb-3 text-sm font-semibold text-ink">{{ __('hosts.detail.hosted') }}</h2>
@if ($instances->isEmpty()) @if ($instances->isEmpty())
<p class="text-sm text-muted">{{ __('hosts.detail.no_instances') }}</p> <p class="text-sm text-muted">{{ __('hosts.detail.no_instances') }}</p>
@ -128,7 +128,7 @@
</div> </div>
@if ($run && $run->status === 'failed') @if ($run && $run->status === 'failed')
<div class="flex items-start gap-3 rounded-xl border border-danger-border bg-danger-bg p-4 animate-rise"> <div class="flex items-start gap-3 rounded-lg border border-danger-border bg-danger-bg p-4 animate-rise">
<x-ui.icon name="alert-triangle" class="size-5 shrink-0 text-danger" /> <x-ui.icon name="alert-triangle" class="size-5 shrink-0 text-danger" />
<div class="min-w-0"> <div class="min-w-0">
<p class="text-sm font-semibold text-danger">{{ __('hosts.error_title') }}</p> <p class="text-sm font-semibold text-danger">{{ __('hosts.error_title') }}</p>
@ -138,7 +138,7 @@
@endif @endif
<div class="grid grid-cols-1 gap-4 lg:grid-cols-5"> <div class="grid grid-cols-1 gap-4 lg:grid-cols-5">
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise lg:col-span-3"> <div class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise lg:col-span-3">
<h2 class="mb-4 text-sm font-semibold text-ink">{{ __('hosts.progress') }}</h2> <h2 class="mb-4 text-sm font-semibold text-ink">{{ __('hosts.progress') }}</h2>
@if ($run) @if ($run)
<x-ui.progress-stepper :steps="$steps" /> <x-ui.progress-stepper :steps="$steps" />
@ -147,7 +147,7 @@
@endif @endif
</div> </div>
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise lg:col-span-2"> <div class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise lg:col-span-2">
<h2 class="mb-4 text-sm font-semibold text-ink">{{ __('hosts.events') }}</h2> <h2 class="mb-4 text-sm font-semibold text-ink">{{ __('hosts.events') }}</h2>
@if ($events->isEmpty()) @if ($events->isEmpty())
<p class="text-sm text-muted">{{ __('hosts.no_run') }}</p> <p class="text-sm text-muted">{{ __('hosts.no_run') }}</p>
@ -161,7 +161,7 @@
<span class="mt-1.5 size-1.5 shrink-0 rounded-pill bg-current {{ $tone }}" aria-hidden="true"></span> <span class="mt-1.5 size-1.5 shrink-0 rounded-pill bg-current {{ $tone }}" aria-hidden="true"></span>
<div class="min-w-0"> <div class="min-w-0">
<p class="text-body">{{ __('hosts.step.'.$event->step) }}</p> <p class="text-body">{{ __('hosts.step.'.$event->step) }}</p>
<p class="text-xs text-faint"> <p class="text-xs text-muted">
{{ __('hosts.outcome.'.$event->outcome) }} {{ __('hosts.outcome.'.$event->outcome) }}
· {{ __('hosts.attempt') }} {{ $event->attempt }} · {{ __('hosts.attempt') }} {{ $event->attempt }}
@if ($event->message) · <span class="break-words">{{ $event->message }}</span> @endif @if ($event->message) · <span class="break-words">{{ $event->message }}</span> @endif

View File

@ -1,7 +1,7 @@
<div class="space-y-5"> <div class="space-y-5">
<div class="flex items-start justify-between gap-4 animate-rise"> <div class="flex items-start justify-between gap-4 animate-rise">
<div> <div>
<h1 class="text-2xl font-semibold tracking-tight text-ink">{{ __('hosts.title') }}</h1> <h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('hosts.title') }}</h1>
<p class="mt-1 text-sm text-muted">{{ __('hosts.subtitle') }}</p> <p class="mt-1 text-sm text-muted">{{ __('hosts.subtitle') }}</p>
</div> </div>
<a href="{{ route('admin.hosts.create') }}" wire:navigate> <a href="{{ route('admin.hosts.create') }}" wire:navigate>
@ -13,7 +13,7 @@
<div class="flex flex-wrap items-center gap-2 animate-rise [animation-delay:40ms]"> <div class="flex flex-wrap items-center gap-2 animate-rise [animation-delay:40ms]">
<div class="relative min-w-48 flex-1"> <div class="relative min-w-48 flex-1">
<input type="search" wire:model.live.debounce.300ms="search" placeholder="{{ __('hosts.search_placeholder') }}" <input type="search" wire:model.live.debounce.300ms="search" placeholder="{{ __('hosts.search_placeholder') }}"
class="w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-ink placeholder:text-faint" /> class="w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-ink placeholder:text-muted" />
</div> </div>
<select wire:model.live="datacenter" class="rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-body"> <select wire:model.live="datacenter" class="rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-body">
<option value="">{{ __('hosts.all_datacenters') }}</option> <option value="">{{ __('hosts.all_datacenters') }}</option>
@ -34,16 +34,16 @@
</div> </div>
@if ($hosts->isEmpty()) @if ($hosts->isEmpty())
<div class="rounded-xl border border-dashed border-line-strong bg-surface p-10 text-center animate-rise"> <div class="rounded-lg border border-dashed border-line-strong bg-surface p-10 text-center animate-rise">
<span class="mx-auto grid size-12 place-items-center rounded-lg bg-surface-2 text-muted"><x-ui.icon name="server" class="size-6" /></span> <span class="mx-auto grid size-12 place-items-center rounded-lg bg-surface-2 text-muted"><x-ui.icon name="server" class="size-6" /></span>
<p class="mt-3 text-sm text-muted">{{ $total === 0 ? __('hosts.empty') : __('hosts.no_matches') }}</p> <p class="mt-3 text-sm text-muted">{{ $total === 0 ? __('hosts.empty') : __('hosts.no_matches') }}</p>
</div> </div>
@else @else
<div class="overflow-hidden rounded-xl border border-line bg-surface shadow-xs animate-rise [animation-delay:80ms]"> <div class="overflow-hidden rounded-lg border border-line bg-surface shadow-xs animate-rise [animation-delay:80ms]">
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="w-full text-sm"> <table class="w-full text-sm">
<thead> <thead>
<tr class="border-b border-line bg-surface-2 text-left text-xs uppercase tracking-wide text-faint"> <tr class="border-b border-line bg-surface-2 text-left text-xs font-semibold text-muted">
<th class="px-4 py-3 font-semibold">{{ __('hosts.col.host') }}</th> <th class="px-4 py-3 font-semibold">{{ __('hosts.col.host') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('hosts.col.datacenter') }}</th> <th class="px-4 py-3 font-semibold">{{ __('hosts.col.datacenter') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('hosts.col.health') }}</th> <th class="px-4 py-3 font-semibold">{{ __('hosts.col.health') }}</th>
@ -86,7 +86,7 @@
<span class="font-mono text-xs text-muted">{{ $host->availableGb() }} / {{ $host->freeGb() }} GB</span> <span class="font-mono text-xs text-muted">{{ $host->availableGb() }} / {{ $host->freeGb() }} GB</span>
</div> </div>
@else @else
<span class="text-xs text-faint">{{ __('hosts.unknown') }}</span> <span class="text-xs text-muted">{{ __('hosts.unknown') }}</span>
@endif @endif
</td> </td>
<td class="px-4 py-3"><x-ui.badge :status="$badge">{{ __('hosts.status.'.$host->status) }}</x-ui.badge></td> <td class="px-4 py-3"><x-ui.badge :status="$badge">{{ __('hosts.status.'.$host->status) }}</x-ui.badge></td>

View File

@ -20,7 +20,7 @@
</div> </div>
</dl> </dl>
<p class="mt-3 text-xs text-faint">{{ __('instances.admin_once') }}</p> <p class="mt-3 text-xs text-muted">{{ __('instances.admin_once') }}</p>
<div class="mt-5 flex justify-end"> <div class="mt-5 flex justify-end">
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('common.close') }}</x-ui.button> <x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('common.close') }}</x-ui.button>

View File

@ -1,14 +1,14 @@
<div class="space-y-5"> <div class="space-y-5">
<div class="animate-rise"> <div class="animate-rise">
<h1 class="text-2xl font-semibold tracking-tight text-ink">{{ __('admin.nav.instances') }}</h1> <h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('admin.nav.instances') }}</h1>
<p class="mt-1 text-sm text-muted">{{ __('admin.instances_sub') }}</p> <p class="mt-1 text-sm text-muted">{{ __('admin.instances_sub') }}</p>
</div> </div>
<div class="overflow-hidden rounded-xl border border-line bg-surface shadow-xs animate-rise [animation-delay:60ms]"> <div class="overflow-hidden rounded-lg border border-line bg-surface shadow-xs animate-rise [animation-delay:60ms]">
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="w-full text-sm"> <table class="w-full text-sm">
<thead> <thead>
<tr class="border-b border-line bg-surface-2 text-left text-xs uppercase tracking-wide text-faint"> <tr class="border-b border-line bg-surface-2 text-left text-xs font-semibold text-muted">
<th class="px-4 py-3 font-semibold">{{ __('admin.col.address') }}</th> <th class="px-4 py-3 font-semibold">{{ __('admin.col.address') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('admin.col.customer') }}</th> <th class="px-4 py-3 font-semibold">{{ __('admin.col.customer') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('admin.col.host') }}</th> <th class="px-4 py-3 font-semibold">{{ __('admin.col.host') }}</th>
@ -30,7 +30,7 @@
<td class="px-4 py-3"><x-ui.badge :status="$r['status']">{{ $r['status_label'] }}</x-ui.badge></td> <td class="px-4 py-3"><x-ui.badge :status="$r['status']">{{ $r['status_label'] }}</x-ui.badge></td>
</tr> </tr>
@empty @empty
<tr><td colspan="7" class="px-4 py-8 text-center text-sm text-faint">{{ __('admin.instances_empty') }}</td></tr> <tr><td colspan="7" class="px-4 py-8 text-center text-sm text-muted">{{ __('admin.instances_empty') }}</td></tr>
@endforelse @endforelse
</tbody> </tbody>
</table> </table>

View File

@ -1,19 +1,19 @@
<div class="space-y-6"> <div class="space-y-6">
<div class="animate-rise"> <div class="animate-rise">
<h1 class="text-2xl font-semibold tracking-tight text-ink">{{ __('maintenance.title') }}</h1> <h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('maintenance.title') }}</h1>
<p class="mt-1 text-sm text-muted">{{ __('maintenance.subtitle') }}</p> <p class="mt-1 text-sm text-muted">{{ __('maintenance.subtitle') }}</p>
</div> </div>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_420px] lg:items-start"> <div class="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_420px] lg:items-start">
{{-- Windows list --}} {{-- Windows list --}}
<div class="overflow-hidden rounded-xl border border-line bg-surface shadow-xs animate-rise [animation-delay:60ms]"> <div class="overflow-hidden rounded-lg border border-line bg-surface shadow-xs animate-rise [animation-delay:60ms]">
@if ($windows->isEmpty()) @if ($windows->isEmpty())
<p class="p-8 text-center text-sm text-muted">{{ __('maintenance.empty') }}</p> <p class="p-8 text-center text-sm text-muted">{{ __('maintenance.empty') }}</p>
@else @else
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="w-full text-sm"> <table class="w-full text-sm">
<thead> <thead>
<tr class="border-b border-line bg-surface-2 text-left text-xs uppercase tracking-wide text-faint"> <tr class="border-b border-line bg-surface-2 text-left text-xs font-semibold text-muted">
<th class="px-4 py-3 font-semibold">{{ __('maintenance.col_window') }}</th> <th class="px-4 py-3 font-semibold">{{ __('maintenance.col_window') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('maintenance.col_when') }}</th> <th class="px-4 py-3 font-semibold">{{ __('maintenance.col_when') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('maintenance.col_impact') }}</th> <th class="px-4 py-3 font-semibold">{{ __('maintenance.col_impact') }}</th>
@ -29,7 +29,7 @@
<td class="px-4 py-3 text-xs text-body">{{ $w['starts_at']->isoFormat('DD.MM. HH:mm') }} {{ $w['ends_at']->isoFormat('HH:mm') }}</td> <td class="px-4 py-3 text-xs text-body">{{ $w['starts_at']->isoFormat('DD.MM. HH:mm') }} {{ $w['ends_at']->isoFormat('HH:mm') }}</td>
<td class="px-4 py-3 text-xs text-muted whitespace-nowrap"> <td class="px-4 py-3 text-xs text-muted whitespace-nowrap">
{{ trans_choice('maintenance.impact_hosts', $w['hosts'], ['count' => $w['hosts']]) }} {{ trans_choice('maintenance.impact_hosts', $w['hosts'], ['count' => $w['hosts']]) }}
<span class="text-faint">·</span> <span class="text-muted">·</span>
{{ trans_choice('maintenance.impact_customers', $w['affected'], ['count' => $w['affected']]) }} {{ trans_choice('maintenance.impact_customers', $w['affected'], ['count' => $w['affected']]) }}
</td> </td>
<td class="px-4 py-3"><x-ui.badge :status="$tone">{{ __('maintenance.state_'.$w['state']) }}</x-ui.badge></td> <td class="px-4 py-3"><x-ui.badge :status="$tone">{{ __('maintenance.state_'.$w['state']) }}</x-ui.badge></td>
@ -69,7 +69,7 @@
</div> </div>
{{-- Create form --}} {{-- Create form --}}
<form wire:submit="publish" class="overflow-hidden rounded-xl border border-line bg-surface shadow-xs animate-rise [animation-delay:120ms]"> <form wire:submit="publish" class="overflow-hidden rounded-lg border border-line bg-surface shadow-xs animate-rise [animation-delay:120ms]">
<div class="border-b border-line bg-surface-2 px-5 py-3.5"> <div class="border-b border-line bg-surface-2 px-5 py-3.5">
<h2 class="font-semibold text-ink">{{ __('maintenance.new_title') }}</h2> <h2 class="font-semibold text-ink">{{ __('maintenance.new_title') }}</h2>
<p class="mt-0.5 text-xs text-muted">{{ __('maintenance.new_sub') }}</p> <p class="mt-0.5 text-xs text-muted">{{ __('maintenance.new_sub') }}</p>
@ -81,14 +81,14 @@
<div> <div>
<label class="text-sm font-medium text-body" for="publicDescription">{{ __('maintenance.field_public') }}</label> <label class="text-sm font-medium text-body" for="publicDescription">{{ __('maintenance.field_public') }}</label>
<textarea id="publicDescription" wire:model="publicDescription" rows="2" placeholder="{{ __('maintenance.field_public_ph') }}" <textarea id="publicDescription" wire:model="publicDescription" rows="2" placeholder="{{ __('maintenance.field_public_ph') }}"
class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-ink placeholder:text-faint focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"></textarea> 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"></textarea>
<p class="mt-1 text-xs text-faint">{{ __('maintenance.field_public_hint') }}</p> <p class="mt-1 text-xs text-muted">{{ __('maintenance.field_public_hint') }}</p>
</div> </div>
<div> <div>
<label class="text-sm font-medium text-body" for="internalNotes">{{ __('maintenance.field_internal') }}</label> <label class="text-sm font-medium text-body" for="internalNotes">{{ __('maintenance.field_internal') }}</label>
<textarea id="internalNotes" wire:model="internalNotes" rows="2" placeholder="{{ __('maintenance.field_internal_ph') }}" <textarea id="internalNotes" wire:model="internalNotes" rows="2" placeholder="{{ __('maintenance.field_internal_ph') }}"
class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-ink placeholder:text-faint focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"></textarea> 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"></textarea>
</div> </div>
<div class="space-y-3"> <div class="space-y-3">
@ -97,7 +97,7 @@
<div> <div>
<label class="text-sm font-medium text-body" for="{{ $field }}">{{ __('maintenance.'.$key) }}</label> <label class="text-sm font-medium text-body" for="{{ $field }}">{{ __('maintenance.'.$key) }}</label>
<div class="relative mt-1.5"> <div class="relative mt-1.5">
<x-ui.icon name="calendar" class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-faint" /> <x-ui.icon name="calendar" class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted" />
<input id="{{ $field }}" type="datetime-local" wire:model.live="{{ $field }}" <input id="{{ $field }}" type="datetime-local" wire:model.live="{{ $field }}"
class="w-full rounded-md border border-line-strong bg-surface py-2 pl-9 pr-3 text-sm text-ink focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30 [color-scheme:dark]" /> class="w-full rounded-md border border-line-strong bg-surface py-2 pl-9 pr-3 text-sm text-ink focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30 [color-scheme:dark]" />
</div> </div>
@ -109,7 +109,7 @@
{{-- Typing an end time by hand is the fiddliest part of this form; {{-- Typing an end time by hand is the fiddliest part of this form;
these set it from the start time. --}} these set it from the start time. --}}
<div class="flex flex-wrap items-center gap-1.5"> <div class="flex flex-wrap items-center gap-1.5">
<span class="text-xs text-faint">{{ __('maintenance.duration') }}</span> <span class="text-xs text-muted">{{ __('maintenance.duration') }}</span>
@foreach ([30, 60, 120, 240] as $minutes) @foreach ([30, 60, 120, 240] as $minutes)
<button type="button" wire:click="setDuration({{ $minutes }})" <button type="button" wire:click="setDuration({{ $minutes }})"
class="rounded-pill border px-2.5 py-0.5 text-xs font-medium transition class="rounded-pill border px-2.5 py-0.5 text-xs font-medium transition
@ -127,7 +127,7 @@
<div> <div>
<div class="flex items-baseline justify-between"> <div class="flex items-baseline justify-between">
<label class="text-sm font-medium text-body">{{ __('maintenance.field_hosts') }}</label> <label class="text-sm font-medium text-body">{{ __('maintenance.field_hosts') }}</label>
<span class="font-mono text-xs {{ count($hostIds) > 0 ? 'text-accent-text' : 'text-faint' }}">{{ __('maintenance.selected', ['n' => count($hostIds)]) }}</span> <span class="font-mono text-xs {{ count($hostIds) > 0 ? 'text-accent-text' : 'text-muted' }}">{{ __('maintenance.selected', ['n' => count($hostIds)]) }}</span>
</div> </div>
@error('hostIds')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror @error('hostIds')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
<div class="mt-1.5 max-h-64 divide-y divide-line overflow-y-auto rounded-lg border border-line-strong bg-surface-2"> <div class="mt-1.5 max-h-64 divide-y divide-line overflow-y-auto rounded-lg border border-line-strong bg-surface-2">
@ -139,7 +139,7 @@
@if ($dcHosts->isNotEmpty()) @if ($dcHosts->isNotEmpty())
<div class="p-2.5"> <div class="p-2.5">
<div class="flex items-center justify-between px-1.5 pb-1.5"> <div class="flex items-center justify-between px-1.5 pb-1.5">
<p class="text-xs font-semibold uppercase tracking-wide text-faint"> <p class="text-xs font-semibold text-muted">
{{ $dc->name }} {{ $dc->name }}
@if ($dcSelected) @if ($dcSelected)
<span class="ml-1 font-mono text-accent-text">{{ $dcSelected }}/{{ $dcHosts->count() }}</span> <span class="ml-1 font-mono text-accent-text">{{ $dcSelected }}/{{ $dcHosts->count() }}</span>
@ -158,7 +158,7 @@
<input type="checkbox" wire:model.live="hostIds" value="{{ $host->id }}" <input type="checkbox" wire:model.live="hostIds" value="{{ $host->id }}"
class="size-4 rounded border-line-strong bg-surface text-accent-active focus:ring-2 focus:ring-accent/30" /> class="size-4 rounded border-line-strong bg-surface text-accent-active focus:ring-2 focus:ring-accent/30" />
<span class="font-mono">{{ $host->name }}</span> <span class="font-mono">{{ $host->name }}</span>
<span class="ml-auto font-mono text-xs {{ $checked ? 'text-accent-text' : 'text-faint' }}">{{ $host->public_ip }}</span> <span class="ml-auto font-mono text-xs {{ $checked ? 'text-accent-text' : 'text-muted' }}">{{ $host->public_ip }}</span>
</label> </label>
@endforeach @endforeach
</div> </div>
@ -170,7 +170,7 @@
</div> </div>
<div class="flex flex-wrap items-center justify-end gap-2 border-t border-line bg-surface-2 px-5 py-3.5"> <div class="flex flex-wrap items-center justify-end gap-2 border-t border-line bg-surface-2 px-5 py-3.5">
<p class="mr-auto max-w-[14rem] text-xs text-faint">{{ __('maintenance.mail_note') }}</p> <p class="mr-auto max-w-[14rem] text-xs text-muted">{{ __('maintenance.mail_note') }}</p>
<x-ui.button variant="secondary" type="button" size="sm" wire:click="saveDraft" wire:loading.attr="disabled" wire:target="saveDraft">{{ __('maintenance.save_draft') }}</x-ui.button> <x-ui.button variant="secondary" type="button" size="sm" wire:click="saveDraft" wire:loading.attr="disabled" wire:target="saveDraft">{{ __('maintenance.save_draft') }}</x-ui.button>
<x-ui.button variant="primary" type="submit" size="sm" wire:loading.attr="disabled" wire:target="publish">{{ __('maintenance.publish_notify') }}</x-ui.button> <x-ui.button variant="primary" type="submit" size="sm" wire:loading.attr="disabled" wire:target="publish">{{ __('maintenance.publish_notify') }}</x-ui.button>
</div> </div>

View File

@ -1,6 +1,6 @@
<div class="space-y-5"> <div class="space-y-5">
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 animate-rise"> <div class="flex flex-wrap items-center gap-x-4 gap-y-1 animate-rise">
<h1 class="text-2xl font-semibold tracking-tight text-ink">{{ __('admin.overview_title') }}</h1> <h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('admin.overview_title') }}</h1>
{{-- Derived from the notice list below, never asserted: a green badge {{-- Derived from the notice list below, never asserted: a green badge
that is not computed is the most expensive kind of decoration. --}} that is not computed is the most expensive kind of decoration. --}}
@if ($noticeCount === 0) @if ($noticeCount === 0)
@ -19,8 +19,8 @@
@php $kd = ['[animation-delay:40ms]', '[animation-delay:80ms]', '[animation-delay:120ms]', '[animation-delay:160ms]']; @endphp @php $kd = ['[animation-delay:40ms]', '[animation-delay:80ms]', '[animation-delay:120ms]', '[animation-delay:160ms]']; @endphp
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4"> <div class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
@foreach ($kpis as $i => $kpi) @foreach ($kpis as $i => $kpi)
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise {{ $kd[$i] ?? '' }}"> <div class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise {{ $kd[$i] ?? '' }}">
<p class="text-xs font-semibold uppercase tracking-wide text-faint">{{ $kpi['label'] }}</p> <p class="text-xs font-semibold text-muted">{{ $kpi['label'] }}</p>
<p class="mt-2 font-mono text-2xl font-semibold text-ink">{{ $kpi['value'] }}</p> <p class="mt-2 font-mono text-2xl font-semibold text-ink">{{ $kpi['value'] }}</p>
@if (($kpi['sub'] ?? null) !== null) @if (($kpi['sub'] ?? null) !== null)
<p class="mt-1 text-xs text-muted">{{ $kpi['sub'] }}</p> <p class="mt-1 text-xs text-muted">{{ $kpi['sub'] }}</p>
@ -31,18 +31,18 @@
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3"> <div class="grid grid-cols-1 gap-4 lg:grid-cols-3">
{{-- New instances per month --}} {{-- New instances per month --}}
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:60ms] lg:col-span-2"> <div class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:60ms] lg:col-span-2">
<h2 class="font-semibold text-ink">{{ __('admin.new_instances') }}</h2> <h2 class="font-semibold text-ink">{{ __('admin.new_instances') }}</h2>
<p class="text-sm text-muted">{{ __('admin.new_instances_sub') }}</p> <p class="text-sm text-muted">{{ __('admin.new_instances_sub') }}</p>
@if ($newInstances['empty']) @if ($newInstances['empty'])
<p class="mt-8 text-center text-sm text-faint">{{ __('admin.no_data_yet') }}</p> <p class="mt-8 text-center text-sm text-muted">{{ __('admin.no_data_yet') }}</p>
@else @else
<div class="mt-4 h-56" wire:ignore><x-ui.chart :config="$newInstances['config']" class="h-56" :label="__('admin.new_instances')" /></div> <div class="mt-4 h-56" wire:ignore><x-ui.chart :config="$newInstances['config']" class="h-56" :label="__('admin.new_instances')" /></div>
@endif @endif
</div> </div>
{{-- Committed storage per host --}} {{-- Committed storage per host --}}
<div class="flex flex-col rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:120ms]"> <div class="flex flex-col rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:120ms]">
<h2 class="font-semibold text-ink">{{ __('admin.host_load') }}</h2> <h2 class="font-semibold text-ink">{{ __('admin.host_load') }}</h2>
<p class="text-sm text-muted">{{ __('admin.host_load_sub') }}</p> <p class="text-sm text-muted">{{ __('admin.host_load_sub') }}</p>
<ul class="mt-4 flex-1 space-y-3.5"> <ul class="mt-4 flex-1 space-y-3.5">
@ -58,7 +58,7 @@
</div> </div>
</li> </li>
@empty @empty
<li class="text-sm text-faint">{{ __('admin.no_hosts_yet') }}</li> <li class="text-sm text-muted">{{ __('admin.no_hosts_yet') }}</li>
@endforelse @endforelse
</ul> </ul>
</div> </div>
@ -66,7 +66,7 @@
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2"> <div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
{{-- Open provisioning runs --}} {{-- Open provisioning runs --}}
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:60ms]"> <div class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:60ms]">
<div class="flex items-center"> <div class="flex items-center">
<h2 class="font-semibold text-ink">{{ __('admin.active_runs') }}</h2> <h2 class="font-semibold text-ink">{{ __('admin.active_runs') }}</h2>
<a href="{{ route('admin.provisioning') }}" class="ml-auto text-xs font-semibold text-accent-text hover:underline">{{ __('admin.view_all') }} </a> <a href="{{ route('admin.provisioning') }}" class="ml-auto text-xs font-semibold text-accent-text hover:underline">{{ __('admin.view_all') }} </a>
@ -82,13 +82,13 @@
<x-ui.badge :status="$r['status'] === 'failed' ? 'error' : 'provisioning'">{{ __('admin.run_status.'.$r['status']) }}</x-ui.badge> <x-ui.badge :status="$r['status'] === 'failed' ? 'error' : 'provisioning'">{{ __('admin.run_status.'.$r['status']) }}</x-ui.badge>
</li> </li>
@empty @empty
<li class="py-3 text-sm text-faint">{{ __('admin.no_open_runs') }}</li> <li class="py-3 text-sm text-muted">{{ __('admin.no_open_runs') }}</li>
@endforelse @endforelse
</ul> </ul>
</div> </div>
{{-- Notices --}} {{-- Notices --}}
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:120ms]"> <div class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:120ms]">
<h2 class="font-semibold text-ink">{{ __('admin.alerts') }}</h2> <h2 class="font-semibold text-ink">{{ __('admin.alerts') }}</h2>
<ul class="mt-2 space-y-2"> <ul class="mt-2 space-y-2">
@forelse ($notices as $a) @forelse ($notices as $a)
@ -97,7 +97,7 @@
<span class="text-body">{{ $a['text'] }}</span> <span class="text-body">{{ $a['text'] }}</span>
</li> </li>
@empty @empty
<li class="py-1 text-sm text-faint">{{ __('admin.no_notices') }}</li> <li class="py-1 text-sm text-muted">{{ __('admin.no_notices') }}</li>
@endforelse @endforelse
</ul> </ul>
</div> </div>

View File

@ -7,7 +7,7 @@
<x-ui.icon name="arrow-left" class="size-3.5" /> <x-ui.icon name="arrow-left" class="size-3.5" />
{{ __('plans.back') }} {{ __('plans.back') }}
</a> </a>
<h1 class="mt-2 text-2xl font-semibold tracking-tight text-ink">{{ $family->name }}</h1> <h1 class="mt-2 text-2xl font-bold tracking-tight text-ink">{{ $family->name }}</h1>
<p class="mt-1 text-sm text-muted">{{ __('plans.versions_subtitle') }}</p> <p class="mt-1 text-sm text-muted">{{ __('plans.versions_subtitle') }}</p>
</div> </div>
@ -30,7 +30,7 @@
$live = $windowOpen && $family->sales_enabled; $live = $windowOpen && $family->sales_enabled;
$scheduled = $version->isPublished() && $version->available_from->greaterThan($now); $scheduled = $version->isPublished() && $version->available_from->greaterThan($now);
@endphp @endphp
<div wire:key="v-{{ $version->uuid }}" class="rounded-xl border border-line bg-surface p-5 shadow-xs <div wire:key="v-{{ $version->uuid }}" class="rounded-lg border border-line bg-surface p-5 shadow-xs
{{ $live ? 'border-success-border' : '' }}"> {{ $live ? 'border-success-border' : '' }}">
<div class="flex flex-wrap items-start justify-between gap-3"> <div class="flex flex-wrap items-start justify-between gap-3">
<div> <div>
@ -105,7 +105,7 @@
['plans.f_template', $version->template_vmid ?? '—'], ['plans.f_template', $version->template_vmid ?? '—'],
] as [$label, $value]) ] as [$label, $value])
<div> <div>
<dt class="text-xs uppercase tracking-wide text-faint">{{ __($label) }}</dt> <dt class="text-xs font-semibold text-muted">{{ __($label) }}</dt>
<dd class="text-body">{{ $value }}</dd> <dd class="text-body">{{ $value }}</dd>
</div> </div>
@endforeach @endforeach
@ -114,9 +114,9 @@
<div class="mt-4 flex flex-wrap gap-4 border-t border-line pt-3 text-sm"> <div class="mt-4 flex flex-wrap gap-4 border-t border-line pt-3 text-sm">
@foreach ($version->prices->sortBy('term') as $price) @foreach ($version->prices->sortBy('term') as $price)
<span class="text-body"> <span class="text-body">
<span class="text-xs uppercase tracking-wide text-faint">{{ __('plans.term_'.$price->term) }}</span> <span class="text-xs font-semibold text-muted">{{ __('plans.term_'.$price->term) }}</span>
<span class="ml-1.5 font-semibold text-ink">{{ $eur($price->amount_cents) }}</span> <span class="ml-1.5 font-semibold text-ink">{{ $eur($price->amount_cents) }}</span>
<span class="text-xs text-faint">{{ __('plans.net') }}</span> <span class="text-xs text-muted">{{ __('plans.net') }}</span>
</span> </span>
@endforeach @endforeach
</div> </div>
@ -132,14 +132,14 @@
@endif @endif
</div> </div>
@empty @empty
<p class="rounded-xl border border-line bg-surface p-8 text-center text-sm text-muted shadow-xs"> <p class="rounded-lg border border-line bg-surface p-8 text-center text-sm text-muted shadow-xs">
{{ __('plans.no_versions') }} {{ __('plans.no_versions') }}
</p> </p>
@endforelse @endforelse
</div> </div>
{{-- New draft --}} {{-- New draft --}}
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:120ms]"> <div class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:120ms]">
<h2 class="text-base font-semibold text-ink">{{ __('plans.draft_title') }}</h2> <h2 class="text-base font-semibold text-ink">{{ __('plans.draft_title') }}</h2>
<p class="mt-1 text-sm text-muted">{{ __('plans.draft_body') }}</p> <p class="mt-1 text-sm text-muted">{{ __('plans.draft_body') }}</p>
@ -162,7 +162,7 @@
</div> </div>
</div> </div>
<p class="pt-1 text-xs font-semibold uppercase tracking-wide text-faint">{{ __('plans.infra') }}</p> <p class="pt-1 text-xs font-semibold text-muted">{{ __('plans.infra') }}</p>
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3">
<x-ui.input name="ramMb" type="number" min="512" max="4194304" :label="__('plans.f_ram')" wire:model="ramMb" /> <x-ui.input name="ramMb" type="number" min="512" max="4194304" :label="__('plans.f_ram')" wire:model="ramMb" />
<x-ui.input name="cores" type="number" min="1" max="512" :label="__('plans.f_cores')" wire:model="cores" /> <x-ui.input name="cores" type="number" min="1" max="512" :label="__('plans.f_cores')" wire:model="cores" />
@ -170,13 +170,13 @@
<x-ui.input name="templateVmid" type="number" min="100" max="999999999" :label="__('plans.f_template')" wire:model="templateVmid" /> <x-ui.input name="templateVmid" type="number" min="100" max="999999999" :label="__('plans.f_template')" wire:model="templateVmid" />
</div> </div>
<p class="pt-1 text-xs font-semibold uppercase tracking-wide text-faint">{{ __('plans.pricing') }}</p> <p class="pt-1 text-xs font-semibold text-muted">{{ __('plans.pricing') }}</p>
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3">
<x-ui.input name="monthlyPrice" inputmode="decimal" :label="__('plans.term_monthly')" :hint="__('plans.euro_hint')" wire:model="monthlyPrice" /> <x-ui.input name="monthlyPrice" inputmode="decimal" :label="__('plans.term_monthly')" :hint="__('plans.euro_hint')" wire:model="monthlyPrice" />
<x-ui.input name="yearlyPrice" inputmode="decimal" :label="__('plans.term_yearly')" :hint="__('plans.euro_hint')" wire:model="yearlyPrice" /> <x-ui.input name="yearlyPrice" inputmode="decimal" :label="__('plans.term_yearly')" :hint="__('plans.euro_hint')" wire:model="yearlyPrice" />
</div> </div>
<p class="pt-1 text-xs font-semibold uppercase tracking-wide text-faint">{{ __('plans.features') }}</p> <p class="pt-1 text-xs font-semibold text-muted">{{ __('plans.features') }}</p>
<div class="space-y-2"> <div class="space-y-2">
@foreach ($featureKeys as $key) @foreach ($featureKeys as $key)
<div wire:key="feat-{{ $key }}"> <div wire:key="feat-{{ $key }}">

View File

@ -1,19 +1,19 @@
<div class="space-y-5"> <div class="space-y-5">
<div class="animate-rise"> <div class="animate-rise">
<h1 class="text-2xl font-semibold tracking-tight text-ink">{{ __('plans.title') }}</h1> <h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('plans.title') }}</h1>
<p class="mt-1 text-sm text-muted">{{ __('plans.subtitle') }}</p> <p class="mt-1 text-sm text-muted">{{ __('plans.subtitle') }}</p>
</div> </div>
<div class="grid grid-cols-1 gap-5 lg:grid-cols-[1fr_320px] lg:items-start"> <div class="grid grid-cols-1 gap-5 lg:grid-cols-[1fr_320px] lg:items-start">
{{-- Plan lines --}} {{-- Plan lines --}}
<div class="overflow-hidden rounded-xl border border-line bg-surface shadow-xs animate-rise [animation-delay:60ms]"> <div class="overflow-hidden rounded-lg border border-line bg-surface shadow-xs animate-rise [animation-delay:60ms]">
@if ($families->isEmpty()) @if ($families->isEmpty())
<p class="p-8 text-center text-sm text-muted">{{ __('plans.empty') }}</p> <p class="p-8 text-center text-sm text-muted">{{ __('plans.empty') }}</p>
@else @else
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="w-full text-sm"> <table class="w-full text-sm">
<thead> <thead>
<tr class="border-b border-line bg-surface-2 text-left text-xs uppercase tracking-wide text-faint"> <tr class="border-b border-line bg-surface-2 text-left text-xs font-semibold text-muted">
<th class="px-4 py-3 font-semibold">{{ __('plans.plan') }}</th> <th class="px-4 py-3 font-semibold">{{ __('plans.plan') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('plans.tier') }}</th> <th class="px-4 py-3 font-semibold">{{ __('plans.tier') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('plans.live_version') }}</th> <th class="px-4 py-3 font-semibold">{{ __('plans.live_version') }}</th>
@ -26,7 +26,7 @@
<tr wire:key="plan-{{ $family['uuid'] }}" class="border-b border-line last:border-0"> <tr wire:key="plan-{{ $family['uuid'] }}" class="border-b border-line last:border-0">
<td class="px-4 py-3"> <td class="px-4 py-3">
<span class="font-semibold text-ink">{{ $family['name'] }}</span> <span class="font-semibold text-ink">{{ $family['name'] }}</span>
<span class="ml-1.5 font-mono text-xs text-faint">{{ $family['key'] }}</span> <span class="ml-1.5 font-mono text-xs text-muted">{{ $family['key'] }}</span>
@if ($family['drafts'] > 0) @if ($family['drafts'] > 0)
<span class="ml-2 rounded-pill border border-line-strong bg-surface-2 px-2 py-0.5 text-xs text-muted"> <span class="ml-2 rounded-pill border border-line-strong bg-surface-2 px-2 py-0.5 text-xs text-muted">
{{ trans_choice('plans.drafts', $family['drafts'], ['count' => $family['drafts']]) }} {{ trans_choice('plans.drafts', $family['drafts'], ['count' => $family['drafts']]) }}
@ -38,12 +38,12 @@
@if ($family['live']) @if ($family['live'])
v{{ $family['live']->version }} v{{ $family['live']->version }}
@if ($family['next']) @if ($family['next'])
<span class="block text-xs text-faint"> <span class="block text-xs text-muted">
{{ __('plans.next_from', ['version' => $family['next']->version, 'date' => $family['next']->available_from->isoFormat('L')]) }} {{ __('plans.next_from', ['version' => $family['next']->version, 'date' => $family['next']->available_from->isoFormat('L')]) }}
</span> </span>
@endif @endif
@else @else
<span class="text-faint">{{ __('plans.no_live_version') }}</span> <span class="text-muted">{{ __('plans.no_live_version') }}</span>
@endif @endif
</td> </td>
<td class="px-4 py-3"> <td class="px-4 py-3">
@ -71,7 +71,7 @@
</div> </div>
{{-- New plan line --}} {{-- New plan line --}}
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:120ms]"> <div class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:120ms]">
<h2 class="text-base font-semibold text-ink">{{ __('plans.new_title') }}</h2> <h2 class="text-base font-semibold text-ink">{{ __('plans.new_title') }}</h2>
<p class="mt-1 text-sm text-muted">{{ __('plans.new_body') }}</p> <p class="mt-1 text-sm text-muted">{{ __('plans.new_body') }}</p>

View File

@ -1,18 +1,18 @@
<div class="space-y-5" @if ($hasActive) wire:poll.4s @endif> <div class="space-y-5" @if ($hasActive) wire:poll.4s @endif>
<div class="animate-rise"> <div class="animate-rise">
<h1 class="text-2xl font-semibold tracking-tight text-ink">{{ __('admin.nav.provisioning') }}</h1> <h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('admin.nav.provisioning') }}</h1>
<p class="mt-1 text-sm text-muted">{{ __('admin.provisioning_sub') }}</p> <p class="mt-1 text-sm text-muted">{{ __('admin.provisioning_sub') }}</p>
</div> </div>
<div class="grid grid-cols-1 gap-5 lg:grid-cols-[1fr_300px] lg:items-start"> <div class="grid grid-cols-1 gap-5 lg:grid-cols-[1fr_300px] lg:items-start">
<div class="overflow-hidden rounded-xl border border-line bg-surface shadow-xs animate-rise [animation-delay:60ms]"> <div class="overflow-hidden rounded-lg border border-line bg-surface shadow-xs animate-rise [animation-delay:60ms]">
@if (count($rows) === 0) @if (count($rows) === 0)
<p class="p-8 text-center text-sm text-muted">{{ __('admin.no_runs') }}</p> <p class="p-8 text-center text-sm text-muted">{{ __('admin.no_runs') }}</p>
@else @else
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="w-full text-sm"> <table class="w-full text-sm">
<thead> <thead>
<tr class="border-b border-line bg-surface-2 text-left text-xs uppercase tracking-wide text-faint"> <tr class="border-b border-line bg-surface-2 text-left text-xs font-semibold text-muted">
<th class="px-4 py-3 font-semibold">{{ __('admin.col.customer') }}</th> <th class="px-4 py-3 font-semibold">{{ __('admin.col.customer') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('admin.col.step') }}</th> <th class="px-4 py-3 font-semibold">{{ __('admin.col.step') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('admin.col.attempt') }}</th> <th class="px-4 py-3 font-semibold">{{ __('admin.col.attempt') }}</th>
@ -25,17 +25,17 @@
@php $tone = ['running' => 'provisioning', 'done' => 'active', 'failed' => 'failed'][$r['state']] ?? 'info'; @endphp @php $tone = ['running' => 'provisioning', 'done' => 'active', 'failed' => 'failed'][$r['state']] ?? 'info'; @endphp
<tr wire:key="run-{{ $r['uuid'] }}" class="border-b border-line last:border-0 hover:bg-surface-hover"> <tr wire:key="run-{{ $r['uuid'] }}" class="border-b border-line last:border-0 hover:bg-surface-hover">
<td class="px-4 py-3 font-medium text-ink">{{ $r['customer'] }} <td class="px-4 py-3 font-medium text-ink">{{ $r['customer'] }}
<span class="ml-1 font-mono text-xs text-faint">{{ $r['pipeline'] }}</span></td> <span class="ml-1 font-mono text-xs text-muted">{{ $r['pipeline'] }}</span></td>
<td class="px-4 py-3"> <td class="px-4 py-3">
<div class="flex items-baseline gap-1.5"> <div class="flex items-baseline gap-1.5">
<span class="text-xs text-body">{{ $r['step'] }}</span> <span class="text-xs text-body">{{ $r['step'] }}</span>
<span class="font-mono text-xs text-faint">{{ $r['n'] }}</span> <span class="font-mono text-xs text-muted">{{ $r['n'] }}</span>
</div> </div>
<div class="mt-1.5 flex items-center gap-2"> <div class="mt-1.5 flex items-center gap-2">
<div class="h-1 w-24 overflow-hidden rounded-pill bg-surface-2"> <div class="h-1 w-24 overflow-hidden rounded-pill bg-surface-2">
<div class="h-full rounded-pill {{ $r['failed'] ? 'bg-danger' : ($r['stale'] ? 'bg-warning' : 'bg-accent') }} transition-[width] duration-500" style="width: {{ $r['percent'] }}%"></div> <div class="h-full rounded-pill {{ $r['failed'] ? 'bg-danger' : ($r['stale'] ? 'bg-warning' : 'bg-accent') }} transition-[width] duration-500" style="width: {{ $r['percent'] }}%"></div>
</div> </div>
<span class="font-mono text-[0.65rem] {{ $r['stale'] ? 'text-warning' : 'text-faint' }}">{{ $r['activity'] }}</span> <span class="font-mono text-[0.65rem] {{ $r['stale'] ? 'text-warning' : 'text-muted' }}">{{ $r['activity'] }}</span>
</div> </div>
</td> </td>
<td class="px-4 py-3 font-mono text-xs text-muted">{{ $r['attempt'] }}</td> <td class="px-4 py-3 font-mono text-xs text-muted">{{ $r['attempt'] }}</td>
@ -49,7 +49,7 @@
<x-ui.icon name="rotate-ccw" class="size-3.5" />{{ __('admin.retry') }} <x-ui.icon name="rotate-ccw" class="size-3.5" />{{ __('admin.retry') }}
</button> </button>
@else @else
<span class="text-xs text-faint"></span> <span class="text-xs text-muted"></span>
@endif @endif
</td> </td>
</tr> </tr>
@ -60,9 +60,9 @@
@endif @endif
</div> </div>
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:120ms]"> <div class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:120ms]">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h2 class="text-xs font-semibold uppercase tracking-wider text-faint">{{ __('admin.run_detail') }}</h2> <h2 class="text-xs font-semibold uppercase tracking-wider text-muted">{{ __('admin.run_detail') }}</h2>
@if ($panel) @if ($panel)
<span class="font-mono text-xs {{ $panel['failed'] ? 'text-danger' : 'text-muted' }}">{{ $panel['percent'] }}%</span> <span class="font-mono text-xs {{ $panel['failed'] ? 'text-danger' : 'text-muted' }}">{{ $panel['percent'] }}%</span>
@endif @endif
@ -72,7 +72,7 @@
<p class="mt-4 text-sm text-muted">{{ __('admin.no_runs') }}</p> <p class="mt-4 text-sm text-muted">{{ __('admin.no_runs') }}</p>
@else @else
<p class="mt-2 truncate font-semibold text-ink">{{ $panel['subject'] }}</p> <p class="mt-2 truncate font-semibold text-ink">{{ $panel['subject'] }}</p>
<p class="font-mono text-xs text-faint">{{ $panel['pipeline'] }} · {{ __('admin.run_attempt', ['n' => $panel['attempt']]) }}</p> <p class="font-mono text-xs text-muted">{{ $panel['pipeline'] }} · {{ __('admin.run_attempt', ['n' => $panel['attempt']]) }}</p>
{{-- Progress bar (width is the one allowed inline style, R4) --}} {{-- Progress bar (width is the one allowed inline style, R4) --}}
<div class="mt-4 h-1.5 overflow-hidden rounded-pill bg-surface-2"> <div class="mt-4 h-1.5 overflow-hidden rounded-pill bg-surface-2">
@ -83,9 +83,9 @@
<dl class="mt-3 space-y-1 text-xs"> <dl class="mt-3 space-y-1 text-xs">
@if ($panel['started']) @if ($panel['started'])
<div class="flex justify-between"><dt class="text-faint">{{ __('admin.run_started') }}</dt><dd class="font-mono text-muted">{{ $panel['started'] }}</dd></div> <div class="flex justify-between"><dt class="text-muted">{{ __('admin.run_started') }}</dt><dd class="font-mono text-muted">{{ $panel['started'] }}</dd></div>
@endif @endif
<div class="flex justify-between"><dt class="text-faint">{{ __('admin.run_activity') }}</dt><dd class="font-mono {{ $panel['stale'] ? 'text-warning' : 'text-muted' }}">{{ $panel['activity'] }}</dd></div> <div class="flex justify-between"><dt class="text-muted">{{ __('admin.run_activity') }}</dt><dd class="font-mono {{ $panel['stale'] ? 'text-warning' : 'text-muted' }}">{{ $panel['activity'] }}</dd></div>
</dl> </dl>
@if ($panel['stale'] && ! $panel['failed']) @if ($panel['stale'] && ! $panel['failed'])
@ -106,7 +106,7 @@
</div> </div>
@elseif ($panel['current']) @elseif ($panel['current'])
<div class="mt-4"> <div class="mt-4">
<p class="text-[0.65rem] font-semibold uppercase tracking-wider text-faint">{{ __('admin.run_current') }}</p> <p class="text-[0.65rem] font-semibold uppercase tracking-wider text-muted">{{ __('admin.run_current') }}</p>
<div class="mt-1.5 flex items-center gap-2.5"> <div class="mt-1.5 flex items-center gap-2.5">
<span class="size-2 shrink-0 rounded-pill bg-info motion-safe:animate-pulse" aria-hidden="true"></span> <span class="size-2 shrink-0 rounded-pill bg-info motion-safe:animate-pulse" aria-hidden="true"></span>
<span class="text-sm font-medium text-ink">{{ $panel['current'] }}</span> <span class="text-sm font-medium text-ink">{{ $panel['current'] }}</span>
@ -116,7 +116,7 @@
@if ($panel['next'] && ! $panel['failed']) @if ($panel['next'] && ! $panel['failed'])
<div class="mt-4"> <div class="mt-4">
<p class="text-[0.65rem] font-semibold uppercase tracking-wider text-faint">{{ __('admin.run_next') }}</p> <p class="text-[0.65rem] font-semibold uppercase tracking-wider text-muted">{{ __('admin.run_next') }}</p>
<div class="mt-1.5 flex items-center gap-2.5"> <div class="mt-1.5 flex items-center gap-2.5">
<span class="size-2 shrink-0 rounded-pill border border-line-strong" aria-hidden="true"></span> <span class="size-2 shrink-0 rounded-pill border border-line-strong" aria-hidden="true"></span>
<span class="text-sm text-muted">{{ $panel['next'] }}</span> <span class="text-sm text-muted">{{ $panel['next'] }}</span>

View File

@ -1,14 +1,14 @@
<div class="space-y-5"> <div class="space-y-5">
<div class="animate-rise"> <div class="animate-rise">
<h1 class="text-2xl font-semibold tracking-tight text-ink">{{ __('admin.nav.revenue') }}</h1> <h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('admin.nav.revenue') }}</h1>
<p class="mt-1 text-sm text-muted">{{ __('admin.revenue_sub') }}</p> <p class="mt-1 text-sm text-muted">{{ __('admin.revenue_sub') }}</p>
</div> </div>
@php $rd = ['[animation-delay:40ms]', '[animation-delay:80ms]', '[animation-delay:120ms]', '[animation-delay:160ms]']; @endphp @php $rd = ['[animation-delay:40ms]', '[animation-delay:80ms]', '[animation-delay:120ms]', '[animation-delay:160ms]']; @endphp
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4"> <div class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
@foreach ($kpis as $i => $kpi) @foreach ($kpis as $i => $kpi)
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise {{ $rd[$i] ?? '' }}"> <div class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise {{ $rd[$i] ?? '' }}">
<p class="text-xs font-semibold uppercase tracking-wide text-faint">{{ $kpi['label'] }}</p> <p class="text-xs font-semibold text-muted">{{ $kpi['label'] }}</p>
<p class="mt-2 font-mono text-2xl font-semibold text-ink">{{ $kpi['value'] }}</p> <p class="mt-2 font-mono text-2xl font-semibold text-ink">{{ $kpi['value'] }}</p>
<p class="mt-1 text-xs text-muted">{{ $kpi['sub'] }}</p> <p class="mt-1 text-xs text-muted">{{ $kpi['sub'] }}</p>
</div> </div>
@ -16,32 +16,32 @@
</div> </div>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3"> <div class="grid grid-cols-1 gap-4 lg:grid-cols-3">
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:60ms]"> <div class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:60ms]">
<h2 class="font-semibold text-ink">{{ __('admin.mrr_by_plan') }}</h2> <h2 class="font-semibold text-ink">{{ __('admin.mrr_by_plan') }}</h2>
@forelse ($planCharts as $chart) @forelse ($planCharts as $chart)
{{-- One ring per currency: a doughnut sums its slices, and a {{-- One ring per currency: a doughnut sums its slices, and a
total mixing euros with francs is not an amount. --}} total mixing euros with francs is not an amount. --}}
@if (count($planCharts) > 1) @if (count($planCharts) > 1)
<p class="mt-3 font-mono text-xs uppercase tracking-wide text-faint">{{ $chart['currency'] }}</p> <p class="mt-3 font-mono text-xs font-semibold text-muted">{{ $chart['currency'] }}</p>
@endif @endif
<div class="mt-3 h-56" wire:ignore><x-ui.chart :config="$chart['config']" class="h-56" :label="__('admin.mrr_by_plan').' — '.$chart['currency']" /></div> <div class="mt-3 h-56" wire:ignore><x-ui.chart :config="$chart['config']" class="h-56" :label="__('admin.mrr_by_plan').' — '.$chart['currency']" /></div>
@empty @empty
<p class="mt-8 text-center text-sm text-faint">{{ __('admin.no_data_yet') }}</p> <p class="mt-8 text-center text-sm text-muted">{{ __('admin.no_data_yet') }}</p>
@endforelse @endforelse
</div> </div>
<div class="overflow-hidden rounded-xl border border-line bg-surface shadow-xs animate-rise [animation-delay:120ms] lg:col-span-2"> <div class="overflow-hidden rounded-lg border border-line bg-surface shadow-xs animate-rise [animation-delay:120ms] lg:col-span-2">
<div class="border-b border-line px-5 py-3"><h2 class="font-semibold text-ink">{{ __('admin.recent_payments') }}</h2></div> <div class="border-b border-line px-5 py-3"><h2 class="font-semibold text-ink">{{ __('admin.recent_payments') }}</h2></div>
<ul class="divide-y divide-line"> <ul class="divide-y divide-line">
@forelse ($payments as $p) @forelse ($payments as $p)
<li class="flex items-center gap-3 px-5 py-3 text-sm"> <li class="flex items-center gap-3 px-5 py-3 text-sm">
<span class="grid size-8 shrink-0 place-items-center rounded-lg bg-surface-2 text-success"><x-ui.icon name="check" class="size-4" /></span> <span class="grid size-8 shrink-0 place-items-center rounded-lg bg-surface-2 text-success"><x-ui.icon name="check" class="size-4" /></span>
<span class="min-w-0 flex-1 truncate font-medium text-ink">{{ $p['customer'] }}</span> <span class="min-w-0 flex-1 truncate font-medium text-ink">{{ $p['customer'] }}</span>
<span class="font-mono text-xs text-faint">{{ $p['when'] }}</span> <span class="font-mono text-xs text-muted">{{ $p['when'] }}</span>
<span class="font-mono text-body">{{ $p['amount'] }}</span> <span class="font-mono text-body">{{ $p['amount'] }}</span>
</li> </li>
@empty @empty
<li class="px-5 py-8 text-center text-sm text-faint">{{ __('admin.no_payments_yet') }}</li> <li class="px-5 py-8 text-center text-sm text-muted">{{ __('admin.no_payments_yet') }}</li>
@endforelse @endforelse
</ul> </ul>
</div> </div>

View File

@ -1,6 +1,6 @@
<div class="mx-auto max-w-3xl space-y-6"> <div class="mx-auto max-w-3xl space-y-6">
<div class="animate-rise"> <div class="animate-rise">
<h1 class="text-2xl font-semibold tracking-tight text-ink">{{ __('secrets.title') }}</h1> <h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('secrets.title') }}</h1>
<p class="mt-1 text-sm text-muted">{{ __('secrets.subtitle') }}</p> <p class="mt-1 text-sm text-muted">{{ __('secrets.subtitle') }}</p>
</div> </div>
@ -12,7 +12,7 @@
{{-- The second gate. Being signed in is not enough to read a payment {{-- 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 key: the realistic threat is an unlocked machine, and a session is
exactly what that hands over. --}} exactly what that hands over. --}}
<form wire:submit="confirmPassword" class="rounded-xl border border-line bg-surface p-6 shadow-xs animate-rise"> <form wire:submit="confirmPassword" class="rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise">
<h2 class="font-semibold text-ink">{{ __('secrets.locked_title') }}</h2> <h2 class="font-semibold text-ink">{{ __('secrets.locked_title') }}</h2>
<p class="mt-1.5 max-w-xl text-sm text-muted">{{ __('secrets.locked_body') }}</p> <p class="mt-1.5 max-w-xl text-sm text-muted">{{ __('secrets.locked_body') }}</p>
@ -34,7 +34,7 @@
</div> </div>
@foreach ($entries as $entry) @foreach ($entries as $entry)
<div wire:key="secret-{{ $entry['key'] }}" class="space-y-4 rounded-xl border border-line bg-surface p-6 shadow-xs animate-rise"> <div wire:key="secret-{{ $entry['key'] }}" class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise">
<div class="flex flex-wrap items-start justify-between gap-3"> <div class="flex flex-wrap items-start justify-between gap-3">
<div> <div>
<h2 class="font-semibold text-ink">{{ $entry['label'] }}</h2> <h2 class="font-semibold text-ink">{{ $entry['label'] }}</h2>
@ -99,7 +99,7 @@
@endforeach @endforeach
@if ($check !== null) @if ($check !== null)
<div class="rounded-xl border border-line bg-surface p-6 shadow-xs animate-rise"> <div class="rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise">
<h2 class="font-semibold text-ink">{{ __('secrets.check_title') }}</h2> <h2 class="font-semibold text-ink">{{ __('secrets.check_title') }}</h2>
@if (! $check['ok']) @if (! $check['ok'])

View File

@ -1,6 +1,6 @@
<div class="mx-auto max-w-3xl space-y-6"> <div class="mx-auto max-w-3xl space-y-6">
<div class="animate-rise"> <div class="animate-rise">
<h1 class="text-2xl font-semibold tracking-tight text-ink">{{ __('admin_settings.title') }}</h1> <h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('admin_settings.title') }}</h1>
<p class="mt-1 text-sm text-muted">{{ __('admin_settings.subtitle') }}</p> <p class="mt-1 text-sm text-muted">{{ __('admin_settings.subtitle') }}</p>
</div> </div>
@ -9,7 +9,7 @@
{{-- Polls itself: an operator watching an update run should not have to {{-- Polls itself: an operator watching an update run should not have to
reload the page to find out whether it finished. Fast while reload the page to find out whether it finished. Fast while
something is happening, slow when nothing is. --}} something is happening, slow when nothing is. --}}
<div class="rounded-xl border border-line bg-surface p-6 shadow-xs animate-rise" <div class="rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise"
wire:poll.{{ $update['running'] ? '3s' : '30s' }}> wire:poll.{{ $update['running'] ? '3s' : '30s' }}>
<div class="flex flex-wrap items-start justify-between gap-4"> <div class="flex flex-wrap items-start justify-between gap-4">
<div class="min-w-0"> <div class="min-w-0">
@ -130,7 +130,7 @@
{{-- My account --}} {{-- My account --}}
@if ($canManageSite) @if ($canManageSite)
<div class="rounded-xl border border-line bg-surface p-6 shadow-xs animate-rise"> <div class="rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise">
<div class="flex flex-wrap items-start justify-between gap-4"> <div class="flex flex-wrap items-start justify-between gap-4">
<div class="min-w-0"> <div class="min-w-0">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@ -144,7 +144,7 @@
<p class="mt-1.5 max-w-xl text-sm text-muted"> <p class="mt-1.5 max-w-xl text-sm text-muted">
{{ $sitePublic ? __('admin_settings.site_public_body') : __('admin_settings.site_hidden_body') }} {{ $sitePublic ? __('admin_settings.site_public_body') : __('admin_settings.site_hidden_body') }}
</p> </p>
<p class="mt-2 text-xs text-faint"> <p class="mt-2 text-xs text-muted">
{{ __('admin_settings.site_your_view', ['ip' => request()->ip()]) }} {{ __('admin_settings.site_your_view', ['ip' => request()->ip()]) }}
<span class="{{ $viewerOnVpn ? 'text-success' : '' }}"> <span class="{{ $viewerOnVpn ? 'text-success' : '' }}">
{{ $viewerOnVpn ? __('admin_settings.site_via_vpn') : __('admin_settings.site_not_vpn') }} {{ $viewerOnVpn ? __('admin_settings.site_via_vpn') : __('admin_settings.site_not_vpn') }}
@ -161,7 +161,7 @@
{{-- Who may reach this console --}} {{-- Who may reach this console --}}
@if ($canManageSite) @if ($canManageSite)
<div class="rounded-xl border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:30ms]"> <div class="rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:30ms]">
<div class="flex flex-wrap items-start justify-between gap-4"> <div class="flex flex-wrap items-start justify-between gap-4">
<div class="min-w-0"> <div class="min-w-0">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@ -175,7 +175,7 @@
<p class="mt-1 max-w-xl text-sm text-muted"> <p class="mt-1 max-w-xl text-sm text-muted">
{{ $consoleRestricted ? __('admin_settings.console_locked_body') : __('admin_settings.console_open_body') }} {{ $consoleRestricted ? __('admin_settings.console_locked_body') : __('admin_settings.console_open_body') }}
</p> </p>
<p class="mt-2 font-mono text-xs text-faint"> <p class="mt-2 font-mono text-xs text-muted">
{{ __('admin_settings.console_your_ip', ['ip' => $viewerIp]) }} {{ __('admin_settings.console_your_ip', ['ip' => $viewerIp]) }}
</p> </p>
</div> </div>
@ -187,15 +187,15 @@
</div> </div>
<div class="mt-5 space-y-2 border-t border-line pt-4"> <div class="mt-5 space-y-2 border-t border-line pt-4">
<p class="text-xs font-semibold uppercase tracking-wide text-faint">{{ __('admin_settings.console_always') }}</p> <p class="text-xs font-semibold text-muted">{{ __('admin_settings.console_always') }}</p>
@foreach ($consoleVpnRanges as $range) @foreach ($consoleVpnRanges as $range)
<div class="flex items-center justify-between rounded-lg border border-line-strong bg-surface-2 px-3 py-2"> <div class="flex items-center justify-between rounded-lg border border-line-strong bg-surface-2 px-3 py-2">
<span class="font-mono text-sm text-body">{{ $range }}</span> <span class="font-mono text-sm text-body">{{ $range }}</span>
<span class="text-xs text-faint">{{ __('admin_settings.console_vpn_note') }}</span> <span class="text-xs text-muted">{{ __('admin_settings.console_vpn_note') }}</span>
</div> </div>
@endforeach @endforeach
<p class="pt-3 text-xs font-semibold uppercase tracking-wide text-faint">{{ __('admin_settings.console_extra') }}</p> <p class="pt-3 text-xs font-semibold text-muted">{{ __('admin_settings.console_extra') }}</p>
@forelse ($consoleIps as $ip) @forelse ($consoleIps as $ip)
<div wire:key="cip-{{ $ip }}" class="flex items-center justify-between rounded-lg border border-line-strong bg-surface-2 px-3 py-2"> <div wire:key="cip-{{ $ip }}" class="flex items-center justify-between rounded-lg border border-line-strong bg-surface-2 px-3 py-2">
<span class="font-mono text-sm text-body">{{ $ip }}</span> <span class="font-mono text-sm text-body">{{ $ip }}</span>
@ -217,7 +217,7 @@
</div> </div>
<x-ui.button type="submit" wire:loading.attr="disabled" wire:target="addConsoleIp">{{ __('admin_settings.console_add') }}</x-ui.button> <x-ui.button type="submit" wire:loading.attr="disabled" wire:target="addConsoleIp">{{ __('admin_settings.console_add') }}</x-ui.button>
</div> </div>
<p class="mt-1.5 text-xs text-faint">{{ __('admin_settings.console_ip_hint') }}</p> <p class="mt-1.5 text-xs text-muted">{{ __('admin_settings.console_ip_hint') }}</p>
</form> </form>
</div> </div>
</div> </div>
@ -226,7 +226,7 @@
{{-- Own password. There was no way to change one at all: an account created {{-- 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. --}} with a generated password kept it until someone opened a shell. --}}
<form wire:submit="updateOwnPassword" class="space-y-4 rounded-xl border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:90ms]"> <form wire:submit="updateOwnPassword" class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:90ms]">
<div> <div>
<h2 class="font-semibold text-ink">{{ __('admin_settings.password_title') }}</h2> <h2 class="font-semibold text-ink">{{ __('admin_settings.password_title') }}</h2>
<p class="mt-1 text-sm text-muted">{{ __('admin_settings.password_sub') }}</p> <p class="mt-1 text-sm text-muted">{{ __('admin_settings.password_sub') }}</p>
@ -246,13 +246,13 @@
</div> </div>
</form> </form>
<form wire:submit="saveAccount" class="space-y-4 rounded-xl border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:60ms]"> <form wire:submit="saveAccount" class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:60ms]">
<h2 class="font-semibold text-ink">{{ __('admin_settings.account_title') }}</h2> <h2 class="font-semibold text-ink">{{ __('admin_settings.account_title') }}</h2>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<x-ui.input name="name" wire:model="name" :label="__('admin_settings.name')" /> <x-ui.input name="name" wire:model="name" :label="__('admin_settings.name')" />
<x-ui.input name="email" wire:model="email" :label="__('admin_settings.email')" type="email" /> <x-ui.input name="email" wire:model="email" :label="__('admin_settings.email')" type="email" />
</div> </div>
<p class="text-xs text-faint">{{ __('admin_settings.twofa_hint') }}</p> <p class="text-xs text-muted">{{ __('admin_settings.twofa_hint') }}</p>
<div class="flex justify-end"> <div class="flex justify-end">
<x-ui.button variant="primary" type="submit" wire:loading.attr="disabled" wire:target="saveAccount">{{ __('admin_settings.save') }}</x-ui.button> <x-ui.button variant="primary" type="submit" wire:loading.attr="disabled" wire:target="saveAccount">{{ __('admin_settings.save') }}</x-ui.button>
</div> </div>
@ -260,7 +260,7 @@
{{-- Staff & access (Owner-only) --}} {{-- Staff & access (Owner-only) --}}
@if ($canManageStaff) @if ($canManageStaff)
<div class="space-y-4 rounded-xl border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:120ms]"> <div class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:120ms]">
<div> <div>
<h2 class="font-semibold text-ink">{{ __('admin_settings.staff_title') }}</h2> <h2 class="font-semibold text-ink">{{ __('admin_settings.staff_title') }}</h2>
<p class="mt-1 text-sm text-muted">{{ __('admin_settings.staff_sub') }}</p> <p class="mt-1 text-sm text-muted">{{ __('admin_settings.staff_sub') }}</p>
@ -293,7 +293,7 @@
<x-ui.icon name="plus" class="size-4" />{{ __('admin_settings.invite') }} <x-ui.icon name="plus" class="size-4" />{{ __('admin_settings.invite') }}
</x-ui.button> </x-ui.button>
</form> </form>
<p class="text-xs text-faint">{{ __('admin_settings.invite_hint') }}</p> <p class="text-xs text-muted">{{ __('admin_settings.invite_hint') }}</p>
@if ($invitedPassword) @if ($invitedPassword)
<div class="rounded-lg border border-warning-border bg-warning-bg p-4"> <div class="rounded-lg border border-warning-border bg-warning-bg p-4">
@ -307,11 +307,11 @@
@endif @endif
{{-- Staff list --}} {{-- Staff list --}}
<div class="overflow-hidden rounded-xl border border-line"> <div class="overflow-hidden rounded-lg border border-line">
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="w-full text-sm"> <table class="w-full text-sm">
<thead> <thead>
<tr class="border-b border-line bg-surface-2 text-left text-xs uppercase tracking-wide text-faint"> <tr class="border-b border-line bg-surface-2 text-left text-xs font-semibold text-muted">
<th class="px-4 py-3 font-semibold">{{ __('admin_settings.col_person') }}</th> <th class="px-4 py-3 font-semibold">{{ __('admin_settings.col_person') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('admin_settings.role') }}</th> <th class="px-4 py-3 font-semibold">{{ __('admin_settings.role') }}</th>
<th class="px-4 py-3 text-right font-semibold">{{ __('admin_settings.col_actions') }}</th> <th class="px-4 py-3 text-right font-semibold">{{ __('admin_settings.col_actions') }}</th>
@ -322,7 +322,7 @@
<tr wire:key="staff-{{ $s['id'] }}" class="border-b border-line last:border-0 hover:bg-surface-hover"> <tr wire:key="staff-{{ $s['id'] }}" class="border-b border-line last:border-0 hover:bg-surface-hover">
<td class="px-4 py-3"> <td class="px-4 py-3">
<p class="font-medium text-ink">{{ $s['name'] }} <p class="font-medium text-ink">{{ $s['name'] }}
@if ($s['self'])<span class="ml-1 text-xs text-faint">({{ __('admin_settings.you') }})</span>@endif @if ($s['self'])<span class="ml-1 text-xs text-muted">({{ __('admin_settings.you') }})</span>@endif
</p> </p>
<p class="text-xs text-muted">{{ $s['email'] }}</p> <p class="text-xs text-muted">{{ $s['email'] }}</p>
</td> </td>
@ -344,7 +344,7 @@
yourself and an empty cell reads as a bug rather yourself and an empty cell reads as a bug rather
than as a rule. --}} than as a rule. --}}
@if ($s['self']) @if ($s['self'])
<span class="text-xs text-faint"></span> <span class="text-xs text-muted"></span>
@endif @endif
@unless ($s['self']) @unless ($s['self'])
<button type="button" wire:click="revokeStaff({{ $s['id'] }})" class="rounded-md border border-line px-2.5 py-1.5 text-xs font-semibold text-muted hover:border-danger hover:text-danger">{{ __('admin_settings.revoke') }}</button> <button type="button" wire:click="revokeStaff({{ $s['id'] }})" class="rounded-md border border-line px-2.5 py-1.5 text-xs font-semibold text-muted hover:border-danger hover:text-danger">{{ __('admin_settings.revoke') }}</button>

View File

@ -3,12 +3,12 @@
seconds. Traffic figures can wait the minute someone needs to copy a key. --}} seconds. Traffic figures can wait the minute someone needs to copy a key. --}}
<div class="space-y-5" @if (! $newConfig) wire:poll.5s="refreshPeers" @endif> <div class="space-y-5" @if (! $newConfig) wire:poll.5s="refreshPeers" @endif>
<div class="animate-rise"> <div class="animate-rise">
<h1 class="text-2xl font-semibold tracking-tight text-ink">{{ __('vpn.title') }}</h1> <h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('vpn.title') }}</h1>
<p class="mt-1 text-sm text-muted">{{ __('vpn.subtitle') }}</p> <p class="mt-1 text-sm text-muted">{{ __('vpn.subtitle') }}</p>
</div> </div>
@if ($hubEndpoint === '' || $hubPublicKey === '') @if ($hubEndpoint === '' || $hubPublicKey === '')
<div class="flex items-start gap-3 rounded-xl border border-warning-border bg-warning-bg p-4 animate-rise"> <div class="flex items-start gap-3 rounded-lg border border-warning-border bg-warning-bg p-4 animate-rise">
<x-ui.icon name="alert-triangle" class="mt-0.5 size-5 shrink-0 text-warning" /> <x-ui.icon name="alert-triangle" class="mt-0.5 size-5 shrink-0 text-warning" />
<div class="text-sm"> <div class="text-sm">
<p class="font-medium text-ink">{{ __('vpn.hub_incomplete_title') }}</p> <p class="font-medium text-ink">{{ __('vpn.hub_incomplete_title') }}</p>
@ -19,7 +19,7 @@
{{-- Shown once. The private key exists only in this response; a reload loses it. --}} {{-- Shown once. The private key exists only in this response; a reload loses it. --}}
@if ($newConfig) @if ($newConfig)
<div class="rounded-xl border border-accent-border bg-accent-bg p-5 animate-rise"> <div class="rounded-lg border border-accent-border bg-accent-bg p-5 animate-rise">
<div class="flex items-start justify-between gap-4"> <div class="flex items-start justify-between gap-4">
<div> <div>
<h2 class="font-semibold text-ink">{{ __('vpn.config_ready', ['name' => $newConfigName]) }}</h2> <h2 class="font-semibold text-ink">{{ __('vpn.config_ready', ['name' => $newConfigName]) }}</h2>
@ -61,10 +61,10 @@
<div class="grid grid-cols-1 gap-5 lg:grid-cols-[1fr_320px] lg:items-start"> <div class="grid grid-cols-1 gap-5 lg:grid-cols-[1fr_320px] lg:items-start">
{{-- Peer list --}} {{-- Peer list --}}
<div class="overflow-hidden rounded-xl border border-line bg-surface shadow-xs animate-rise [animation-delay:60ms]"> <div class="overflow-hidden rounded-lg border border-line bg-surface shadow-xs animate-rise [animation-delay:60ms]">
<div class="flex items-center justify-between border-b border-line px-4 py-3"> <div class="flex items-center justify-between border-b border-line px-4 py-3">
<h2 class="text-sm font-semibold text-ink">{{ __('vpn.peers') }}</h2> <h2 class="text-sm font-semibold text-ink">{{ __('vpn.peers') }}</h2>
<span class="text-xs text-faint"> <span class="text-xs text-muted">
{{ $lastSync ? __('vpn.last_sync', ['time' => \Illuminate\Support\Carbon::parse($lastSync)->diffForHumans()]) : __('vpn.never_synced') }} {{ $lastSync ? __('vpn.last_sync', ['time' => \Illuminate\Support\Carbon::parse($lastSync)->diffForHumans()]) : __('vpn.never_synced') }}
</span> </span>
</div> </div>
@ -120,7 +120,7 @@
<span class="rounded-pill border border-accent-border bg-accent-subtle px-1.5 py-0.5 text-[10px] font-medium text-accent-text">{{ __('vpn.you') }}</span> <span class="rounded-pill border border-accent-border bg-accent-subtle px-1.5 py-0.5 text-[10px] font-medium text-accent-text">{{ __('vpn.you') }}</span>
@endif @endif
@elseif (! $peer->host) @elseif (! $peer->host)
<span class="text-sm text-faint">{{ __('vpn.no_owner') }}</span> <span class="text-sm text-muted">{{ __('vpn.no_owner') }}</span>
@endif @endif
</div> </div>
@ -129,7 +129,7 @@
<div class="mt-1.5 flex flex-wrap items-center gap-x-5 gap-y-1 font-mono text-xs text-muted"> <div class="mt-1.5 flex flex-wrap items-center gap-x-5 gap-y-1 font-mono text-xs text-muted">
<span class="text-body">{{ $peer->allowed_ip }}</span> <span class="text-body">{{ $peer->allowed_ip }}</span>
@if ($peer->endpoint) @if ($peer->endpoint)
<span class="text-faint">{{ $peer->endpoint }}</span> <span class="text-muted">{{ $peer->endpoint }}</span>
@endif @endif
<span class="whitespace-nowrap">&darr;&nbsp;{{ \App\Support\Bytes::human($peer->rx_bytes) }}&nbsp;&nbsp;&uarr;&nbsp;{{ \App\Support\Bytes::human($peer->tx_bytes) }}</span> <span class="whitespace-nowrap">&darr;&nbsp;{{ \App\Support\Bytes::human($peer->rx_bytes) }}&nbsp;&nbsp;&uarr;&nbsp;{{ \App\Support\Bytes::human($peer->tx_bytes) }}</span>
<span class="whitespace-nowrap">{{ __('vpn.handshake') }}: {{ $peer->last_handshake_at?->diffForHumans() ?? __('vpn.never') }}</span> <span class="whitespace-nowrap">{{ __('vpn.handshake') }}: {{ $peer->last_handshake_at?->diffForHumans() ?? __('vpn.never') }}</span>
@ -147,7 +147,7 @@
nothing to hand out, and saying so beats an absent button. --}} 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()) @if (! $peer->hasStoredConfig() && $peer->kind === \App\Models\VpnPeer::KIND_STAFF && $peer->user_id === auth()->id())
<span title="{{ __('vpn.no_stored_config') }}" <span title="{{ __('vpn.no_stored_config') }}"
class="grid size-8 cursor-help place-items-center rounded-md border border-dashed border-line text-faint"> class="grid size-8 cursor-help place-items-center rounded-md border border-dashed border-line text-muted">
<x-ui.icon name="download" class="size-4" /> <x-ui.icon name="download" class="size-4" />
</span> </span>
@endif @endif
@ -193,7 +193,7 @@
<div class="space-y-5"> <div class="space-y-5">
{{-- New access --}} {{-- New access --}}
@if ($canManage) @if ($canManage)
<form wire:submit="create" class="space-y-4 rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:120ms]"> <form wire:submit="create" class="space-y-4 rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:120ms]">
<h2 class="font-semibold text-ink">{{ __('vpn.add') }}</h2> <h2 class="font-semibold text-ink">{{ __('vpn.add') }}</h2>
<x-ui.input name="name" wire:model="name" :label="__('vpn.name')" placeholder="Notebook Boban" /> <x-ui.input name="name" wire:model="name" :label="__('vpn.name')" placeholder="Notebook Boban" />
@ -231,18 +231,18 @@
@endif @endif
{{-- Hub facts an operator needs when configuring a peer by hand --}} {{-- Hub facts an operator needs when configuring a peer by hand --}}
<div class="space-y-3 rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:180ms]"> <div class="space-y-3 rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:180ms]">
<h2 class="font-semibold text-ink">{{ __('vpn.hub') }}</h2> <h2 class="font-semibold text-ink">{{ __('vpn.hub') }}</h2>
<div> <div>
<div class="text-xs uppercase tracking-wide text-faint">{{ __('vpn.endpoint') }}</div> <div class="text-xs font-semibold text-muted">{{ __('vpn.endpoint') }}</div>
<div class="mt-0.5 break-all font-mono text-xs text-body">{{ $hubEndpoint ?: '—' }}</div> <div class="mt-0.5 break-all font-mono text-xs text-body">{{ $hubEndpoint ?: '—' }}</div>
</div> </div>
<div> <div>
<div class="text-xs uppercase tracking-wide text-faint">{{ __('vpn.hub_key') }}</div> <div class="text-xs font-semibold text-muted">{{ __('vpn.hub_key') }}</div>
<div class="mt-0.5 break-all font-mono text-xs text-body">{{ $hubPublicKey ?: '—' }}</div> <div class="mt-0.5 break-all font-mono text-xs text-body">{{ $hubPublicKey ?: '—' }}</div>
</div> </div>
<div> <div>
<div class="text-xs uppercase tracking-wide text-faint">{{ __('vpn.subnet') }}</div> <div class="text-xs font-semibold text-muted">{{ __('vpn.subnet') }}</div>
<div class="mt-0.5 font-mono text-xs text-body">{{ config('provisioning.wireguard.subnet') }}</div> <div class="mt-0.5 font-mono text-xs text-body">{{ config('provisioning.wireguard.subnet') }}</div>
</div> </div>
</div> </div>

View File

@ -14,10 +14,10 @@
<div class="w-full max-w-sm animate-rise"> <div class="w-full max-w-sm animate-rise">
<div class="mb-9 flex items-center gap-2.5 lg:hidden"> <div class="mb-9 flex items-center gap-2.5 lg:hidden">
<x-ui.logo class="size-8" /> <x-ui.logo class="size-8" />
<span class="font-serif text-lg font-semibold tracking-tight text-ink">CluPilot</span> <span class="text-lg font-bold tracking-tight text-ink">Clu<span class="text-accent-text">Pilot</span></span>
</div> </div>
<h1 class="font-serif text-[1.75rem] font-semibold leading-tight tracking-tight text-ink">{{ __('auth.login_title') }}</h1> <h1 class="text-3xl font-bold leading-tight tracking-tight text-ink">{{ __('auth.login_title') }}</h1>
<p class="mt-2 text-sm text-muted">{{ __('auth.login_subtitle') }}</p> <p class="mt-2 text-sm text-muted">{{ __('auth.login_subtitle') }}</p>
@if (session('status')) @if (session('status'))

View File

@ -14,10 +14,10 @@
<div class="w-full max-w-sm animate-rise"> <div class="w-full max-w-sm animate-rise">
<div class="mb-9 flex items-center gap-2.5 lg:hidden"> <div class="mb-9 flex items-center gap-2.5 lg:hidden">
<x-ui.logo class="size-8" /> <x-ui.logo class="size-8" />
<span class="font-serif text-lg font-semibold tracking-tight text-ink">CluPilot</span> <span class="text-lg font-bold tracking-tight text-ink">Clu<span class="text-accent-text">Pilot</span></span>
</div> </div>
<h1 class="font-serif text-[1.75rem] font-semibold leading-tight tracking-tight text-ink">{{ __('auth.register_title') }}</h1> <h1 class="text-3xl font-bold leading-tight tracking-tight text-ink">{{ __('auth.register_title') }}</h1>
<p class="mt-2 text-sm text-muted">{{ __('auth.register_subtitle') }}</p> <p class="mt-2 text-sm text-muted">{{ __('auth.register_subtitle') }}</p>
<form method="POST" action="{{ route('register.store') }}" class="mt-7 space-y-5"> <form method="POST" action="{{ route('register.store') }}" class="mt-7 space-y-5">

View File

@ -1,24 +1,24 @@
<div class="space-y-6" x-data="{ msgs: @js(['restore' => __('backups.restore_toast')]) }"> <div class="space-y-6" x-data="{ msgs: @js(['restore' => __('backups.restore_toast')]) }">
<div class="flex flex-wrap items-center gap-3 animate-rise"> <div class="flex flex-wrap items-center gap-3 animate-rise">
<div> <div>
<h1 class="text-2xl font-semibold tracking-tight text-ink">{{ __('backups.title') }}</h1> <h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('backups.title') }}</h1>
<p class="mt-1 text-sm text-muted">{{ __('backups.subtitle') }}</p> <p class="mt-1 text-sm text-muted">{{ __('backups.subtitle') }}</p>
</div> </div>
<span class="ml-auto rounded-pill bg-success-bg px-3 py-1.5 text-xs font-semibold text-success">{{ __('backups.schedule') }}</span> <span class="ml-auto rounded-pill bg-success-bg px-3 py-1.5 text-xs font-semibold text-success">{{ __('backups.schedule') }}</span>
</div> </div>
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:60ms]"> <div class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:60ms]">
<h2 class="font-semibold text-ink">{{ __('backups.size_trend') }}</h2> <h2 class="font-semibold text-ink">{{ __('backups.size_trend') }}</h2>
<div class="mt-3 h-52" wire:ignore> <div class="mt-3 h-52" wire:ignore>
<x-ui.chart :config="$sizeChart" class="h-52" :label="__('backups.size_trend')" /> <x-ui.chart :config="$sizeChart" class="h-52" :label="__('backups.size_trend')" />
</div> </div>
</div> </div>
<div class="overflow-hidden rounded-xl border border-line bg-surface shadow-xs animate-rise [animation-delay:120ms]"> <div class="overflow-hidden rounded-lg border border-line bg-surface shadow-xs animate-rise [animation-delay:120ms]">
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="w-full text-sm"> <table class="w-full text-sm">
<thead> <thead>
<tr class="border-b border-line bg-surface-2 text-left text-xs uppercase tracking-wide text-faint"> <tr class="border-b border-line bg-surface-2 text-left text-xs font-semibold text-muted">
<th class="px-4 py-3 font-semibold">{{ __('backups.col_when') }}</th> <th class="px-4 py-3 font-semibold">{{ __('backups.col_when') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('backups.col_size') }}</th> <th class="px-4 py-3 font-semibold">{{ __('backups.col_size') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('backups.col_status') }}</th> <th class="px-4 py-3 font-semibold">{{ __('backups.col_status') }}</th>

View File

@ -3,15 +3,15 @@
@php $loc = app()->getLocale(); $eur = fn ($c) => Number::currency($c / 100, in: 'EUR', locale: $loc); @endphp @php $loc = app()->getLocale(); $eur = fn ($c) => Number::currency($c / 100, in: 'EUR', locale: $loc); @endphp
<div class="animate-rise"> <div class="animate-rise">
<h1 class="text-2xl font-semibold tracking-tight text-ink">{{ __('billing.title') }}</h1> <h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('billing.title') }}</h1>
<p class="mt-1 text-sm text-muted">{{ __('billing.subtitle') }}</p> <p class="mt-1 text-sm text-muted">{{ __('billing.subtitle') }}</p>
</div> </div>
{{-- Current plan --}} {{-- Current plan --}}
<div class="overflow-hidden rounded-xl border border-line bg-surface shadow-xs animate-rise [animation-delay:60ms]"> <div class="overflow-hidden rounded-lg border border-line bg-surface shadow-xs animate-rise [animation-delay:60ms]">
<div class="flex flex-wrap items-center justify-between gap-4 border-b border-line bg-surface-2 px-6 py-4"> <div class="flex flex-wrap items-center justify-between gap-4 border-b border-line bg-surface-2 px-6 py-4">
<div> <div>
<p class="text-xs font-semibold uppercase tracking-wider text-faint">{{ __('billing.current_plan') }}</p> <p class="text-xs font-semibold uppercase tracking-wider text-muted">{{ __('billing.current_plan') }}</p>
<p class="mt-0.5 text-lg font-semibold text-ink">{{ __('billing.plan.'.$currentKey) }}</p> <p class="mt-0.5 text-lg font-semibold text-ink">{{ __('billing.plan.'.$currentKey) }}</p>
</div> </div>
<div class="text-right"> <div class="text-right">
@ -44,7 +44,7 @@
{{-- Cart: "5 purchases pending" told nobody what they had ordered, and {{-- Cart: "5 purchases pending" told nobody what they had ordered, and
offered no way to change their mind. --}} offered no way to change their mind. --}}
@if ($pending->isNotEmpty()) @if ($pending->isNotEmpty())
<div class="overflow-hidden rounded-xl border border-info-border bg-surface shadow-xs animate-rise"> <div class="overflow-hidden rounded-lg border border-info-border bg-surface shadow-xs animate-rise">
<div class="flex items-center gap-2 border-b border-line bg-info-bg px-5 py-3"> <div class="flex items-center gap-2 border-b border-line bg-info-bg px-5 py-3">
<x-ui.icon name="receipt" class="size-4 shrink-0 text-info" /> <x-ui.icon name="receipt" class="size-4 shrink-0 text-info" />
<h2 class="text-sm font-semibold text-ink">{{ __('billing.cart.title') }}</h2> <h2 class="text-sm font-semibold text-ink">{{ __('billing.cart.title') }}</h2>
@ -56,13 +56,13 @@
<li wire:key="order-{{ $order->uuid }}" class="flex flex-wrap items-center gap-3 px-5 py-3"> <li wire:key="order-{{ $order->uuid }}" class="flex flex-wrap items-center gap-3 px-5 py-3">
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<p class="text-sm font-medium text-ink">{{ $order->label() }}</p> <p class="text-sm font-medium text-ink">{{ $order->label() }}</p>
<p class="mt-0.5 text-xs text-faint"> <p class="mt-0.5 text-xs text-muted">
{{ __('billing.cart.added', ['when' => $order->created_at->isoFormat('LL')]) }} {{ __('billing.cart.added', ['when' => $order->created_at->isoFormat('LL')]) }}
</p> </p>
</div> </div>
<div class="text-right"> <div class="text-right">
<p class="font-mono text-sm text-body">{{ $eur($order->amount_cents) }}</p> <p class="font-mono text-sm text-body">{{ $eur($order->amount_cents) }}</p>
<p class="text-xs text-faint"> <p class="text-xs text-muted">
{{ __('billing.cart.net') }} · {{ __('billing.cart.net') }} ·
{{ $order->isRecurring() ? __('billing.cart.per_month') : __('billing.cart.once') }} {{ $order->isRecurring() ? __('billing.cart.per_month') : __('billing.cart.once') }}
</p> </p>
@ -103,7 +103,7 @@
@if ($recurringGross > 0) @if ($recurringGross > 0)
{{-- The number that actually matters every month, kept apart {{-- The number that actually matters every month, kept apart
from a one-off traffic top-up in the same cart. --}} from a one-off traffic top-up in the same cart. --}}
<p class="pt-1 text-xs text-faint"> <p class="pt-1 text-xs text-muted">
{{ __('billing.cart.recurring_note', ['amount' => $eur($recurringGross)]) }} {{ __('billing.cart.recurring_note', ['amount' => $eur($recurringGross)]) }}
</p> </p>
@endif @endif
@ -118,7 +118,7 @@
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"> <div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
@foreach ($upgrades as $key) @foreach ($upgrades as $key)
@php $p = $plans[$key]; @endphp @php $p = $plans[$key]; @endphp
<div wire:key="upgrade-{{ $key }}" class="flex flex-col rounded-xl border border-line bg-surface p-5 shadow-xs transition hover:border-accent-border"> <div wire:key="upgrade-{{ $key }}" class="flex flex-col rounded-lg border border-line bg-surface p-5 shadow-xs transition hover:border-accent-border">
<div class="flex items-baseline justify-between"> <div class="flex items-baseline justify-between">
<p class="font-semibold text-ink">{{ __('billing.plan.'.$key) }}</p> <p class="font-semibold text-ink">{{ __('billing.plan.'.$key) }}</p>
<span class="rounded-pill bg-accent-subtle px-2 py-0.5 text-xs font-semibold text-accent-text">{{ __('billing.perf.'.($p['performance'] ?? 'standard')) }}</span> <span class="rounded-pill bg-accent-subtle px-2 py-0.5 text-xs font-semibold text-accent-text">{{ __('billing.perf.'.($p['performance'] ?? 'standard')) }}</span>
@ -133,7 +133,7 @@
@endforeach @endforeach
</ul> </ul>
<p class="mt-4 text-xl font-semibold text-ink">{{ $eur($p['price_cents']) }}<span class="text-sm font-normal text-muted"> / {{ __('billing.month_short') }}</span></p> <p class="mt-4 text-xl font-semibold text-ink">{{ $eur($p['price_cents']) }}<span class="text-sm font-normal text-muted"> / {{ __('billing.month_short') }}</span></p>
<p class="text-xs text-faint"> <p class="text-xs text-muted">
{{ $tax->reverseCharge {{ $tax->reverseCharge
? __('billing.net_reverse_charge') ? __('billing.net_reverse_charge')
: __('billing.net_hint', ['percent' => $tax->percentLabel()]) }} : __('billing.net_hint', ['percent' => $tax->percentLabel()]) }}
@ -147,29 +147,82 @@
</div> </div>
@endif @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 !== [])
<section class="animate-rise">
<h2 class="text-md font-semibold text-ink">{{ __('billing.downgrade') }}</h2>
<p class="mt-1 text-sm text-muted">{{ __('billing.downgrade_hint') }}</p>
<div class="mt-4 grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
@foreach ($downgrades as $key => $plan)
<article class="flex flex-col rounded-lg border border-line bg-surface p-5 shadow-xs">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<h3 class="text-md font-semibold text-ink">{{ $plan['name'] ?? $key }}</h3>
<p class="mt-1 text-xs text-muted">
{{ $plan['quota_gb'] ?? 0 }} GB · {{ __('billing.seats_count', ['count' => $plan['seats'] ?? 0]) }}
</p>
</div>
<p class="shrink-0 text-right font-mono text-lg font-semibold text-ink">
{{ $eur($plan['price_cents'] ?? 0) }}
<span class="mt-0.5 block text-xs font-normal text-muted">{{ __('billing.per_month') }}</span>
</p>
</div>
@if ($plan['check']->allowed)
<x-ui.button variant="secondary" class="mt-5 w-full"
wire:click="purchase('downgrade', '{{ $key }}')"
wire:loading.attr="disabled" wire:target="purchase('downgrade', '{{ $key }}')">
{{ __('billing.downgrade_to', ['plan' => $plan['name'] ?? $key]) }}
</x-ui.button>
@else
<div class="mt-5 rounded border border-warning-border bg-warning-bg p-3">
<p class="text-xs font-semibold text-warning">{{ __('billing.downgrade_blocked') }}</p>
<ul class="mt-2 space-y-1.5">
@foreach ($plan['check']->blockers as $blocker)
<li class="text-xs leading-relaxed text-ink">
{{ __('billing.downgrade_blocker.'.$blocker['key'], [
'current' => $blocker['current'],
'limit' => $blocker['limit'],
]) }}
</li>
@endforeach
</ul>
</div>
@endif
</article>
@endforeach
</div>
</section>
@endif
{{-- Extra storage + Add-ons --}} {{-- Extra storage + Add-ons --}}
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3 xl:grid-cols-4 animate-rise [animation-delay:180ms]"> <div class="grid grid-cols-1 gap-4 lg:grid-cols-3 xl:grid-cols-4 animate-rise [animation-delay:180ms]">
{{-- Storage --}} {{-- Storage --}}
<div class="flex flex-col rounded-xl border border-accent-border bg-accent-subtle p-5"> <div class="flex flex-col rounded-lg border border-accent-border bg-accent-subtle p-5">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<x-ui.icon name="database" class="size-5 text-accent-active" /> <x-ui.icon name="database" class="size-5 text-accent-active" />
<p class="font-semibold text-ink">{{ __('billing.storage_title') }}</p> <p class="font-semibold text-ink">{{ __('billing.storage_title') }}</p>
</div> </div>
<p class="mt-2 text-sm text-body">{{ __('billing.storage_body', ['gb' => $storage['gb'], 'price' => $eur($storage['price_cents'])]) }}</p> <p class="mt-2 text-sm text-body">{{ __('billing.storage_body', ['gb' => $storage['gb'], 'price' => $eur($storage['price_cents'])]) }}</p>
<p class="mt-1 text-xs text-faint">{{ __('billing.net_per_month') }}</p> <p class="mt-1 text-xs text-muted">{{ __('billing.net_per_month') }}</p>
<x-ui.button variant="primary" size="sm" class="mt-4 w-full" wire:click="purchase('storage')" wire:target="purchase('storage')" wire:loading.attr="disabled"> <x-ui.button variant="primary" size="sm" class="mt-4 w-full" wire:click="purchase('storage')" wire:target="purchase('storage')" wire:loading.attr="disabled">
{{ __('billing.storage_cta', ['gb' => $storage['gb']]) }} {{ __('billing.storage_cta', ['gb' => $storage['gb']]) }}
</x-ui.button> </x-ui.button>
</div> </div>
{{-- Traffic --}} {{-- Traffic --}}
<div class="flex flex-col rounded-xl border {{ $trafficMeter && $trafficMeter->state() !== 'ok' ? 'border-warning-border bg-warning-bg' : 'border-line bg-surface shadow-xs' }} p-5"> <div class="flex flex-col rounded-lg border {{ $trafficMeter && $trafficMeter->state() !== 'ok' ? 'border-warning-border bg-warning-bg' : 'border-line bg-surface shadow-xs' }} p-5">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<x-ui.icon name="activity" class="size-5 {{ $trafficMeter && $trafficMeter->state() !== 'ok' ? 'text-warning' : 'text-muted' }}" /> <x-ui.icon name="activity" class="size-5 {{ $trafficMeter && $trafficMeter->state() !== 'ok' ? 'text-warning' : 'text-muted' }}" />
<p class="font-semibold text-ink">{{ __('billing.traffic_title') }}</p> <p class="font-semibold text-ink">{{ __('billing.traffic_title') }}</p>
</div> </div>
<p class="mt-2 text-sm text-body">{{ __('billing.traffic_body', ['gb' => $trafficAddon['gb'], 'price' => $eur($trafficAddon['price_cents'])]) }}</p> <p class="mt-2 text-sm text-body">{{ __('billing.traffic_body', ['gb' => $trafficAddon['gb'], 'price' => $eur($trafficAddon['price_cents'])]) }}</p>
<p class="mt-1 text-xs text-faint">{{ __('billing.net_once') }}</p> <p class="mt-1 text-xs text-muted">{{ __('billing.net_once') }}</p>
@if ($trafficMeter) @if ($trafficMeter)
<p class="mt-2 font-mono text-xs text-muted"> <p class="mt-2 font-mono text-xs text-muted">
{{ __('billing.traffic_used', [ {{ __('billing.traffic_used', [
@ -186,7 +239,7 @@
{{-- Add-ons --}} {{-- Add-ons --}}
@foreach ($addons as $key => $addon) @foreach ($addons as $key => $addon)
<div wire:key="addon-{{ $key }}" class="flex flex-col rounded-xl border border-line bg-surface p-5 shadow-xs"> <div wire:key="addon-{{ $key }}" class="flex flex-col rounded-lg border border-line bg-surface p-5 shadow-xs">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<x-ui.icon name="box" class="size-5 text-muted" /> <x-ui.icon name="box" class="size-5 text-muted" />
<p class="font-semibold text-ink">{{ __('billing.addon.'.$key.'.name') }}</p> <p class="font-semibold text-ink">{{ __('billing.addon.'.$key.'.name') }}</p>
@ -200,7 +253,7 @@
<span class="text-sm font-normal text-muted">· {{ __('billing.addon_packs', ['count' => $addon['quantity']]) }}</span> <span class="text-sm font-normal text-muted">· {{ __('billing.addon_packs', ['count' => $addon['quantity']]) }}</span>
@endif @endif
</p> </p>
<p class="text-xs text-faint"> <p class="text-xs text-muted">
{{ $tax->reverseCharge {{ $tax->reverseCharge
? __('billing.net_reverse_charge') ? __('billing.net_reverse_charge')
: __('billing.net_hint', ['percent' => $tax->percentLabel()]) }} : __('billing.net_hint', ['percent' => $tax->percentLabel()]) }}
@ -221,5 +274,5 @@
@endforeach @endforeach
</div> </div>
<p class="text-xs text-faint">{{ __('billing.mock_note') }}</p> <p class="text-xs text-muted">{{ __('billing.mock_note') }}</p>
</div> </div>

View File

@ -1,12 +1,11 @@
<div class="space-y-6" x-data="{ msgs: @js(['soon' => __('cloud.action_toast')]) }"> <div class="space-y-6" x-data="{ msgs: @js(['soon' => __('cloud.action_toast')]) }">
<div class="animate-rise"> <div class="animate-rise">
<h1 class="text-2xl font-semibold tracking-tight text-ink">{{ __('cloud.title') }}</h1> <h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('cloud.title') }}</h1>
<p class="mt-1 text-sm text-muted">{{ __('cloud.subtitle') }}</p> <p class="mt-1 text-sm text-muted">{{ __('cloud.subtitle') }}</p>
</div> </div>
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:60ms]"> <div class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:60ms]">
<div class="flex flex-wrap items-center gap-3"> <div class="flex flex-wrap items-center gap-3">
<span class="grid size-11 place-items-center rounded-lg bg-ink font-semibold text-on-accent">B</span>
<div class="min-w-0"> <div class="min-w-0">
<p class="font-semibold text-ink">{{ $instance['name'] }}</p> <p class="font-semibold text-ink">{{ $instance['name'] }}</p>
<a href="https://{{ $instance['domain'] }}" target="_blank" rel="noopener noreferrer" class="font-mono text-sm text-accent-text hover:underline">{{ $instance['domain'] }}</a> <a href="https://{{ $instance['domain'] }}" target="_blank" rel="noopener noreferrer" class="font-mono text-sm text-accent-text hover:underline">{{ $instance['domain'] }}</a>
@ -43,19 +42,17 @@
['cloud.seats', $instance['seats']], ['cloud.seats', $instance['seats']],
['cloud.performance', $instance['performance']], ['cloud.performance', $instance['performance']],
['cloud.location', $instance['location']], ['cloud.location', $instance['location']],
['cloud.version', $instance['version'].' · '.__('cloud.current')],
['cloud.runtime', $instance['php']],
['cloud.storage', $storageLabel], ['cloud.storage', $storageLabel],
] as [$key, $val]) ] as [$key, $val])
<div> <div>
<dt class="text-xs font-semibold uppercase tracking-wide text-faint">{{ __($key) }}</dt> <dt class="text-xs font-semibold text-muted">{{ __($key) }}</dt>
<dd class="mt-1 font-mono text-sm text-body">{{ $val }}</dd> <dd class="mt-1 font-mono text-sm text-body">{{ $val }}</dd>
</div> </div>
@endforeach @endforeach
</dl> </dl>
</div> </div>
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:120ms]"> <div class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:120ms]">
<h2 class="font-semibold text-ink">{{ __('cloud.storage_growth') }}</h2> <h2 class="font-semibold text-ink">{{ __('cloud.storage_growth') }}</h2>
<p class="text-sm text-muted">{{ __('cloud.storage_growth_sub') }}</p> <p class="text-sm text-muted">{{ __('cloud.storage_growth_sub') }}</p>
<div class="mt-4 h-56" wire:ignore> <div class="mt-4 h-56" wire:ignore>

View File

@ -1,4 +1,4 @@
<div class="rounded-xl bg-surface p-6"> <div class="rounded-lg bg-surface p-6">
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<span class="grid size-10 shrink-0 place-items-center rounded-lg bg-warning-bg text-warning"> <span class="grid size-10 shrink-0 place-items-center rounded-lg bg-warning-bg text-warning">
<x-ui.icon name="alert-triangle" class="size-5" /> <x-ui.icon name="alert-triangle" class="size-5" />

View File

@ -1,4 +1,4 @@
<div class="rounded-xl bg-surface p-6"> <div class="rounded-lg bg-surface p-6">
@if ($blocked) @if ($blocked)
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<span class="grid size-10 shrink-0 place-items-center rounded-lg bg-warning-bg text-warning"> <span class="grid size-10 shrink-0 place-items-center rounded-lg bg-warning-bg text-warning">

View File

@ -1,6 +1,6 @@
<div @if ($polling) wire:poll.4s @endif> <div @if ($polling) wire:poll.4s @endif>
@if ($active) @if ($active)
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise"> <div class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="size-2 rounded-pill {{ $failed ? 'bg-danger' : 'bg-info animate-pulse' }}" aria-hidden="true"></span> <span class="size-2 rounded-pill {{ $failed ? 'bg-danger' : 'bg-info animate-pulse' }}" aria-hidden="true"></span>
<h2 class="font-semibold text-ink">{{ __('dashboard.provisioning_title') }}</h2> <h2 class="font-semibold text-ink">{{ __('dashboard.provisioning_title') }}</h2>

View File

@ -1,272 +1,300 @@
<div class="space-y-4" x-data="{ msgs: @js(['addon' => __('dashboard.modules.add_toast'), 'restore' => __('dashboard.backups.restore_toast')]) }"> @php
{{-- Header --}} use App\Support\Bytes;
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 animate-rise"> use Illuminate\Support\Number;
<h1 class="text-2xl font-semibold tracking-tight text-ink">{{ __('dashboard.greeting', ['name' => auth()->user()->name]) }}</h1>
<span class="ml-auto inline-flex items-center gap-2 rounded-pill bg-success-bg px-3.5 py-1.5 text-xs font-semibold text-success"> $locale = app()->getLocale();
<span class="size-2 rounded-pill bg-success-bright" aria-hidden="true"></span>{{ __('dashboard.system_ok') }} $money = fn (int $cents, string $currency) => Number::currency($cents / 100, in: $currency, locale: $locale);
</span> @endphp
<p class="w-full text-sm text-muted">{{ __('dashboard.as_of', ['time' => '08:42']) }}</p> <div class="space-y-3.5">
</div> {{-- 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. --}}
<header class="mb-2 flex flex-wrap items-center gap-3.5 animate-rise">
<div class="min-w-0 flex-1">
<p class="lbl">
{{ __('dashboard.sheet', ['when' => $asOf->locale($locale)->isoFormat('LL, LT')]) }}
</p>
<h1 class="mt-[7px] text-[23px] font-bold leading-[1.12] tracking-[-0.03em] text-ink min-[901px]:text-[30px]">
{{ $instance !== null ? __('dashboard.title_running') : __('dashboard.title_pending') }}
</h1>
</div>
@if ($instance !== null)
<span @class([
'inline-flex shrink-0 items-center gap-[7px] rounded-pill border px-[11px] py-1 text-xs font-medium',
'border-success-border bg-success-bg text-success' => $instance->status === 'active',
'border-warning-border bg-warning-bg text-warning' => $instance->status !== 'active',
])>
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>
{{ __('dashboard.instance_status.'.$instance->status) }}
</span>
@if ($domain)
<x-ui.button variant="ink" class="shrink-0" :href="'https://'.$domain" target="_blank" rel="noopener">
<x-ui.icon name="external-link" class="size-4" />{{ __('dashboard.cloud.open') }}
</x-ui.button>
@endif
@endif
</header>
{{-- Live provisioning progress (only while a run is in flight) --}} {{-- Live provisioning progress (only while a run is in flight) --}}
<livewire:customer-provisioning /> <livewire:customer-provisioning />
{{-- Traffic: the one allowance that throttles the service when it runs out, @if ($instance === null)
so it gets a full-width band rather than a tile in the KPI row. --}} {{-- Said plainly rather than papered over with an empty record: a page
@if ($traffic) of dashes reads as broken, and "we are still setting it up" and
@php "you have not ordered yet" are different things. --}}
$state = $traffic->state(); <section class="rounded-lg border border-line bg-surface p-8 shadow-xs animate-rise">
$bar = ['ok' => 'bg-accent', 'warn' => 'bg-warning', 'critical' => 'bg-warning', 'exhausted' => 'bg-danger'][$state]; <h2 class="text-lg font-semibold text-ink">{{ __('dashboard.no_instance_label') }}</h2>
$frame = [ <p class="mt-2 max-w-prose text-md text-muted">{{ __('dashboard.no_instance_body') }}</p>
'ok' => 'border-line', <div class="mt-6 flex flex-wrap gap-2.5">
'warn' => 'border-warning-border bg-warning-bg', <x-ui.button variant="ink" :href="route('billing')" wire:navigate>{{ __('dashboard.no_instance_cta') }}</x-ui.button>
'critical' => 'border-warning-border bg-warning-bg', <x-ui.button variant="secondary" :href="route('support')" wire:navigate>{{ __('dashboard.nav.support') }}</x-ui.button>
'exhausted' => 'border-danger-border bg-danger-bg',
][$state];
@endphp
<div class="rounded-xl border {{ $frame }} bg-surface p-5 shadow-xs animate-rise">
<div class="flex flex-wrap items-baseline justify-between gap-2">
<div class="flex items-baseline gap-2">
<h2 class="text-sm font-semibold text-ink">{{ __('dashboard.traffic.title') }}</h2>
<span class="text-xs text-muted">{{ __('dashboard.traffic.resets', ['days' => $traffic->daysUntilReset()]) }}</span>
</div>
<p class="font-mono text-sm text-body">
{{ \App\Support\Bytes::human($traffic->usedBytes) }}
<span class="text-faint">/ {{ \App\Support\Bytes::human($traffic->quotaBytes) }}</span>
</p>
</div> </div>
</section>
@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. --}}
<section class="grid gap-3.5 min-[561px]:grid-cols-2 min-[1101px]:grid-cols-4">
@if ($disk)
<x-ui.metric
:label="__('dashboard.master.storage')"
:value="Number::format($disk['used'] / 1024 ** 3, precision: 0, locale: $locale)"
:unit="'/ '.Number::format($disk['total'] / 1024 ** 3, precision: 0, locale: $locale).' GB'"
:foot="trim(__('dashboard.master.storage_used', ['percent' => Number::format($disk['percent'], precision: 0, locale: $locale)])
.($disk['week_delta'] !== null ? ' · '.__('dashboard.master.storage_week', ['amount' => ($disk['week_delta'] >= 0 ? '+' : '').Bytes::human(abs($disk['week_delta']))]) : ''))"
:href="route('cloud')"
class="animate-rise [animation-delay:60ms]"
>
<x-slot:visual><x-ui.ring :percent="$disk['percent']" :size="62" /></x-slot:visual>
</x-ui.metric>
@elseif ($instance->quota_gb)
<x-ui.metric
:label="__('dashboard.master.storage')"
:value="$instance->quota_gb"
unit="GB"
:foot="__('dashboard.master.storage_note')"
:href="route('cloud')"
class="animate-rise [animation-delay:60ms]"
/>
@endif
<div class="mt-3 h-2 overflow-hidden rounded-pill bg-surface-2"> <x-ui.metric
{{-- R4: width is data, not decoration the only inline style allowed. --}} :label="__('dashboard.master.users')"
<div class="h-full rounded-pill {{ $bar }} transition-all duration-700" :value="$seats['used']"
style="width: {{ min(100, $traffic->percent()) }}%"></div> :unit="$seats['total'] !== null ? __('dashboard.master.of_seats', ['total' => $seats['total']]) : null"
</div> :href="route('users')"
class="animate-rise [animation-delay:100ms]"
>
@if ($seats['total'])
<div class="mt-3 h-[5px] overflow-hidden rounded-pill bg-surface-hover">
{{-- R4: width is data, not decoration. --}}
<div class="h-full rounded-pill bg-muted"
style="width: {{ min(100, (int) round($seats['used'] / max(1, $seats['total']) * 100)) }}%"></div>
</div>
@endif
@if ($seatBreakdown !== [])
<p class="mt-2.5 text-xs text-muted">
{{ collect($seatBreakdown)
->map(fn (int $n, string $role) => trans_choice('users.role_count.'.$role, $n, ['count' => $n]))
->join(' · ') }}
</p>
@endif
</x-ui.metric>
<div class="mt-2.5 flex flex-wrap items-center justify-between gap-2"> @if ($traffic)
<p class="text-xs {{ $state === 'ok' ? 'text-muted' : 'text-body' }}"> @php $state = $traffic->state(); @endphp
@if ($state === 'exhausted') <x-ui.metric
{{ __('dashboard.traffic.exhausted') }} :label="__('dashboard.traffic.label')"
@elseif ($state === 'ok') :value="Number::format($traffic->usedBytes / 1024 ** 3, precision: 0, locale: $locale)"
{{ __('dashboard.traffic.remaining', ['amount' => \App\Support\Bytes::human($traffic->remainingBytes())]) }} :unit="'GB / '.Bytes::human($traffic->quotaBytes)"
@else :foot="__('dashboard.traffic.resets', ['days' => $traffic->daysUntilReset()])"
{{ __('dashboard.traffic.almost', ['percent' => $traffic->percent()]) }} :href="route('billing')"
class="animate-rise [animation-delay:140ms]"
>
@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. --}}
<x-slot:visual><x-ui.spark :points="$trend" area /></x-slot:visual>
@endif @endif
</p>
@if ($state !== 'ok') @if ($state !== 'ok')
{{-- The offer belongs next to the warning: someone reading that <a href="{{ route('billing') }}" wire:navigate class="mt-3 inline-flex items-center gap-1.5 text-xs font-semibold text-accent-text hover:underline">
they are nearly out should not have to go looking. --}} <x-ui.icon name="plus" class="size-3.5" />{{ __('dashboard.traffic.buy') }}
<a href="{{ route('billing') }}" wire:navigate </a>
class="inline-flex items-center gap-1.5 rounded-md border border-accent-border bg-accent-bg px-2.5 py-1 text-xs font-semibold text-accent-text transition hover:border-accent"> @endif
<x-ui.icon name="plus" class="size-3.5" />{{ __('dashboard.traffic.buy') }} </x-ui.metric>
</a> @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)
<x-ui.metric
:label="__('dashboard.master.availability')"
:value="Number::format($availability, precision: 2, locale: $locale)"
unit="%"
:foot="__('dashboard.master.availability_note')"
:href="route('cloud')"
class="animate-rise [animation-delay:180ms]"
>
@if (count($availabilityTrend) > 2)
<x-slot:visual><x-ui.spark :points="$availabilityTrend" tone="muted" /></x-slot:visual>
@endif
</x-ui.metric>
@elseif ($location)
<x-ui.metric
:label="__('dashboard.master.location')"
:value="$location['name']"
:foot="__('dashboard.master.location_note')"
class="animate-rise [animation-delay:180ms]"
/>
@endif
</section>
<section class="grid gap-4 lg:grid-cols-[minmax(0,1.5fr)_minmax(0,1fr)] lg:items-start">
{{-- ── The proof register ────────────────────────────────────── --}}
<article class="overflow-hidden rounded-lg border border-line bg-surface shadow-xs animate-rise [animation-delay:200ms]">
<div class="flex items-center justify-between gap-4 border-b border-line px-5 py-4">
<h2 class="text-md font-semibold text-ink">{{ __('dashboard.proofs.label') }}</h2>
<a href="{{ route('backups') }}" wire:navigate class="text-sm font-semibold text-accent-text hover:underline">{{ __('dashboard.proofs.all') }}</a>
</div>
@if ($proofs === [])
<p class="px-5 py-8 text-sm text-muted">{{ __('dashboard.proofs.empty') }}</p>
@else
<div class="overflow-x-auto">
<table class="w-full text-sm">
<tbody>
@foreach ($proofs as $proof)
<tr class="border-b border-line last:border-0">
<td class="px-5 py-4">
<span class="font-semibold text-ink">{{ __('dashboard.proofs.item.'.$proof['key']) }}</span>
<span class="mt-1 block font-mono text-xs text-muted">
{{ $proof['at']?->locale($locale)->isoFormat('DD.MM. HH:mm') ?? '—' }}@if ($proof['note']) · {{ $proof['note'] }}@endif
</span>
</td>
<td class="w-px whitespace-nowrap px-5 py-4 text-right">
<span @class([
'inline-flex items-center gap-1.5 rounded-pill border px-2.5 py-1 text-xs font-semibold',
'border-success-border bg-success-bg text-success' => $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',
])>
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>
{{ __('dashboard.proofs.state.'.$proof['state']) }}
</span>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
</article>
<div class="space-y-4">
{{-- ── The contract ───────────────────────────────────────── --}}
<article class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:240ms]">
<h2 class="text-md font-semibold text-ink">{{ __('dashboard.master.label') }}</h2>
<dl class="mt-4">
<div class="flex items-baseline justify-between gap-4 border-b border-line py-2.5">
<dt class="text-sm text-muted">{{ __('dashboard.master.package') }}</dt>
<dd class="text-right text-sm font-semibold text-ink">
{{ __('billing.plan.'.($instance->plan ?: 'team')) }}
@if ($planPrice)
<span class="mt-0.5 block font-mono text-xs font-normal text-muted">
{{ __('dashboard.master.per_'.($planPrice['term'] === 'yearly' ? 'year' : 'month'), ['amount' => $money($planPrice['cents'], $planPrice['currency'])]) }}
</span>
@endif
</dd>
</div>
@if ($contract?->performance)
<div class="flex items-baseline justify-between gap-4 border-b border-line py-2.5">
<dt class="text-sm text-muted">{{ __('dashboard.master.operation') }}</dt>
<dd class="text-right text-sm font-semibold text-ink">{{ __('billing.perf.'.$contract->performance) }}</dd>
</div>
@endif
@if ($domain)
<div class="flex items-baseline justify-between gap-4 py-2.5">
<dt class="text-sm text-muted">{{ __('dashboard.master.address') }}</dt>
<dd class="min-w-0 break-all text-right font-mono text-xs text-ink">{{ $domain }}</dd>
</div>
@endif
</dl>
</article>
@if ($nextInvoice)
<article class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:280ms]">
<h2 class="text-md font-semibold text-ink">{{ __('dashboard.invoice.label') }}</h2>
<dl class="mt-4">
@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. --}}
<div class="flex items-baseline justify-between gap-4 border-b border-line py-2.5">
<dt class="text-sm text-muted">{{ __('dashboard.invoice.plan') }}</dt>
<dd class="font-mono text-sm text-ink">{{ $money($nextInvoice['plan'], $nextInvoice['currency']) }}</dd>
</div>
<div class="flex items-baseline justify-between gap-4 border-b border-line py-2.5">
<dt class="text-sm text-muted">{{ __('dashboard.invoice.addons') }}</dt>
<dd class="font-mono text-sm text-ink">{{ $money($nextInvoice['addons'], $nextInvoice['currency']) }}</dd>
</div>
@endif
<div class="flex items-baseline justify-between gap-4 border-b border-line py-2.5">
<dt class="text-sm text-muted">{{ __('dashboard.invoice.amount') }}</dt>
<dd class="text-right font-mono text-md font-semibold text-ink">
{{ $money($nextInvoice['total'], $nextInvoice['currency']) }}
<span class="mt-0.5 block text-xs font-normal text-muted">{{ __('dashboard.invoice.net') }}</span>
</dd>
</div>
<div class="flex items-baseline justify-between gap-4 py-2.5">
<dt class="text-sm text-muted">{{ __('dashboard.invoice.due') }}</dt>
<dd class="font-mono text-sm text-ink">{{ $nextInvoice['due']?->locale($locale)->isoFormat('LL') ?? '—' }}</dd>
</div>
</dl>
<x-ui.button variant="secondary" :href="route('invoices')" wire:navigate class="mt-4 w-full">
{{ __('dashboard.invoice.all') }}
</x-ui.button>
</article>
@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. --}}
<article class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:320ms]">
<h2 class="text-md font-semibold text-ink">{{ __('dashboard.setup.label') }}</h2>
<p class="mt-2 text-sm text-body">{{ trans_choice('dashboard.setup.open', $openTasks, ['count' => $openTasks]) }}</p>
<a href="{{ route('cloud') }}" wire:navigate class="mt-3 inline-flex items-center gap-1.5 text-sm font-semibold text-accent-text hover:underline">
{{ __('dashboard.setup.continue') }}
</a>
</article>
@endif @endif
</div> </div>
</div> </section>
{{-- ── The three things customers come here to do ─────────────────── --}}
<section class="grid gap-px overflow-hidden rounded-lg border border-line bg-line shadow-xs animate-rise [animation-delay:360ms] sm:grid-cols-3">
@foreach ([
['users', 'users', 'add_user'],
['backups', 'rotate-ccw', 'restore'],
['billing', 'plus', 'grow'],
] as [$route, $icon, $key])
<a href="{{ route($route) }}" wire:navigate class="flex min-h-11 items-center gap-3.5 bg-surface px-5 py-4 transition hover:bg-surface-hover">
<span class="grid size-9 shrink-0 place-items-center rounded bg-accent-subtle text-accent-text">
<x-ui.icon :name="$icon" class="size-[1.05rem]" />
</span>
<span class="min-w-0">
<span class="block text-sm font-semibold text-ink">{{ __('dashboard.quick.'.$key.'_title') }}</span>
<span class="block text-xs text-muted">{{ __('dashboard.quick.'.$key.'_body') }}</span>
</span>
</a>
@endforeach
</section>
@endif @endif
{{-- KPI row --}}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
{{-- Storage ring --}}
<div class="flex items-center gap-4 rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:60ms]">
<div class="relative size-24 shrink-0" wire:ignore>
<x-ui.chart :config="$storageChart" class="size-24" :label="__('dashboard.kpi.storage')" />
<div class="pointer-events-none absolute inset-0 grid place-items-center text-center">
<div>
<span class="font-mono text-lg font-semibold text-ink">{{ $storagePercent }}%</span>
<span class="block text-[0.62rem] text-muted">{{ __('dashboard.occupied') }}</span>
</div>
</div>
</div>
<div>
<p class="text-xs font-semibold uppercase tracking-wide text-faint">{{ __('dashboard.kpi.storage') }}</p>
<p class="mt-1 font-mono text-xl font-semibold text-ink">{{ $storageUsed }}<span class="ml-1 text-sm font-normal text-muted">/ {{ $storageQuota }} GB</span></p>
<p class="mt-0.5 text-xs text-muted">{{ __('dashboard.storage_week') }}</p>
</div>
</div>
{{-- Users --}}
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:120ms]">
<p class="text-xs font-semibold uppercase tracking-wide text-faint">{{ __('dashboard.kpi.users') }}</p>
<p class="mt-2 font-mono text-2xl font-semibold text-ink">{{ $usersActive }}<span class="ml-1 text-sm font-normal text-muted">/ {{ $usersQuota }}</span></p>
<p class="mt-1 text-xs text-muted">{{ __('dashboard.users_detail') }}</p>
</div>
{{-- Last backup --}}
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:180ms]">
<p class="text-xs font-semibold uppercase tracking-wide text-faint">{{ __('dashboard.kpi.backup') }}</p>
<p class="mt-2 flex items-center gap-1.5 font-mono text-2xl font-semibold text-success">
{{ $lastBackup }}<x-ui.icon name="check" class="size-5" />
</p>
<p class="mt-1 text-xs text-muted">{{ __('dashboard.backup_verified') }}</p>
</div>
{{-- Availability sparkline --}}
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:240ms]">
<p class="text-xs font-semibold uppercase tracking-wide text-faint">{{ __('dashboard.kpi.availability') }}</p>
<p class="mt-2 font-mono text-2xl font-semibold text-ink">{{ $availability }}<span class="ml-1 text-sm font-normal text-muted">%</span></p>
<div class="mt-2 h-8" wire:ignore>
<x-ui.chart :config="$availabilityChart" class="h-8" :label="__('dashboard.kpi.availability')" />
</div>
</div>
</div>
{{-- Upsell --}}
<div class="relative flex flex-wrap items-center gap-4 overflow-hidden rounded-xl border border-accent-border bg-accent-subtle px-5 py-4 animate-rise [animation-delay:300ms]">
{{-- Shimmer sweep (left right), disabled for reduced motion --}}
<span aria-hidden="true" class="pointer-events-none absolute inset-0 overflow-hidden motion-reduce:hidden">
<span class="absolute inset-y-0 left-0 w-1/3 bg-gradient-to-r from-transparent via-surface/60 to-transparent animate-shimmer"></span>
</span>
<x-ui.icon name="alert-triangle" class="relative size-6 shrink-0 text-accent-active" />
<div class="relative min-w-0">
<p class="text-sm font-semibold text-ink">{{ __('dashboard.upsell.title', ['percent' => $storagePercent]) }}</p>
<p class="text-sm text-muted">{{ __('dashboard.upsell.body') }}</p>
</div>
<a href="{{ route('billing') }}" wire:navigate class="relative ml-auto">
<x-ui.button variant="secondary" size="sm">{{ __('dashboard.upsell.cta') }}</x-ui.button>
</a>
</div>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-[1.35fr_1fr] lg:items-start">
{{-- Left column --}}
<div class="flex flex-col gap-4">
{{-- Cloud card --}}
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:360ms]">
<div class="flex flex-wrap items-center gap-3">
<span class="grid size-11 place-items-center rounded-lg bg-ink font-semibold text-on-accent">B</span>
<div class="min-w-0">
<p class="font-semibold text-ink">{{ $cloud['name'] }}</p>
<a href="https://{{ $cloud['domain'] }}" target="_blank" rel="noopener noreferrer" class="font-mono text-sm text-accent-text hover:underline">{{ $cloud['domain'] }}</a>
</div>
<a href="https://{{ $cloud['domain'] }}" target="_blank" rel="noopener noreferrer"
class="ml-auto inline-flex items-center gap-2 rounded-pill bg-accent-active px-4 py-2 text-sm font-semibold text-on-accent transition hover:bg-accent-press">
<x-ui.icon name="external-link" class="size-4" />{{ __('dashboard.cloud.open') }}
</a>
</div>
<div class="mt-4 grid grid-cols-1 gap-3 border-t border-line pt-4 sm:grid-cols-2">
<div><p class="text-xs font-semibold uppercase tracking-wide text-faint">{{ __('dashboard.cloud.package') }}</p><p class="mt-1 text-sm text-body">{{ $cloud['package'] }}</p></div>
<div><p class="text-xs font-semibold uppercase tracking-wide text-faint">{{ __('dashboard.cloud.status') }}</p><p class="mt-1 text-sm text-success">{{ __('dashboard.cloud.active_since', ['date' => $cloud['since']]) }}</p></div>
<div><p class="text-xs font-semibold uppercase tracking-wide text-faint">{{ __('dashboard.cloud.location') }}</p><p class="mt-1 text-sm text-body">{{ $cloud['location'] }}</p></div>
<div><p class="text-xs font-semibold uppercase tracking-wide text-faint">{{ __('dashboard.cloud.version') }}</p><p class="mt-1 font-mono text-sm text-body">{{ $cloud['version'] }} · {{ __('dashboard.cloud.current') }}</p></div>
</div>
</div>
{{-- Onboarding checklist (Alpine) --}}
<div
class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:420ms]"
x-data="{
tasks: @js(collect($onboarding)->map(fn ($t) => ['label' => $t['label'], 'done' => $t['done'], 'date' => $t['date']])),
total: {{ count($onboarding) }},
get doneCount() { return this.tasks.filter(t => t.done).length; },
get percent() { return Math.round(this.doneCount / this.total * 100); },
complete(i) {
if (this.tasks[i].done) return;
this.tasks[i].done = true;
$dispatch('notify', { message: @js(__('dashboard.onboarding.done_toast')) });
},
nextIndex() { return this.tasks.findIndex(t => !t.done); },
}"
>
<div class="flex items-baseline gap-3">
<h2 class="font-semibold text-ink">{{ __('dashboard.onboarding.title') }}</h2>
<span class="ml-auto font-mono text-sm font-semibold text-muted" x-text="doneCount + ' / ' + total"></span>
</div>
<div class="mt-3 h-1.5 overflow-hidden rounded-pill bg-surface-2">
<div class="h-full rounded-pill bg-accent transition-all duration-700" :style="`width: ${percent}%`"></div>
</div>
<ul class="mt-2">
<template x-for="(task, i) in tasks" :key="i">
<li>
<button
type="button"
class="flex w-full items-center gap-3 rounded-lg px-2.5 py-3 text-left text-sm transition hover:bg-surface-hover"
:class="!task.done && i === nextIndex() ? 'bg-accent-subtle' : ''"
:disabled="task.done"
@click="complete(i)"
>
<span
class="grid size-6 shrink-0 place-items-center rounded-pill border"
:class="task.done ? 'border-success-bright bg-success-bright text-on-accent' : (i === nextIndex() ? 'border-accent' : 'border-line-strong')"
>
<svg x-show="task.done" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" class="size-3.5" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>
</span>
<span class="min-w-0 flex-1" :class="task.done ? 'text-muted line-through' : 'text-body'" x-text="task.label"></span>
<span x-show="task.date" class="text-xs text-faint" x-text="task.date"></span>
<span x-show="!task.done && i === nextIndex()" class="text-xs font-semibold text-accent-text whitespace-nowrap">{{ __('dashboard.onboarding.review') }} </span>
</button>
</li>
</template>
</ul>
</div>
</div>
{{-- Right column --}}
<div class="flex flex-col gap-4">
{{-- Backups --}}
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:360ms]">
<div class="flex items-center">
<h2 class="font-semibold text-ink">{{ __('dashboard.backups.title') }}</h2>
<span class="ml-auto rounded-pill bg-success-bg px-2.5 py-1 text-xs font-semibold text-success">{{ __('dashboard.backups.schedule') }}</span>
</div>
<ul class="mt-2 divide-y divide-line">
@foreach ($backups as $b)
<li class="flex items-center gap-3 py-2.5 text-sm">
<x-ui.icon name="check" class="size-4 text-success" />
<span class="font-mono text-xs text-muted">{{ $b['when'] }}</span>
<span class="ml-auto font-mono text-xs text-muted">{{ $b['size'] }}</span>
<x-ui.button variant="secondary" size="sm" @click="$dispatch('notify', { message: msgs.restore })">{{ __('dashboard.backups.restore') }}</x-ui.button>
</li>
@endforeach
</ul>
<p class="mt-3 flex gap-2 text-xs leading-relaxed text-muted">
<x-ui.icon name="shield" class="mt-px size-4 shrink-0 text-accent-active" />
<span>{{ __('dashboard.backups.note') }}</span>
</p>
</div>
{{-- Modules --}}
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:420ms]">
<div class="flex items-center">
<h2 class="font-semibold text-ink">{{ __('dashboard.modules.title') }}</h2>
<span class="ml-auto rounded-pill bg-surface-2 px-2.5 py-1 text-xs font-semibold text-muted">{{ __('dashboard.modules.addon') }}</span>
</div>
<ul class="mt-2 divide-y divide-line">
@foreach ($modules as $m)
<li class="flex items-center gap-3 py-2.5 text-sm">
<span class="grid size-9 shrink-0 place-items-center rounded-lg bg-surface-2 text-muted"><x-ui.icon :name="$m['icon']" class="size-5" /></span>
<div class="min-w-0">
<p class="font-medium text-ink">{{ $m['name'] }}</p>
<p class="text-xs text-muted">{{ $m['desc'] }}</p>
</div>
@if ($m['status'] === 'active')
<span class="ml-auto rounded-pill bg-success-bg px-2.5 py-1 text-xs font-semibold text-success">{{ __('dashboard.modules.active') }}</span>
@elseif ($m['status'] === 'available')
<x-ui.button variant="secondary" size="sm" class="ml-auto" @click="$dispatch('notify', { message: msgs.addon })">{{ __('dashboard.modules.add') }}</x-ui.button>
@else
<span class="ml-auto rounded-pill bg-surface-2 px-2.5 py-1 text-xs font-semibold text-muted">{{ __('dashboard.modules.soon') }}</span>
@endif
</li>
@endforeach
</ul>
</div>
{{-- Activity feed --}}
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:480ms]">
<div class="flex items-center">
<h2 class="font-semibold text-ink">{{ __('dashboard.activity') }}</h2>
<span class="ml-auto rounded-pill bg-surface-2 px-2.5 py-1 text-xs font-semibold text-muted">{{ __('dashboard.activity_live') }}</span>
</div>
<ul class="mt-2 divide-y divide-line">
@foreach ($activity as $a)
<li class="flex items-start gap-3 py-2.5 text-sm">
<span class="grid size-7 shrink-0 place-items-center rounded-lg bg-surface-2 text-accent-active"><x-ui.icon :name="$a['icon']" class="size-4" /></span>
<div class="min-w-0 flex-1">
<p class="text-body">{{ $a['text'] }}</p>
<p class="font-mono text-xs text-faint">{{ $a['time'] }}</p>
</div>
</li>
@endforeach
</ul>
</div>
</div>
</div>
</div> </div>

View File

@ -1,15 +1,15 @@
<div class="space-y-6" x-data="{ msgs: @js(['download' => __('invoices.download_toast')]) }"> <div class="space-y-6" x-data="{ msgs: @js(['download' => __('invoices.download_toast')]) }">
<div class="animate-rise"> <div class="animate-rise">
<h1 class="text-2xl font-semibold tracking-tight text-ink">{{ __('invoices.title') }}</h1> <h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('invoices.title') }}</h1>
<p class="mt-1 text-sm text-muted">{{ __('invoices.subtitle') }}</p> <p class="mt-1 text-sm text-muted">{{ __('invoices.subtitle') }}</p>
</div> </div>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_320px] lg:items-start"> <div class="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_320px] lg:items-start">
<div class="overflow-hidden rounded-xl border border-line bg-surface shadow-xs animate-rise [animation-delay:60ms]"> <div class="overflow-hidden rounded-lg border border-line bg-surface shadow-xs animate-rise [animation-delay:60ms]">
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="w-full text-sm"> <table class="w-full text-sm">
<thead> <thead>
<tr class="border-b border-line bg-surface-2 text-left text-xs uppercase tracking-wide text-faint"> <tr class="border-b border-line bg-surface-2 text-left text-xs font-semibold text-muted">
<th class="px-4 py-3 font-semibold">{{ __('invoices.col_no') }}</th> <th class="px-4 py-3 font-semibold">{{ __('invoices.col_no') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('invoices.col_date') }}</th> <th class="px-4 py-3 font-semibold">{{ __('invoices.col_date') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('invoices.col_amount') }}</th> <th class="px-4 py-3 font-semibold">{{ __('invoices.col_amount') }}</th>
@ -33,12 +33,12 @@
</div> </div>
<div class="flex flex-col gap-6"> <div class="flex flex-col gap-6">
<div class="rounded-xl border border-accent-border bg-accent-subtle p-5 shadow-xs animate-rise [animation-delay:120ms]"> <div class="rounded-lg border border-accent-border bg-accent-subtle p-5 shadow-xs animate-rise [animation-delay:120ms]">
<p class="text-xs font-semibold uppercase tracking-wide text-faint">{{ __('invoices.next_charge') }}</p> <p class="text-xs font-semibold text-muted">{{ __('invoices.next_charge') }}</p>
<p class="mt-1 font-mono text-2xl font-semibold text-ink">{{ $nextAmount }}</p> <p class="mt-1 font-mono text-2xl font-semibold text-ink">{{ $nextAmount }}</p>
<p class="mt-1 text-sm text-muted">{{ __('invoices.on_date', ['date' => $nextCharge]) }}</p> <p class="mt-1 text-sm text-muted">{{ __('invoices.on_date', ['date' => $nextCharge]) }}</p>
</div> </div>
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:180ms]"> <div class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:180ms]">
<h2 class="font-semibold text-ink">{{ __('invoices.spend') }}</h2> <h2 class="font-semibold text-ink">{{ __('invoices.spend') }}</h2>
<div class="mt-3 h-48" wire:ignore> <div class="mt-3 h-48" wire:ignore>
<x-ui.chart :config="$spendChart" class="h-48" :label="__('invoices.spend')" /> <x-ui.chart :config="$spendChart" class="h-48" :label="__('invoices.spend')" />

View File

@ -1,11 +1,11 @@
<div class="mx-auto max-w-3xl space-y-6"> <div class="mx-auto max-w-3xl space-y-6">
<div class="animate-rise"> <div class="animate-rise">
<h1 class="text-2xl font-semibold tracking-tight text-ink">{{ __('settings.title') }}</h1> <h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('settings.title') }}</h1>
<p class="mt-1 text-sm text-muted">{{ __('settings.subtitle') }}</p> <p class="mt-1 text-sm text-muted">{{ __('settings.subtitle') }}</p>
</div> </div>
{{-- Company / billing profile --}} {{-- Company / billing profile --}}
<form wire:submit="saveProfile" class="space-y-4 rounded-xl border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:60ms]"> <form wire:submit="saveProfile" class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:60ms]">
<h2 class="font-semibold text-ink">{{ __('settings.company_title') }}</h2> <h2 class="font-semibold text-ink">{{ __('settings.company_title') }}</h2>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<x-ui.input name="companyName" wire:model="companyName" :label="__('settings.company_name')" /> <x-ui.input name="companyName" wire:model="companyName" :label="__('settings.company_name')" />
@ -28,7 +28,7 @@
{{-- Own password. There was no way to change one at all Fortify's {{-- Own password. There was no way to change one at all Fortify's
updatePasswords feature was switched off. --}} updatePasswords feature was switched off. --}}
<form wire:submit="updateOwnPassword" class="space-y-4 rounded-xl border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:90ms]"> <form wire:submit="updateOwnPassword" class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:90ms]">
<div> <div>
<h2 class="font-semibold text-ink">{{ __('admin_settings.password_title') }}</h2> <h2 class="font-semibold text-ink">{{ __('admin_settings.password_title') }}</h2>
<p class="mt-1 text-sm text-muted">{{ __('admin_settings.password_sub') }}</p> <p class="mt-1 text-sm text-muted">{{ __('admin_settings.password_sub') }}</p>
@ -52,7 +52,7 @@
{{-- Two-factor. Every action is re-checked server-side against a recent {{-- Two-factor. Every action is re-checked server-side against a recent
password confirmation hiding the buttons stops nobody who can post password confirmation hiding the buttons stops nobody who can post
to /livewire/update. --}} to /livewire/update. --}}
<div class="space-y-4 rounded-xl border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:150ms]"> <div class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:150ms]">
<div class="flex flex-wrap items-start justify-between gap-4"> <div class="flex flex-wrap items-start justify-between gap-4">
<div> <div>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
@ -126,7 +126,7 @@
@endif @endif
</div> </div>
<form wire:submit="saveBranding" class="space-y-4 rounded-xl border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:120ms]"> <form wire:submit="saveBranding" class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:120ms]">
<div> <div>
<h2 class="font-semibold text-ink">{{ __('settings.branding_title') }}</h2> <h2 class="font-semibold text-ink">{{ __('settings.branding_title') }}</h2>
<p class="mt-1 text-sm text-muted">{{ __('settings.branding_sub') }}</p> <p class="mt-1 text-sm text-muted">{{ __('settings.branding_sub') }}</p>
@ -162,12 +162,12 @@
@elseif ($logoUrl) @elseif ($logoUrl)
<img src="{{ $logoUrl }}" alt="" class="max-h-16 max-w-16 object-contain" /> <img src="{{ $logoUrl }}" alt="" class="max-h-16 max-w-16 object-contain" />
@else @else
<span class="text-xs text-faint">{{ __('settings.brand_default') }}</span> <span class="text-xs text-muted">{{ __('settings.brand_default') }}</span>
@endif @endif
</div> </div>
<div class="flex flex-col gap-1.5"> <div class="flex flex-col gap-1.5">
<input type="file" wire:model="logo" accept="image/png,image/webp" class="text-sm text-body file:mr-3 file:rounded-md file:border-0 file:bg-surface-2 file:px-3 file:py-1.5 file:text-sm file:font-semibold file:text-body" /> <input type="file" wire:model="logo" accept="image/png,image/webp" class="text-sm text-body file:mr-3 file:rounded-md file:border-0 file:bg-surface-2 file:px-3 file:py-1.5 file:text-sm file:font-semibold file:text-body" />
<p class="text-xs text-faint">{{ __('settings.brand_logo_hint') }}</p> <p class="text-xs text-muted">{{ __('settings.brand_logo_hint') }}</p>
@if ($logoUrl) @if ($logoUrl)
<button type="button" wire:click="removeLogo" class="self-start text-xs font-semibold text-danger hover:underline">{{ __('settings.brand_logo_remove') }}</button> <button type="button" wire:click="removeLogo" class="self-start text-xs font-semibold text-danger hover:underline">{{ __('settings.brand_logo_remove') }}</button>
@endif @endif
@ -177,7 +177,7 @@
</div> </div>
@if ($branding['is_default'] ?? false) @if ($branding['is_default'] ?? false)
<p class="text-xs text-faint">{{ __('settings.brand_using_default') }}</p> <p class="text-xs text-muted">{{ __('settings.brand_using_default') }}</p>
@endif @endif
<div class="flex justify-end"> <div class="flex justify-end">
@ -186,7 +186,7 @@
</form> </form>
{{-- Package & account lifecycle --}} {{-- Package & account lifecycle --}}
<div class="space-y-4 rounded-xl border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:180ms]"> <div class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:180ms]">
<h2 class="font-semibold text-ink">{{ __('settings.package_title') }}</h2> <h2 class="font-semibold text-ink">{{ __('settings.package_title') }}</h2>
@if ($cancellationScheduled) @if ($cancellationScheduled)

View File

@ -1,7 +1,7 @@
<div class="space-y-6" x-data="{ open: null, msgs: @js(['sent' => __('support.new_toast')]) }"> <div class="space-y-6" x-data="{ open: null, msgs: @js(['sent' => __('support.new_toast')]) }">
<div class="flex flex-wrap items-center gap-3 animate-rise"> <div class="flex flex-wrap items-center gap-3 animate-rise">
<div> <div>
<h1 class="text-2xl font-semibold tracking-tight text-ink">{{ __('support.title') }}</h1> <h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('support.title') }}</h1>
<p class="mt-1 text-sm text-muted">{{ __('support.subtitle') }}</p> <p class="mt-1 text-sm text-muted">{{ __('support.subtitle') }}</p>
</div> </div>
<x-ui.button variant="primary" class="ml-auto" @click="$dispatch('notify', { message: msgs.sent })"> <x-ui.button variant="primary" class="ml-auto" @click="$dispatch('notify', { message: msgs.sent })">
@ -17,10 +17,10 @@
['external-link', __('support.contact_email'), 'support@clupilot.com'], ['external-link', __('support.contact_email'), 'support@clupilot.com'],
['shield-check', __('support.contact_sla'), __('support.contact_sla_val')], ['shield-check', __('support.contact_sla'), __('support.contact_sla_val')],
] as $i => [$icon, $label, $val]) ] as $i => [$icon, $label, $val])
<div class="flex items-center gap-3 rounded-xl border border-line bg-surface p-4 shadow-xs animate-rise {{ $delays[$i] }}"> <div class="flex items-center gap-3 rounded-lg border border-line bg-surface p-4 shadow-xs animate-rise {{ $delays[$i] }}">
<span class="grid size-10 shrink-0 place-items-center rounded-lg bg-surface-2 text-accent-active"><x-ui.icon :name="$icon" class="size-5" /></span> <span class="grid size-10 shrink-0 place-items-center rounded-lg bg-surface-2 text-accent-active"><x-ui.icon :name="$icon" class="size-5" /></span>
<div class="min-w-0"> <div class="min-w-0">
<p class="text-xs font-semibold uppercase tracking-wide text-faint">{{ $label }}</p> <p class="text-xs font-semibold text-muted">{{ $label }}</p>
<p class="mt-0.5 truncate text-sm font-medium text-ink">{{ $val }}</p> <p class="mt-0.5 truncate text-sm font-medium text-ink">{{ $val }}</p>
</div> </div>
</div> </div>
@ -28,29 +28,7 @@
</div> </div>
{{-- Tickets --}} {{-- Tickets --}}
<div class="overflow-hidden rounded-xl border border-line bg-surface shadow-xs animate-rise [animation-delay:240ms]"> <div class="rounded-lg border border-line bg-surface shadow-xs animate-rise [animation-delay:300ms]">
<div class="border-b border-line px-5 py-3"><h2 class="font-semibold text-ink">{{ __('support.tickets') }}</h2></div>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<tbody>
@foreach ($tickets as $t)
<tr class="border-b border-line last:border-0 hover:bg-surface-hover">
<td class="px-5 py-3">
<p class="font-medium text-ink">{{ $t['subject'] }}</p>
<p class="font-mono text-xs text-muted">{{ $t['ref'] }} · {{ $t['date'] }}</p>
</td>
<td class="px-5 py-3 text-right">
<x-ui.badge :status="$t['status'] === 'open' ? 'provisioning' : ($t['status'] === 'progress' ? 'warning' : 'active')">{{ __('support.status_'.$t['status']) }}</x-ui.badge>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
{{-- FAQ accordion --}}
<div class="rounded-xl border border-line bg-surface shadow-xs animate-rise [animation-delay:300ms]">
<div class="border-b border-line px-5 py-3"><h2 class="font-semibold text-ink">{{ __('support.faq') }}</h2></div> <div class="border-b border-line px-5 py-3"><h2 class="font-semibold text-ink">{{ __('support.faq') }}</h2></div>
<ul class="divide-y divide-line"> <ul class="divide-y divide-line">
@foreach ($faqs as $i => $f) @foreach ($faqs as $i => $f)

View File

@ -1,18 +1,18 @@
<div class="space-y-6"> <div class="space-y-6">
<div class="flex flex-wrap items-end justify-between gap-4 animate-rise"> <div class="flex flex-wrap items-end justify-between gap-4 animate-rise">
<div> <div>
<h1 class="text-2xl font-semibold tracking-tight text-ink">{{ __('users.title') }}</h1> <h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('users.title') }}</h1>
<p class="mt-1 text-sm text-muted">{{ __('users.subtitle') }}</p> <p class="mt-1 text-sm text-muted">{{ __('users.subtitle') }}</p>
</div> </div>
<div class="text-right"> <div class="text-right">
<p class="font-mono text-lg font-semibold text-ink">{{ $used }} / {{ $limit }}</p> <p class="font-mono text-lg font-semibold text-ink">{{ $used }} / {{ $limit }}</p>
<p class="text-xs text-muted">{{ __('users.seats_used') }}</p> <p class="text-xs text-muted">{{ __('users.seats_used') }}</p>
<p class="text-xs text-faint">{{ __('users.seats_note') }}</p> <p class="text-xs text-muted">{{ __('users.seats_note') }}</p>
</div> </div>
</div> </div>
{{-- Invite --}} {{-- Invite --}}
<form wire:submit="invite" class="grid grid-cols-1 gap-3 rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:60ms] sm:grid-cols-[1fr_1fr_auto_auto] sm:items-end"> <form wire:submit="invite" class="grid grid-cols-1 gap-3 rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:60ms] sm:grid-cols-[1fr_1fr_auto_auto] sm:items-end">
<div> <div>
<label class="text-sm font-medium text-body" for="inviteEmail">{{ __('users.invite_email') }}</label> <label class="text-sm font-medium text-body" for="inviteEmail">{{ __('users.invite_email') }}</label>
<input id="inviteEmail" type="email" wire:model="inviteEmail" class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-ink" /> <input id="inviteEmail" type="email" wire:model="inviteEmail" class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-ink" />
@ -36,15 +36,17 @@
</form> </form>
{{-- Seats --}} {{-- Seats --}}
<div class="overflow-hidden rounded-xl border border-line bg-surface shadow-xs animate-rise [animation-delay:120ms]"> <div class="overflow-hidden rounded-lg border border-line bg-surface shadow-xs animate-rise [animation-delay:120ms]">
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="w-full text-sm"> <table class="w-full text-sm">
<thead> <thead>
<tr class="border-b border-line bg-surface-2 text-left text-xs uppercase tracking-wide text-faint"> <tr class="border-b border-line bg-surface-2 text-left text-xs font-semibold text-muted">
<th class="px-4 py-3 font-semibold">{{ __('users.col_person') }}</th> <th class="px-4 py-3 font-semibold">{{ __('users.col_person') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('users.role') }}</th> <th class="px-4 py-3 font-semibold">{{ __('users.role') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('users.col_status') }}</th> <th class="px-4 py-3 font-semibold">{{ __('users.col_status') }}</th>
<th class="px-4 py-3 text-right font-semibold">{{ __('users.col_actions') }}</th> @if ($hasActions)
<th class="px-4 py-3 text-right font-semibold">{{ __('users.col_actions') }}</th>
@endif
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -69,19 +71,36 @@
@php $sb = ['active' => 'active', 'invited' => 'provisioning', 'revoked' => 'suspended'][$seat->status] ?? 'info'; @endphp @php $sb = ['active' => 'active', 'invited' => 'provisioning', 'revoked' => 'suspended'][$seat->status] ?? 'info'; @endphp
<x-ui.badge :status="$sb">{{ __('users.status_'.$seat->status) }}</x-ui.badge> <x-ui.badge :status="$sb">{{ __('users.status_'.$seat->status) }}</x-ui.badge>
</td> </td>
@if ($hasActions)
<td class="px-4 py-3"> <td class="px-4 py-3">
<div class="flex items-center justify-end gap-1.5"> <div class="flex items-center justify-end gap-1.5">
@if ($seat->role === 'owner')
<span class="text-muted" title="{{ __('users.owner_no_actions') }}"></span>
@endif
@if ($seat->status === 'invited') @if ($seat->status === 'invited')
<button type="button" wire:click="resend('{{ $seat->uuid }}')" class="rounded-md border border-line px-2.5 py-1.5 text-xs font-semibold text-muted hover:bg-surface-hover">{{ __('users.resend') }}</button> <button type="button" wire:click="resend('{{ $seat->uuid }}')" class="rounded-md border border-line px-2.5 py-1.5 text-xs font-semibold text-muted hover:bg-surface-hover">{{ __('users.resend') }}</button>
@endif @endif
@if ($seat->role !== 'owner') @if ($seat->role !== 'owner')
<button type="button" wire:click="revoke('{{ $seat->uuid }}')" aria-label="{{ __('users.revoke') }}" {{-- Pause first, remove second. Somebody
class="grid size-8 place-items-center rounded-md border border-line text-muted hover:border-danger hover:text-danger"> leaving needs their access stopped
today; deleting the seat also throws
away who held it, which is the half
an audit asks about. --}}
<button type="button" wire:click="suspend('{{ $seat->uuid }}')"
aria-label="{{ $seat->status === 'suspended' ? __('users.reactivate') : __('users.suspend') }}"
title="{{ $seat->status === 'suspended' ? __('users.reactivate') : __('users.suspend') }}"
class="grid size-9 place-items-center rounded-md border border-line text-muted hover:border-warning hover:text-warning">
<x-ui.icon :name="$seat->status === 'suspended' ? 'unlock' : 'lock'" class="size-4" />
</button>
<button type="button" wire:click="revoke('{{ $seat->uuid }}')"
aria-label="{{ __('users.revoke') }}" title="{{ __('users.revoke') }}"
class="grid size-9 place-items-center rounded-md border border-line text-muted hover:border-danger hover:text-danger">
<x-ui.icon name="trash-2" class="size-4" /> <x-ui.icon name="trash-2" class="size-4" />
</button> </button>
@endif @endif
</div> </div>
</td> </td>
@endif
</tr> </tr>
@endforeach @endforeach
</tbody> </tbody>

View File

@ -8,27 +8,24 @@
<meta name="robots" content="noindex"> <meta name="robots" content="noindex">
<link rel="icon" type="image/svg+xml" href="/favicon.svg"> <link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="preload" as="font" type="font/woff2" href="/fonts/ibm-plex-serif-latin-600-normal.woff2" crossorigin>
@verbatim @verbatim
<style> <style>
@font-face{font-family:"Plex Serif";src:url("/fonts/ibm-plex-serif-latin-600-normal.woff2") format("woff2");font-weight:600;font-style:normal;font-display:swap}
@font-face{font-family:"Plex Sans";src:url("/fonts/ibm-plex-sans-latin-400-normal.woff2") format("woff2");font-weight:400;font-style:normal;font-display:swap} @font-face{font-family:"Plex Sans";src:url("/fonts/ibm-plex-sans-latin-400-normal.woff2") format("woff2");font-weight:400;font-style:normal;font-display:swap}
@font-face{font-family:"Plex Sans";src:url("/fonts/ibm-plex-sans-latin-500-normal.woff2") format("woff2");font-weight:500;font-style:normal;font-display:swap} @font-face{font-family:"Plex Sans";src:url("/fonts/ibm-plex-sans-latin-500-normal.woff2") format("woff2");font-weight:500;font-style:normal;font-display:swap}
@font-face{font-family:"Plex Mono";src:url("/fonts/ibm-plex-mono-latin-400-normal.woff2") format("woff2");font-weight:400;font-style:normal;font-display:swap} @font-face{font-family:"Plex Mono";src:url("/fonts/ibm-plex-mono-latin-400-normal.woff2") format("woff2");font-weight:400;font-style:normal;font-display:swap}
:root{ :root{
--paper:#f7f5f1; --paper-2:#efebe3; --card:#fffefb; --paper:#f6f6f8; --paper-2:#fafafb; --card:#ffffff;
--ink:#17140f; --ink-soft:#413a31; --muted:#7b7367; --faint:#a39a8c; --ink:#17171c; --ink-soft:#43434e; --muted:#6e6e7a; --faint:#a39a8c;
--rule:#e3ded3; --rule-mid:#cbc3b4; --rule:#e9e9ee; --rule-mid:#dcdce4;
--accent:#f97316; --accent-text:#b8500a; --accent:#f97316; --accent-text:#b8500a;
--ok:#2f7d4f; --ok-bg:#eef7f1; --ok:#2f7d4f; --ok-bg:#eef7f1;
--warn:#a2600c; --warn-bg:#fdf5e7; --warn:#a2600c; --warn-bg:#fdf5e7;
--bad:#a4372a; --bad-bg:#fdf1ef; --bad:#a4372a; --bad-bg:#fdf1ef;
--unknown:#6c6459; --unknown-bg:#f1eee8; --unknown:#6c6459; --unknown-bg:#f1eee8;
--serif:"Plex Serif",Georgia,serif;
--sans:"Plex Sans",-apple-system,BlinkMacSystemFont,sans-serif; --sans:"Plex Sans",-apple-system,BlinkMacSystemFont,sans-serif;
--mono:"Plex Mono",ui-monospace,monospace; --mono:"Plex Mono",ui-monospace,monospace;
--r:3px; --r:16px;
} }
*{margin:0;padding:0;box-sizing:border-box} *{margin:0;padding:0;box-sizing:border-box}
body{font-family:var(--sans);background:var(--paper);color:var(--ink-soft);font-size:16px;line-height:1.6;-webkit-font-smoothing:antialiased} body{font-family:var(--sans);background:var(--paper);color:var(--ink-soft);font-size:16px;line-height:1.6;-webkit-font-smoothing:antialiased}
@ -37,9 +34,9 @@
.wrap{max-width:760px;margin:0 auto;padding:0 clamp(20px,5vw,32px)} .wrap{max-width:760px;margin:0 auto;padding:0 clamp(20px,5vw,32px)}
header{padding:clamp(48px,8vw,80px) 0 0} header{padding:clamp(48px,8vw,80px) 0 0}
.mark{font-family:var(--serif);font-weight:600;font-size:1.12rem;letter-spacing:-.02em;color:var(--ink)} .mark{font-weight:700;font-size:1.12rem;letter-spacing:-.02em;color:var(--ink)}
.mark i{font-style:normal;color:var(--accent-text)} .mark i{font-style:normal;color:var(--accent-text)}
h1{font-family:var(--serif);font-weight:600;font-size:clamp(2rem,4.5vw,2.8rem);letter-spacing:-.028em;line-height:1.1;color:var(--ink);margin-top:28px} h1{font-weight:700;font-size:clamp(2rem,4.5vw,2.8rem);letter-spacing:-.028em;line-height:1.1;color:var(--ink);margin-top:28px}
.overall{ .overall{
display:flex;align-items:center;gap:13px;margin-top:22px;padding:16px 20px; display:flex;align-items:center;gap:13px;margin-top:22px;padding:16px 20px;

View File

@ -17,7 +17,14 @@ it('forbids non-admin users from the admin console', function (string $route) {
it('renders the admin console for admins', function (string $route) { it('renders the admin console for admins', function (string $route) {
$admin = User::factory()->create(['is_admin' => true]); $admin = User::factory()->create(['is_admin' => true]);
// Asserted on the console badge and the sign-out control rather than on the
// wordmark: the wordmark is split across an element so its second half can
// carry the accent, so "CluPilot" is not a contiguous string in the markup.
// These two say what the assertion actually meant — the console shell, with
// its navigation, rendered for this route.
$this->actingAs($admin)->get(route($route)) $this->actingAs($admin)->get(route($route))
->assertOk() ->assertOk()
->assertSee('CluPilot'); ->assertSee(__('admin.badge'))
->assertSee(__('admin.nav.overview'))
->assertSee(__('dashboard.logout'));
})->with($adminRoutes); })->with($adminRoutes);

View File

@ -14,3 +14,37 @@ it('shows the dashboard to authenticated users', function () {
->assertSee('CluPilot') ->assertSee('CluPilot')
->assertSee(__('dashboard.title')); ->assertSee(__('dashboard.title'));
}); });
it('shows no next invoice once the customer has given notice', function () {
// Cancelling leaves the subscription active until the term runs out, so
// `current_period_end` becomes the day service ENDS rather than the day the
// next charge falls. Billing a renewal that will never happen, on the sheet
// a customer checks their invoices against, is the worst line this page
// could carry.
$user = App\Models\User::factory()->create();
$customer = App\Models\Customer::factory()->create(['user_id' => $user->id]);
$instance = App\Models\Instance::factory()->create([
'customer_id' => $customer->id,
'status' => 'cancellation_scheduled',
'cancel_requested_at' => now(),
'service_ends_at' => now()->addMonth(),
]);
App\Models\Subscription::factory()->create([
'customer_id' => $customer->id,
'instance_id' => $instance->id,
'status' => 'active',
'price_cents' => 17900,
'currency' => 'EUR',
'term' => 'monthly',
'current_period_end' => now()->addMonth(),
]);
Livewire::actingAs($user)
->test(App\Livewire\Dashboard::class)
->assertViewHas('nextInvoice', null)
// The price is still on the master record — they pay it until the end.
->assertViewHas('planPrice', fn (?array $p) => $p !== null && $p['cents'] === 17900)
->assertDontSee(__('dashboard.invoice.label'));
});

View File

@ -0,0 +1,98 @@
<?php
use App\Livewire\Billing;
use App\Models\Customer;
use App\Models\Instance;
use App\Models\InstanceMetric;
use App\Models\Order;
use App\Models\Seat;
use App\Models\User;
use App\Services\Billing\DowngradeCheck;
use Livewire\Livewire;
/**
* Moving down to a smaller plan.
*
* A downgrade is not the mirror of an upgrade: going up always fits, going
* down can ask the instance to hold more than the target allows. What matters
* as much as refusing it is SAYING WHY, in the customer's own numbers a
* disabled button that does not name the obstacle is the thing people ring
* about.
*/
function downgradeCustomer(int $seats = 1, ?int $usedBytes = null): array
{
$user = User::factory()->create();
$customer = Customer::factory()->create(['user_id' => $user->id]);
$instance = Instance::factory()->create(['customer_id' => $customer->id, 'status' => 'active', 'quota_gb' => 500]);
foreach (range(1, $seats) as $n) {
Seat::create([
'customer_id' => $customer->id,
'email' => "seat{$n}@example.test",
'role' => $n === 1 ? 'owner' : 'member',
'status' => 'active',
]);
}
if ($usedBytes !== null) {
InstanceMetric::create([
'instance_id' => $instance->id,
'day' => now()->toDateString(),
'disk_used_bytes' => $usedBytes,
'disk_total_bytes' => 500 * 1024 ** 3,
]);
}
return [$user, $customer, $instance];
}
it('allows a downgrade when everything fits', function () {
[, $customer, $instance] = downgradeCustomer(seats: 3, usedBytes: 40 * 1024 ** 3);
$check = DowngradeCheck::for($customer, $instance, ['seats' => 10, 'quota_gb' => 200]);
expect($check->allowed)->toBeTrue()->and($check->blockers)->toBe([]);
});
it('names the seats that are in the way, with both numbers', function () {
[, $customer, $instance] = downgradeCustomer(seats: 31);
$check = DowngradeCheck::for($customer, $instance, ['seats' => 10, 'quota_gb' => 200]);
expect($check->allowed)->toBeFalse()
->and($check->blockers[0]['key'])->toBe('seats')
->and($check->blockers[0]['current'])->toBe('31')
->and($check->blockers[0]['limit'])->toBe('10');
});
it('measures storage rather than trusting the allowance', function () {
// 480 GB really in use. The contract says 500 GB, but what decides whether
// 200 GB is enough is what is actually stored.
[, $customer, $instance] = downgradeCustomer(seats: 2, usedBytes: 480 * 1024 ** 3);
$check = DowngradeCheck::for($customer, $instance, ['seats' => 10, 'quota_gb' => 200]);
expect($check->allowed)->toBeFalse()
->and(collect($check->blockers)->pluck('key')->all())->toBe(['storage']);
});
it('does not refuse a customer who has simply never been measured', function () {
// A new instance has no reading. Refusing on the contractual allowance
// would block every downgrade for anyone who once bought a large plan.
[, $customer, $instance] = downgradeCustomer(seats: 2, usedBytes: null);
expect(DowngradeCheck::for($customer, $instance, ['seats' => 10, 'quota_gb' => 200])->allowed)->toBeTrue();
});
it('refuses a blocked downgrade even when the button is never rendered', function () {
// The action is callable from a stale tab or a second window. A limit
// enforced only in markup is not enforced.
[$user, $customer, $instance] = downgradeCustomer(seats: 31);
Livewire::actingAs($user)
->test(Billing::class)
->call('purchase', 'downgrade', 'start');
expect(Order::query()->where('customer_id', $customer->id)->where('type', 'downgrade')->count())->toBe(0);
});

View File

@ -0,0 +1,87 @@
<?php
use App\Livewire\Dashboard;
use App\Models\Customer;
use App\Models\Instance;
use App\Models\InstanceMetric;
use App\Models\User;
use Illuminate\Support\Carbon;
use Livewire\Livewire;
/**
* The measurements behind the panel's ring and trend.
*
* The rule they all serve: a reading that could not be taken is not a reading
* of zero. Charting a failed sample as 0 GB tells a customer their data is
* gone, and charting a missing day as no traffic draws an outage that never
* happened.
*/
function metricFor(Instance $instance, string $day, ?int $used, ?int $total, int $rx = 0, int $tx = 0): InstanceMetric
{
return InstanceMetric::create([
'instance_id' => $instance->id,
'day' => $day,
'disk_used_bytes' => $used,
'disk_total_bytes' => $total,
'rx_bytes' => $rx,
'tx_bytes' => $tx,
]);
}
function customerWithInstance(): array
{
$user = User::factory()->create();
$customer = Customer::factory()->create(['user_id' => $user->id]);
$instance = Instance::factory()->create(['customer_id' => $customer->id, 'status' => 'active', 'quota_gb' => 500]);
return [$user, $instance];
}
it('shows the allowance, not a ring, until something has been measured', function () {
[$user, $instance] = customerWithInstance();
Livewire::actingAs($user)
->test(Dashboard::class)
->assertViewHas('disk', null)
// The contractual figure is true and says so; a ring at 0 % would read
// as "your cloud is empty".
->assertSee(__('dashboard.master.storage_note'));
});
it('draws the ring once a reading exists', function () {
[$user, $instance] = customerWithInstance();
metricFor($instance, Carbon::today()->toDateString(), used: 235_000_000_000, total: 500_000_000_000);
Livewire::actingAs($user)
->test(Dashboard::class)
->assertViewHas('disk', fn (?array $d) => $d !== null && (int) round($d['percent']) === 47);
});
it('keeps the last good reading when a sample could not be taken', function () {
[$user, $instance] = customerWithInstance();
metricFor($instance, Carbon::yesterday()->toDateString(), used: 200_000_000_000, total: 500_000_000_000);
// Today's row exists — the traffic half of the sampler wrote it — but the
// guest agent did not answer, so the disk columns stayed null.
metricFor($instance, Carbon::today()->toDateString(), used: null, total: null, rx: 5_000);
Livewire::actingAs($user)
->test(Dashboard::class)
->assertViewHas('disk', fn (?array $d) => $d !== null && $d['used'] === 200_000_000_000);
});
it('passes the measured days to the trend, and only those', function () {
[$user, $instance] = customerWithInstance();
metricFor($instance, Carbon::today()->subDays(2)->toDateString(), null, null, rx: 1_000, tx: 500);
metricFor($instance, Carbon::today()->subDay()->toDateString(), null, null, rx: 2_000, tx: 500);
metricFor($instance, Carbon::today()->toDateString(), null, null, rx: 4_000, tx: 1_000);
// Outside the window the panel draws.
metricFor($instance, Carbon::today()->subDays(40)->toDateString(), null, null, rx: 9_999_999);
Livewire::actingAs($user)
->test(Dashboard::class)
->assertViewHas('trend', [1_500, 2_500, 5_000]);
});

View File

@ -175,7 +175,7 @@ it('shows the customer what is left, and offers a top-up when it gets tight', fu
// Comfortable: no upsell in the customer's face. // Comfortable: no upsell in the customer's face.
Livewire\Livewire::actingAs($user)->test(Dashboard::class) Livewire\Livewire::actingAs($user)->test(Dashboard::class)
->assertSee(__('dashboard.traffic.title')) ->assertSee(__('dashboard.traffic.label'))
->assertDontSee(__('dashboard.traffic.buy')); ->assertDontSee(__('dashboard.traffic.buy'));
InstanceTraffic::query()->update(['tx_bytes' => (int) (1000 * 1000 ** 3 * 0.9)]); InstanceTraffic::query()->update(['tx_bytes' => (int) (1000 * 1000 ** 3 * 0.9)]);