Compare commits

...

9 Commits

Author SHA1 Message Date
nexxo 186c2e46d1 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
2026-07-27 17:10:35 +02:00
nexxo 4daf37a10b Give the demo account a real password, not one written in the source
The demo customer is a genuine login on a panel that answers from the public
internet. A default password in a seeder would sit on production for as long as
the demo does, and "it is only the demo account" is how the first one gets
taken. It now takes DEMO_PASSWORD if the operator sets one and otherwise mints
a 24-character random password, printed once at seed time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 17:09:54 +02:00
nexxo 435a202fdd Match the panel to the approved template, measured rather than assumed
tests / pest (push) Successful in 8m49s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
The panel had been built by reading the template and believing the result.
Every round of review found the same class of defect, so this round the
template was rendered in a browser and measured with getComputedStyle, and the
implementation measured the same way — two number columns instead of an
opinion.

That immediately settled a disagreement: a review of the template's SOURCE said
the metric grid used a 20px gap. Rendered, it is 14px. The measurement wins.

Brought to the template's figures: label 11.5px (it was 11px in three separate
places, which is how it drifted — it is now one .lbl rule), value tracking
-0.02em, unit weight 450, metric row 6px above and 14px between, page head
centred with a 14px gap, grid gap 14px, columns switching at 1101/561px and the
h1 at 901px to match the template's own breakpoints, ring 62px, bar 5px.

The shared button now takes its height from min-height (40px) rather than
vertical padding, at 14px/600 with 0 18px padding and a 10px radius — a button
keeps its height whatever sits inside it, icon, spinner or bare text.

Also here, from the same round of review:

- The chart's blue border was Tailwind's own `ring` utility colliding with a
  component class of the same name. Renamed to `.metric-ring`. This was
  dismissed once as a screenshot artefact; it was real.
- Page titles lost the `sm:text-3xl` (40px) an earlier bulk edit had appended
  to 23 of them. The template's h1 is 30px.
- The users table has its actions back — edit, suspend, lock and delete — for
  every seat that is not the owner, with the owner refused in the action itself
  and not merely hidden in the markup.

DemoCustomerSeeder writes one complete customer as real rows: instance,
subscription, six seats across four roles, a backup, current-period traffic and
thirty days of samples with a deliberate wobble and one day at 286/288 checks.
Nothing in the panel is drawn from a fixture any more, so anything missing
shows up as missing. Removing the demo is one deletion.

Verified: 607 tests, and a Codex comparison of the two measurement sets —
"a person would call them the same design", the only difference a 1px gap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 17:08:05 +02:00
nexxo 85fcc154b8 Measure availability, and let a customer move down again
Availability is now counted from our own monitoring answers. 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 have left the panel
without the number indefinitely. The sync already asks on a schedule; counting
the answers gives a percentage that is ours and explainable.

An unanswered check is not a failed one — the counter only moves when a real
answer arrived, so our monitoring going down does not become the customer's
outage on the one figure they are asked to trust. An instance nobody has ever
checked shows no availability rather than 100 %.

The downgrade path existed nowhere. Going down is not the mirror of going up:
it can ask an instance to hold more than the target plan allows. Smaller plans
are now listed even when they cannot be taken, with the obstacle in the
customer's own numbers — "you have 31 users, this package allows 10" — because
a greyed-out button that does not name what is in the way is the thing people
ring about. Storage is checked against the last real reading rather than the
contractual allowance, or anyone who once bought a large plan could never leave
it. The limit is re-checked in the action: a rule enforced only in markup is
not enforced.

And the smaller corrections from this round:

- The cloud tab carried a hardcoded "B" as the instance's initial, and the PHP
  and MariaDB versions, which tell a tenant nothing they can act on.
- "EU — Serverstandort im Angebot festgehalten" under a label already reading
  "Serverstandort" was two sentences saying nothing. It is the country.
- Support promised a reply "binnen 4 Std." with nothing behind it. What is true
  everywhere else is an answer on the same working day.
- The actions column appeared even when no row in the table had an action.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 16:41:15 +02:00
nexxo ee5e92c0ed Measure what the template draws
The panel could not show the storage ring or the transfer trend because nothing
measured them. instance_traffic keeps one row per billing period — right for an
allowance, useless as a series — and storage consumption was never sampled at
all, so the card could only ever state what was sold rather than what is used.

instance_metrics holds one row per instance per day, written by the traffic
collector on the visit it already makes. A second scheduler entry against the
same Proxmox API would have doubled the load and the failure modes for no gain.

Disk usage comes from `df` inside the guest rather than from Proxmox's own disk
figure, which reports the allocated image: that would show every customer a full
500 GB from their first day.

The rule the whole thing is built on: a reading that could not be taken is not a
reading of zero. The disk columns are nullable and left untouched when the guest
agent does not answer, so yesterday's figure stands instead of the ring dropping
to empty — which would tell a customer their data had vanished. Missing days
stay missing in the series rather than being filled with zeroes, which would
draw an outage that never happened. Both are covered by tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 16:29:28 +02:00
nexxo c2a56382e1 Build the dashboard cards to the template, and name what has no source
Card by card against the approved template: label with a chevron, the figure
and its unit on one line, a line of context underneath, a bar across the foot.
The order matches too — storage, seats, transfer, then the fourth slot.

Three of the template's four visuals have no data behind them, and the code now
says so where someone will read it rather than leaving the next person to
wonder why the charts are missing:

- Storage consumption is never sampled. The card states the contractually
  agreed allowance instead of drawing a ring at an invented level.
- instance_traffic keeps one row per period, not a daily series, so there is
  nothing to draw a sparkline from. The share of the allowance is real, so
  that card carries a bar.
- monitoring_targets records a state and a check time but no uptime figure, so
  there is no availability percentage to show. That slot carries the last
  backup — the thing this product is about and can actually prove.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 16:21:27 +02:00
nexxo 676c38643c Follow the approved template, and stop showing invented records
The dashboard was built to my own idea of the layout rather than to the
template that was signed off: a coloured pill where the template has a stamped
mono line, bare figures where it has a label with a chevron, a unit on its own
line instead of beside the number, and no ring at all. There is no point
agreeing a design and then building something adjacent to it. The metric card
is now a component that draws the template's form, and the pages use it.

Two more places were still showing data that does not exist:

- Support listed three tickets to every customer, complete with reference
  numbers and dates. There is no ticket model; it was fixture text. A request
  list also belongs in the console, where an operator can see who filed what.
  Removed until there is something real behind it.
- The datacentre name reached the customer through the cloud tab's copy as
  well. Customer surfaces name the jurisdiction; the building is operator
  information.

And the actions column rendered an empty cell for the owner, who can be neither
re-invited nor revoked. An empty cell reads as a missing feature; it now says
so with a dash and a title.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 16:17:06 +02:00
nexxo 21d8dc310a One definition of the navigation, and the country instead of the building
The breadcrumb said Übersicht on every page. The sidebar and the breadcrumb
each built their own copy of the structure, so the layout had no way to know
which entry was current and fell back to the first one. Navigation::portal()
and ::console() are now the single definition, and currentLabel() matches on the
route name — which matters for the console, whose PATH changes between
host-bound and fallback mode while its route names do not.

The datacentre name is out of everything a customer sees, for the third time
and now at the source rather than in a template. Falkenstein is how an
operator places an instance; a customer's processing record names the
jurisdiction, and putting the building in customer copy means editing that copy
every time the estate grows.

Also swept the views onto the new scale: --text-faint is 2.8:1 and a decoration
token, but it was carrying table headers, hints and timestamps — text people
have to read. All of it moved to --muted, which is 5.0:1 and passes AA.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 16:09:39 +02:00
nexxo 6376f5e9fa One design system for every surface
The console was a second product. admin-tokens.css held a complete dark set —
its own surfaces, its own orange, its own status triad — so every shared
component had to work in two worlds, and the two halves of the application
drifted apart in ways nobody could see from inside either one. That file now
carries density and nothing else: smaller type, tighter rows, tighter corners.
Colour comes from one place.

The tokens themselves are rebuilt around what the redesign settled on:

- Radii scaled to object size (9/11/16/22) instead of one value everywhere,
  which is what made the interface read as a design-system specification
  rather than as a product.
- Warm, broad, low shadows. A neutral grey drop shadow belongs to a different
  design language and reads as cold on this ground.
- --accent-press, which several rules already referenced and nothing defined.
  An undefined var() does not make a browser skip the declaration; it computes
  to , and for background-color that is transparent — the accent button
  lost its fill on hover and left white text on white.
- No serif. A serif headline is what made every page read as a document, which
  is the impression the whole redesign exists to remove. --font-serif now
  points at the sans so nothing breaks while call sites are cleaned up.

The two shells are one shell with two configurations, and both finally have a
mobile navigation: the sidebar used to simply disappear below 900px, leaving no
way to move around the application at all on a phone.

The customer dashboard is bound to real records — instance, seats, traffic,
backups, contract — instead of the fixtures it shipped with. Where something is
not measured, storage consumption, it says what was contractually agreed rather
than inventing a figure. This is the sheet a customer forwards to their auditor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 16:03:47 +02:00
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\Subscription;
use App\Services\Billing\AddonCatalogue;
use App\Services\Billing\DowngradeCheck;
use App\Services\Billing\PlanCatalogue;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Layout;
@ -34,7 +35,7 @@ class Billing extends Component
$addons = (array) config('provisioning.addons');
[$plan, $amount, $addonKey] = match ($type) {
'upgrade' => [$key, (int) ($plans[$key]['price_cents'] ?? 0), null],
'upgrade', 'downgrade' => [$key, (int) ($plans[$key]['price_cents'] ?? 0), null],
'storage' => [$currentPlan, (int) config('provisioning.storage_addon.price_cents', 0), null],
'traffic' => [$currentPlan, (int) config('provisioning.traffic.addon.price_cents', 0), null],
'addon' => [$currentPlan, (int) ($addons[$key]['price_cents'] ?? 0), $key],
@ -48,6 +49,13 @@ class Billing extends Component
// one and charge for the privilege.
'upgrade' => isset($plans[$key])
&& (int) ($plans[$key]['tier'] ?? 0) > (int) ($instance?->subscription?->tier ?? $plans[$currentPlan]['tier'] ?? 0),
// Re-checked here and not only in the view: the button can be
// hidden and the action still called — a stale tab, a second
// window, anyone with the component name. A limit enforced only in
// markup is not enforced.
'downgrade' => isset($plans[$key])
&& (int) ($plans[$key]['tier'] ?? 0) < (int) ($instance?->subscription?->tier ?? $plans[$currentPlan]['tier'] ?? 0)
&& DowngradeCheck::for($customer, $instance, $plans[$key])->allowed,
'storage' => true,
// Always available: running out of traffic is exactly when someone
// needs to be able to buy more, whatever plan they are on.
@ -73,11 +81,13 @@ class Billing extends Component
$replaced = DB::transaction(function () use ($customer, $type, $plan, $addonKey, $amount, $datacenter) {
Customer::query()->whereKey($customer->id)->lockForUpdate()->first();
$replaced = $type === 'upgrade'
// Up and down are the same kind of change and contradict each
// other just as much, so either replaces a pending one of both.
$replaced = in_array($type, ['upgrade', 'downgrade'], true)
? Order::query()
->where('customer_id', $customer->id)
->where('status', 'pending')
->where('type', 'upgrade')
->whereIn('type', ['upgrade', 'downgrade'])
->delete()
: 0;
@ -147,6 +157,16 @@ class Billing extends Component
// customer immediately for losing resources.
$currentTier = (int) ($instance?->subscription?->tier ?? $plans[$currentKey]['tier'] ?? 0);
// Smaller plans, each with the reason it cannot be taken today. Shown
// WITH the reason rather than hidden: a customer who cannot downgrade
// needs to know what to delete, and a plan that silently disappears
// reads as "not offered any more".
$downgrades = collect($plans)
->filter(fn ($p) => (int) ($p['tier'] ?? 0) < $currentTier)
->sortByDesc(fn ($p) => (int) ($p['tier'] ?? 0))
->map(fn ($p, $k) => $p + ['check' => DowngradeCheck::for($customer, $instance, $p)])
->all();
$upgrades = collect($plans)
->filter(fn ($p) => (int) ($p['tier'] ?? 0) > $currentTier)
->keys()->all();
@ -162,6 +182,7 @@ class Billing extends Component
'plans' => $plans,
'features' => collect($plans)->map(fn ($p) => $p['features'] ?? [])->all(),
'upgrades' => $upgrades,
'downgrades' => $downgrades,
'storage' => (array) config('provisioning.storage_addon'),
'trafficAddon' => (array) config('provisioning.traffic.addon'),
// Resolved once: the cart and the plan cards must not state

View File

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

View File

@ -3,12 +3,27 @@
namespace App\Livewire;
use App\Livewire\Concerns\ResolvesCustomer;
use App\Models\Customer;
use App\Models\Datacenter;
use App\Models\Instance;
use App\Models\InstanceMetric;
use App\Models\MaintenanceWindow;
use App\Models\Seat;
use App\Models\Subscription;
use App\Services\Traffic\TrafficMeter;
use Illuminate\Support\Carbon;
use Illuminate\Support\Number;
use Livewire\Attributes\Layout;
use Livewire\Component;
/**
* The customer's Betriebsblatt: what they have, and the evidence that it is
* being looked after.
*
* Every figure on this page comes from a record. Where something is not
* measured yet storage consumption, restore tests the page says nothing
* rather than showing a plausible number: this is the sheet a customer forwards
* to their auditor, and one invented line on it is worse than a short register.
*/
#[Layout('layouts.portal-app')]
class Dashboard extends Component
{
@ -16,118 +31,290 @@ class Dashboard extends Component
public function render()
{
// Real numbers where they exist: traffic is metered, unlike the
// fixtures below, and a customer needs to see it before it runs out.
$instance = $this->customer()?->instances()->latest('id')->first();
$traffic = $instance !== null ? TrafficMeter::for($instance) : null;
$customer = $this->customer();
// Locale-aware date / number formatting for the fixture values below.
$locale = app()->getLocale();
$date = fn (string $iso, string $fmt) => Carbon::parse($iso)->locale($locale)->isoFormat($fmt);
$gb = fn (float $v) => Number::format($v, precision: 1, locale: $locale).' GB';
// Fixture data until the provisioning engine (separate handoff) supplies
// real instances / metrics. Tenant-specific values stand in for DB rows.
$storageUsed = 235;
$storageQuota = 500;
$storagePercent = (int) round($storageUsed / max(1, $storageQuota) * 100);
// The instance that is actually in service. A cancelled one still has
// rows, and would otherwise keep filling this page after it is gone.
$instance = $customer?->instances()
->whereIn('status', ['active', 'provisioning', 'cancellation_scheduled'])
->latest('id')
->first();
// ~30 days of availability, gently trending near 100%.
$availability = [99.95, 99.97, 99.96, 99.98, 99.99, 99.97, 99.98, 99.99, 99.99, 99.98,
99.97, 99.99, 100, 99.98, 99.99, 99.99, 99.97, 99.98, 99.99, 100,
99.98, 99.99, 99.99, 99.98, 100, 99.99, 99.98, 99.99, 100, 99.98];
$contract = $instance?->subscription;
$maintenance = MaintenanceWindow::forInstance($instance)->first();
return view('livewire.dashboard', [
'traffic' => $traffic,
'storageUsed' => $storageUsed,
'storageQuota' => $storageQuota,
'storagePercent' => $storagePercent,
'usersActive' => 8,
'usersQuota' => 20,
'lastBackup' => '03:12',
'availability' => Number::format(99.98, precision: 2, locale: $locale),
'storageChart' => [
'type' => 'doughnut',
'data' => [
'datasets' => [[
'data' => [$storagePercent, 100 - $storagePercent],
'backgroundColor' => ['token:accent', 'token:surface-2'],
'borderWidth' => 0,
]],
],
'options' => [
'cutout' => '76%',
'plugins' => [
'legend' => ['display' => false],
'tooltip' => ['enabled' => false],
],
],
],
'availabilityChart' => [
'type' => 'line',
'data' => [
'labels' => array_fill(0, count($availability), ''),
'datasets' => [[
'data' => $availability,
'borderColor' => 'token:success-bright',
'backgroundColor' => 'token:success-bright/0.12',
'fill' => true,
'tension' => 0.4,
'pointRadius' => 0,
'borderWidth' => 2,
]],
],
'options' => [
'scales' => [
'x' => ['display' => false],
'y' => ['display' => false, 'min' => 99.9, 'max' => 100],
],
'plugins' => [
'legend' => ['display' => false],
'tooltip' => ['enabled' => false],
],
],
],
'cloud' => [
'name' => 'Cloud der Kanzlei Berger',
'domain' => 'cloud.kanzlei-berger.at',
'package' => __('dashboard.cloud.plan_line', ['plan' => 'CluPilot Cloud Team', 'price' => 179]),
'since' => $date('2026-03-14', 'LL'),
'location' => __('dashboard.cloud.datacenter'),
'version' => 'Nextcloud 31.0.4',
],
'onboarding' => [
['label' => __('dashboard.onboarding.instance'), 'done' => true, 'date' => $date('2026-03-14', 'D. MMMM')],
['label' => __('dashboard.onboarding.domain'), 'done' => true, 'date' => $date('2026-03-15', 'D. MMMM')],
['label' => __('dashboard.onboarding.branding'), 'done' => true, 'date' => $date('2026-03-15', 'D. MMMM')],
['label' => __('dashboard.onboarding.users'), 'done' => true, 'date' => $date('2026-03-16', 'D. MMMM')],
['label' => __('dashboard.onboarding.migration'), 'done' => false, 'date' => null],
['label' => __('dashboard.onboarding.training'), 'done' => false, 'date' => null],
['label' => __('dashboard.onboarding.docs'), 'done' => false, 'date' => null],
],
'backups' => [
['when' => __('dashboard.today').', 03:12', 'size' => $gb(18.4)],
['when' => __('dashboard.yesterday').', 03:09', 'size' => $gb(18.4)],
['when' => $date('2026-07-21', 'D. MMMM').', 03:11', 'size' => $gb(18.3)],
],
'modules' => [
['icon' => 'cloud', 'name' => 'Nextcloud + Collabora', 'desc' => __('dashboard.modules.nextcloud'), 'status' => 'active'],
['icon' => 'shield-check', 'name' => 'Vaultwarden', 'desc' => __('dashboard.modules.vaultwarden'), 'status' => 'active'],
['icon' => 'receipt', 'name' => 'Paperless-ngx', 'desc' => __('dashboard.modules.paperless'), 'status' => 'available'],
['icon' => 'pen', 'name' => __('dashboard.modules.signature_name'), 'desc' => __('dashboard.modules.signature'), 'status' => 'soon'],
],
'activity' => [
['icon' => 'download', 'text' => __('dashboard.activity_items.backup_ok'), 'time' => '03:12'],
['icon' => 'shield-check', 'text' => __('dashboard.activity_items.security'), 'time' => '04:01'],
['icon' => 'users', 'text' => __('dashboard.activity_items.upload'), 'time' => '07:56'],
['icon' => 'shield-check', 'text' => __('dashboard.activity_items.cert'), 'time' => __('dashboard.yesterday').' 22:10'],
'customer' => $customer,
'instance' => $instance,
'contract' => $contract,
'domain' => $this->domain($instance),
'location' => $this->location($instance),
'seats' => $this->seats($customer, $contract),
'traffic' => $instance !== null ? TrafficMeter::for($instance) : null,
// The measured series behind the template's ring and trend. Null
// where nothing has been sampled yet — a new instance has no
// fortnight, and drawing one at zero would tell its owner their
// data had vanished.
'disk' => $instance !== null ? $this->disk($instance) : null,
'availability' => $instance !== null ? InstanceMetric::availability($instance) : null,
'availabilityTrend' => $instance !== null ? InstanceMetric::availabilitySeries($instance) : [],
'seatBreakdown' => $this->seatBreakdown($customer),
'trend' => $instance !== null
? InstanceMetric::series($instance)->map(fn (InstanceMetric $m) => $m->rx_bytes + $m->tx_bytes)->all()
: [],
'proofs' => $this->proofs($instance, $maintenance),
'openTasks' => $instance?->onboardingTasks()->where('done', false)->count() ?? 0,
// What the plan costs is true whether or not another invoice is
// coming — a customer who has given notice still pays until the
// term ends and still needs the figure on their master record.
'planPrice' => $contract === null ? null : [
'cents' => (int) $contract->price_cents,
'currency' => (string) ($contract->currency ?: 'EUR'),
'term' => (string) $contract->term,
],
'nextInvoice' => $this->nextInvoice($contract, $instance),
'asOf' => Carbon::now(),
]);
}
/**
* Who holds the seats, so the card can say something true underneath the
* figure rather than repeating it.
*
* @return array<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;
use Illuminate\Support\Carbon;
use Livewire\Attributes\Layout;
use Livewire\Component;
@ -11,15 +10,7 @@ class Support extends Component
{
public function render()
{
$locale = app()->getLocale();
$date = fn (string $iso) => Carbon::parse($iso)->locale($locale)->isoFormat('LL');
return view('livewire.support', [
'tickets' => [
['subject' => __('support.ticket_migration'), 'ref' => '#4821', 'date' => $date('2026-07-22'), 'status' => 'open'],
['subject' => __('support.ticket_training'), 'ref' => '#4790', 'date' => $date('2026-07-18'), 'status' => 'progress'],
['subject' => __('support.ticket_smtp'), 'ref' => '#4712', 'date' => $date('2026-07-04'), 'status' => 'closed'],
],
'faqs' => [
['q' => __('support.faq_q1'), 'a' => __('support.faq_a1')],
['q' => __('support.faq_q2'), 'a' => __('support.faq_a2')],

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
{
$customer = $this->requireCustomer();
@ -193,7 +225,13 @@ class Users extends Component
$customer = $this->customer();
$seats = $customer ? $customer->seats()->orderByRaw("role = 'owner' desc")->orderBy('email')->get() : collect();
// The owner can be neither re-invited nor revoked, so a table holding
// only the owner has nothing to act on and should not carry a column
// of empty cells.
$hasActions = $seats->contains(fn ($seat) => $seat->role !== 'owner');
return view('livewire.users', [
'hasActions' => $hasActions,
'seats' => $seats,
'used' => $customer ? $this->usedSeats($customer) : 0,
'limit' => $customer ? $this->seatLimit($customer) : 0,

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;
use App\Models\Instance;
use App\Models\InstanceMetric;
use App\Models\InstanceTraffic;
use App\Services\Proxmox\ProxmoxClient;
use App\Services\Traffic\TrafficMeter;
@ -101,9 +102,14 @@ class CollectInstanceTraffic implements ShouldBeUnique, ShouldQueue
// A fresh row starts from this sample: whatever the VM transferred
// before we started counting this month is not ours to bill.
$rxDelta = 0;
$txDelta = 0;
if (! $row->wasRecentlyCreated) {
$row->rx_bytes += $this->delta($netin, $row->last_netin);
$row->tx_bytes += $this->delta($netout, $row->last_netout);
$rxDelta = $this->delta($netin, $row->last_netin);
$txDelta = $this->delta($netout, $row->last_netout);
$row->rx_bytes += $rxDelta;
$row->tx_bytes += $txDelta;
$row->last_netin = $netin;
$row->last_netout = $netout;
$row->sampled_at = now();
@ -111,6 +117,79 @@ class CollectInstanceTraffic implements ShouldBeUnique, ShouldQueue
}
$this->enforce($client, $instance, $row);
$this->recordDay($client, $instance, $rxDelta ?? 0, $txDelta ?? 0);
}
/**
* Today's row for the customer panel's chart.
*
* Kept apart from the billing row on purpose: instance_traffic answers "how
* much of the allowance is gone this period", which must not be disturbed
* by anything drawn on a screen. This answers "what did the last fortnight
* look like", and a failure here must never cost someone their allowance —
* hence the try/catch around a job that has already done its real work.
*/
private function recordDay($client, Instance $instance, int $rxDelta, int $txDelta): void
{
try {
$disk = $this->diskUsage($client, $instance);
$metric = InstanceMetric::query()->firstOrCreate(
['instance_id' => $instance->id, 'day' => now()->toDateString()],
);
// Accumulated, because the sampler runs several times a day.
$metric->rx_bytes += max(0, $rxDelta);
$metric->tx_bytes += max(0, $txDelta);
// Overwritten rather than accumulated: a fill level is a state, not
// a flow. And left untouched when the reading failed, so yesterday's
// figure stands instead of the chart dropping to zero.
if ($disk !== null) {
$metric->disk_used_bytes = $disk['used'];
$metric->disk_total_bytes = $disk['total'];
}
$metric->save();
} catch (Throwable $e) {
Log::warning('daily metric failed', [
'instance' => $instance->uuid, 'error' => $e->getMessage(),
]);
}
}
/**
* How full the instance's data disk is, via the guest agent.
*
* Proxmox's own `disk` figure for a VM is the allocated image, not what is
* used inside it it would show a customer 500 GB from the first day.
* `df` in the guest is the number they recognise.
*
* @return array{used: int, total: int}|null
*/
private function diskUsage($client, Instance $instance): ?array
{
$node = $instance->host->node ?? 'pve';
if (! $client->guestAgentPing($node, $instance->vmid)) {
return null;
}
// -B1 so the numbers are bytes and no locale can reinterpret them.
$result = $client->guestExec($node, $instance->vmid, 'df -B1 --output=size,used /var/www 2>/dev/null || df -B1 --output=size,used /');
$out = trim((string) ($result['out-data'] ?? $result['output'] ?? ''));
// Header line, then one line of two integers. Anything else is a shell
// that answered something we did not ask for, and is discarded rather
// than parsed hopefully.
$lines = preg_split('/\r?\n/', $out) ?: [];
$last = trim((string) end($lines));
if (! preg_match('/^(\d+)\s+(\d+)$/', $last, $m)) {
return null;
}
return ['total' => (int) $m[1], 'used' => (int) $m[2]];
}
private function releasePreviousPeriodThrottle($client, Instance $instance): void

View File

@ -2,6 +2,7 @@
namespace App\Provisioning\Jobs;
use App\Models\InstanceMetric;
use App\Models\MonitoringTarget;
use App\Services\Monitoring\MonitoringClient;
use Illuminate\Bus\Queueable;
@ -29,6 +30,30 @@ class SyncMonitoringStatus implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* One tick of the availability figure the customer panel shows.
*
* Written per day so thirty days is thirty rows, and so a gap in our own
* monitoring shows up as a day with fewer checks rather than as downtime
* that never happened.
*/
private function countCheck(MonitoringTarget $target, ?bool $healthy): void
{
if ($healthy === null || $target->instance_id === null) {
return;
}
$metric = InstanceMetric::query()->firstOrCreate(
['instance_id' => $target->instance_id, 'day' => now()->toDateString()],
);
$metric->increment('checks_total');
if ($healthy) {
$metric->increment('checks_ok');
}
}
public function handle(MonitoringClient $monitoring): void
{
MonitoringTarget::query()
@ -60,6 +85,12 @@ class SyncMonitoringStatus implements ShouldQueue
return;
}
// Counted before the early return below, so a day's denominator grows
// only when a real answer arrived. An unanswered check is not a failed
// one — folding the two together turns a monitoring outage into the
// customer's outage, on the one figure they are asked to trust.
$this->countCheck($target, $healthy);
if ($healthy === null) {
// No answer. The previous verdict stays and is allowed to go stale,
// which readers show as "not monitored" rather than as health.

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',
'to_portal' => 'Zum Kundenportal',
'nav_group' => [
'operations' => 'Betrieb',
'system' => 'System',
],
'nav' => [
'overview' => 'Übersicht',
'customers' => 'Kunden',

View File

@ -1,6 +1,18 @@
<?php
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_per_month' => 'netto pro Monat',
'net_once' => 'netto, einmalig',

View File

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

View File

@ -3,12 +3,104 @@
return [
'traffic' => [
'title' => 'Datenvolumen diesen Monat',
'label' => 'Datenvolumen',
'of' => 'von :total',
'resets' => 'setzt sich in :days Tagen zurück',
'remaining' => ':amount übrig',
'almost' => ':percent % verbraucht — es wird knapp.',
'exhausted' => 'Kontingent aufgebraucht. Ihre Nextcloud läuft weiter, aber langsamer, bis Sie nachbuchen oder der Monat wechselt.',
'buy' => 'Volumen nachbuchen',
],
// Das Betriebsblatt: was der Kunde hat, und der Nachweis, dass es betreut
// wird. Jede Zeile kommt aus einem Datensatz — was nicht gemessen wird,
// steht hier auch nicht.
'sheet' => 'Betriebsblatt · Stand :when',
'title_running' => 'Ihre Cloud läuft.',
'title_pending' => 'Ihre Cloud wird eingerichtet.',
'instance_status' => [
'active' => 'Alle Dienste aktiv',
'provisioning' => 'Wird eingerichtet',
'cancellation_scheduled' => 'Kündigung vorgemerkt',
],
'no_instance_label' => 'Noch keine Instanz',
'no_instance_body' => 'Für dieses Konto läuft derzeit keine Cloud. Sobald eine Bestellung bezahlt ist, richten wir sie ein — dieser Bereich füllt sich dann von selbst.',
'no_instance_cta' => 'Paket wählen',
'master' => [
'label' => 'Stammblatt',
'package' => 'Paket',
'operation' => 'Betriebsform',
'last_backup' => 'Letzte Sicherung',
'availability' => 'Verfügbarkeit',
'availability_note' => 'letzte 30 Tage · gemessen',
'location' => 'Serverstandort',
'users' => 'Benutzer',
'address' => 'Adresse',
'storage' => 'Speicher',
'storage_used' => ':percent % belegt',
'storage_week' => ':amount diese Woche',
'storage_note' => 'vertraglich zugesichert',
'location_note' => 'kein Drittlandtransfer',
'gb' => ':n GB',
'of_seats' => 'von :total Plätzen',
'of_total' => ':used von :total',
'per_month' => ':amount netto / Monat',
'per_year' => ':amount netto / Jahr',
],
'proofs' => [
'label' => 'Nachweise',
'all' => 'Alle ansehen',
'empty' => 'Sobald die erste Sicherung gelaufen ist, steht sie hier.',
'when' => 'Zeitpunkt',
'what' => 'Vorgang',
'result' => 'Ergebnis',
'item' => [
'backup' => 'Sicherung',
'certificate' => 'Verschlüsselte Verbindung',
'maintenance' => 'Geplante Wartung',
'service_ends' => 'Vertragsende',
],
'state' => [
'ok' => 'Geprüft',
'planned' => 'Geplant',
'attention' => 'Prüfen',
],
],
'invoice' => [
'label' => 'Nächste Abrechnung',
'plan' => 'Paket',
'addons' => 'Module',
'amount' => 'Betrag',
'net' => 'zzgl. USt.',
'due' => 'Fällig',
'term' => 'Abrechnung',
'all' => 'Rechnungen ansehen',
],
'term' => [
'monthly' => 'monatlich',
'yearly' => 'jährlich',
],
'setup' => [
'label' => 'Einrichtung',
'open' => '{1} Ein Schritt ist noch offen.|[2,*] :count Schritte sind noch offen.',
'continue' => 'Einrichtung fortsetzen',
],
'quick' => [
'add_user_title' => 'Benutzer hinzufügen',
'add_user_body' => 'Zugang für neue Mitarbeiter anlegen',
'restore_title' => 'Datei wiederherstellen',
'restore_body' => 'Aus den vorhandenen Sicherungen',
'grow_title' => 'Paket erweitern',
'grow_body' => 'Speicher, Benutzer oder Datenvolumen',
],
'title' => 'Übersicht',
'subtitle' => 'Status Ihrer Cloud auf einen Blick.',
'greeting' => 'Guten Morgen, :name',
@ -26,6 +118,13 @@ return [
'support' => 'Support',
],
// Zwei Gruppen: was der Kunde betreibt, und was im Vertrag steht.
'nav_group' => [
'contract' => 'Vertrag',
],
'nav_footer' => "Betreut aus Österreich\nAntwort am selben Werktag",
'signed_in_as' => 'Angemeldet als',
'open_nav' => 'Navigation öffnen',
'close_nav' => 'Navigation schließen',
'logout' => 'Abmelden',

View File

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

View File

@ -16,10 +16,27 @@ return [
'role_member' => 'Mitglied',
'role_readonly' => 'Nur Lesen',
// Counted next to the seat figure, so the card says something true
// underneath the number instead of repeating it.
'role_count' => [
'owner' => '{1} :count Inhaber|[2,*] :count Inhaber',
'admin' => '{1} :count Administrator|[2,*] :count Administratoren',
'member' => '{1} :count Mitglied|[2,*] :count Mitglieder',
'readonly' => '{1} :count Nur Lesen|[2,*] :count Nur Lesen',
'guest' => '{1} :count Gastzugang|[2,*] :count Gastzugänge',
],
'col_person' => 'Person',
'col_status' => 'Status',
'owner_no_actions' => 'Der Inhaber kann nicht entfernt werden.',
'col_actions' => 'Aktionen',
'suspend' => 'Sperren',
'reactivate' => 'Entsperren',
'suspended' => 'Zugang gesperrt.',
'reactivated' => 'Zugang wieder freigegeben.',
'owner_locked' => 'Der Inhaber kann sich nicht selbst sperren.',
'status_suspended' => 'Gesperrt',
'status_active' => 'Aktiv',
'status_invited' => 'Eingeladen',
'status_revoked' => 'Entfernt',

View File

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

View File

@ -1,6 +1,18 @@
<?php
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_per_month' => 'net per month',
'net_once' => 'net, one-off',

View File

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

View File

@ -3,12 +3,104 @@
return [
'traffic' => [
'title' => 'Data transfer this month',
'label' => 'Data transfer',
'of' => 'of :total',
'resets' => 'resets in :days days',
'remaining' => ':amount left',
'almost' => ':percent % used — getting tight.',
'exhausted' => 'Allowance used up. Your Nextcloud keeps running, but slower, until you top up or the month rolls over.',
'buy' => 'Add data volume',
],
// The operating sheet: what the customer has, and the evidence it is being
// looked after. Every line comes from a record — anything not measured does
// not appear here at all.
'sheet' => 'Operating sheet · as of :when',
'title_running' => 'Your cloud is running.',
'title_pending' => 'Your cloud is being set up.',
'instance_status' => [
'active' => 'All services running',
'provisioning' => 'Being set up',
'cancellation_scheduled' => 'Cancellation scheduled',
],
'no_instance_label' => 'No instance yet',
'no_instance_body' => 'No cloud is running for this account at the moment. As soon as an order is paid we set one up — this area then fills itself.',
'no_instance_cta' => 'Choose a package',
'master' => [
'label' => 'Master record',
'package' => 'Package',
'operation' => 'Operating mode',
'last_backup' => 'Last backup',
'availability' => 'Availability',
'availability_note' => 'last 30 days · measured',
'location' => 'Server location',
'users' => 'Users',
'address' => 'Address',
'storage' => 'Storage',
'storage_used' => ':percent % used',
'storage_week' => ':amount this week',
'storage_note' => 'contractually guaranteed',
'location_note' => 'no third-country transfer',
'gb' => ':n GB',
'of_seats' => 'of :total seats',
'of_total' => ':used of :total',
'per_month' => ':amount net / month',
'per_year' => ':amount net / year',
],
'proofs' => [
'label' => 'Evidence',
'all' => 'View all',
'empty' => 'Once the first backup has run, it appears here.',
'when' => 'Time',
'what' => 'Event',
'result' => 'Result',
'item' => [
'backup' => 'Backup',
'certificate' => 'Encrypted connection',
'maintenance' => 'Scheduled maintenance',
'service_ends' => 'Contract ends',
],
'state' => [
'ok' => 'Verified',
'planned' => 'Scheduled',
'attention' => 'Check',
],
],
'invoice' => [
'label' => 'Next invoice',
'plan' => 'Package',
'addons' => 'Modules',
'amount' => 'Amount',
'net' => 'plus VAT',
'due' => 'Due',
'term' => 'Billed',
'all' => 'View invoices',
],
'term' => [
'monthly' => 'monthly',
'yearly' => 'yearly',
],
'setup' => [
'label' => 'Setup',
'open' => '{1} One step is still open.|[2,*] :count steps are still open.',
'continue' => 'Continue setup',
],
'quick' => [
'add_user_title' => 'Add a user',
'add_user_body' => 'Create access for a new colleague',
'restore_title' => 'Restore a file',
'restore_body' => 'From the backups on record',
'grow_title' => 'Extend the package',
'grow_body' => 'Storage, users or data allowance',
],
'title' => 'Overview',
'subtitle' => 'Your cloud status at a glance.',
'greeting' => 'Good morning, :name',
@ -26,6 +118,13 @@ return [
'support' => 'Support',
],
// Two groups: what the customer operates, and what the contract says.
'nav_group' => [
'contract' => 'Contract',
],
'nav_footer' => "Operated from Austria\nAnswered the same working day",
'signed_in_as' => 'Signed in as',
'open_nav' => 'Open navigation',
'close_nav' => 'Close navigation',
'logout' => 'Sign out',

View File

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

View File

@ -16,10 +16,27 @@ return [
'role_member' => 'Member',
'role_readonly' => 'Read-only',
// Counted next to the seat figure, so the card says something true
// underneath the number instead of repeating it.
'role_count' => [
'owner' => '{1} :count owner|[2,*] :count owner',
'admin' => '{1} :count administrator|[2,*] :count administrators',
'member' => '{1} :count member|[2,*] :count members',
'readonly' => '{1} :count read-only|[2,*] :count read-only',
'guest' => '{1} :count guest|[2,*] :count guests',
],
'col_person' => 'Person',
'col_status' => 'Status',
'owner_no_actions' => 'The owner cannot be removed.',
'col_actions' => 'Actions',
'suspend' => 'Suspend',
'reactivate' => 'Reactivate',
'suspended' => 'Access suspended.',
'reactivated' => 'Access restored.',
'owner_locked' => 'The owner cannot lock themselves out.',
'status_suspended' => 'Suspended',
'status_active' => 'Active',
'status_invited' => 'Invited',
'status_revoked' => 'Removed',

View File

@ -1,33 +1,40 @@
/* CluPilot admin console "Tactical Terminal": dark graphite, signal-orange,
ops status triad. Scoped to .theme-admin so it overrides the light portal
tokens only inside the admin layout every shared component adapts for free. */
/* CluPilot operator console density, not a second palette.
*
* This file used to hold a complete dark "Tactical Terminal" set: its own
* surfaces, its own orange, its own status triad. That made the console a
* second product. Two colours for "healthy", two oranges, two ideas of what a
* card is and every shared component had to work in both.
*
* The console is now the same light system as everything else. What genuinely
* differs is DENSITY: an operator scans forty rows, a customer reads four. So
* this file changes spacing and type size, and nothing about colour.
*
* Dark mode is a preference, not the operator aesthetic. Light stays the
* canonical expression on every surface.
*/
.theme-admin {
--bg: #0b0e13;
--surface: #12161d;
--surface-2: #1a2029;
--surface-hover: #1c232d;
/* One step down across the board. The scale is the same scale a console
that used different type sizes as well as different spacing stops being
recognisably the same application. */
--text-base: 13.5px;
--text-md: 15px;
--text-2xl: 26px;
--text-3xl: 32px;
--border: #232b37;
--border-strong: #313b4a;
--text-strong: #eef1f6;
--text: #c2cad6;
--text-muted: #8b95a5;
--text-faint: #626d7e;
/* signal orange — brighter for dark; dark text sits on the fill (AA) */
--accent: #ff7a2f;
--accent-hover: #ff8f4d;
--accent-active: #ff7a2f;
--accent-press: #e8630f;
--accent-subtle: rgba(255, 122, 47, .14);
--accent-border: rgba(255, 122, 47, .32);
--accent-ring: rgba(255, 122, 47, .45);
--on-accent: #0b0e13;
--accent-text: #ff9152;
--success: #35d07f; --success-bg: rgba(53, 208, 127, .13); --success-border: rgba(53, 208, 127, .32); --success-bright: #35d07f;
--warning: #e8b931; --warning-bg: rgba(232, 185, 49, .13); --warning-border: rgba(232, 185, 49, .32);
--danger: #ff5247; --danger-bg: rgba(255, 82, 71, .13); --danger-border: rgba(255, 82, 71, .32);
--info: #5b9bff; --info-bg: rgba(91, 155, 255, .13); --info-border: rgba(91, 155, 255, .32);
/* Tighter corners on a denser grid: a 16px radius around a 40px-high row
looks inflated when there are twenty of them. */
--radius-lg: 13px;
--radius-xl: 16px;
}
/* Table rows an operator scans rather than reads: 4044px, against the 4856px
the portal uses. Written here rather than in the views so a future table
inherits it instead of restating it. */
.theme-admin table td {
padding-top: 11px;
padding-bottom: 11px;
}
.theme-admin table th {
padding-bottom: 10px;
}

View File

@ -7,9 +7,8 @@
@import '@fontsource/ibm-plex-mono/400.css';
@import '@fontsource/ibm-plex-mono/500.css';
@import '@fontsource/ibm-plex-mono/600.css';
/* Serif is the display voice — headlines only, matching the marketing site. */
@import '@fontsource/ibm-plex-serif/400.css';
@import '@fontsource/ibm-plex-serif/600.css';
/* No serif: a serif headline made every page read as a document, which is
exactly the impression the redesign removes. One family, used assertively. */
/* Design tokens, then Tailwind v3 layers. */
@import './portal-tokens.css';
@ -53,48 +52,6 @@
}
}
@layer components {
/* Registration marks the corner crosses on a printed form. A pseudo
element rather than markup: they are decoration, and a screen reader
announcing "plus, plus" on every panel would be noise. Tailwind cannot
express ::before content, so this is the one place it lives in CSS. */
.doc-marks {
position: relative;
}
.doc-marks::before,
.doc-marks::after {
content: "+";
position: absolute;
font-family: var(--font-mono);
font-size: 0.78rem;
line-height: 1;
color: var(--border-strong);
pointer-events: none;
}
.doc-marks::before {
top: -6px;
left: -6px;
}
.doc-marks::after {
bottom: -6px;
right: -6px;
}
/* The rule that precedes an eyebrow label, in the accent. Same reasoning:
decorative, and it has to sit on the text baseline of a flex row. */
.doc-eyebrow::before {
content: "";
display: block;
width: 22px;
height: 1px;
background: var(--accent);
flex: none;
}
}
/* iOS zooms the whole page when a field smaller than 16px takes focus, and
* then leaves the page zoomed every tap into a form throws the layout about.
*
@ -124,3 +81,43 @@
transition-duration: 0.001ms !important;
}
}
/* Metric visuals
* The ring and the sparkline from the approved template. They live here
* rather than as utilities because both need keyframes and a per-element
* custom property, and Tailwind expresses neither.
*
* Every var() carries a fallback: an undefined custom property does not make
* a browser skip the declaration, it computes to `unset` which for a
* stroke-dashoffset silently draws a full ring at every value.
*/
.metric-ring { transform: rotate(-90deg); }
.metric-ring .track { fill: none; stroke: var(--surface-hover); stroke-width: 7; }
.metric-ring .value {
fill: none;
stroke: var(--accent);
stroke-width: 7;
stroke-linecap: round;
stroke-dasharray: var(--c, 157);
stroke-dashoffset: var(--c, 157);
animation: ring-sweep 1.1s cubic-bezier(.2, .7, .2, 1) .2s forwards;
}
@keyframes ring-sweep { to { stroke-dashoffset: var(--o, 0); } }
.spark path { fill: none; stroke-width: 1.75; stroke-linecap: round; stroke-linejoin: round; }
.spark .line {
stroke: var(--accent);
stroke-dasharray: 400;
stroke-dashoffset: 400;
animation: spark-draw 1.3s cubic-bezier(.4, .1, .2, 1) .2s forwards;
}
.spark.muted .line { stroke: var(--text-muted); }
.spark .area { fill: var(--accent); stroke: none; opacity: 0; animation: spark-fade .8s ease .9s forwards; }
@keyframes spark-draw { to { stroke-dashoffset: 0; } }
@keyframes spark-fade { to { opacity: .09; } }
@media (prefers-reduced-motion: reduce) {
.metric-ring .value { stroke-dashoffset: var(--o, 0) !important; }
.spark .line { stroke-dashoffset: 0 !important; }
.spark .area { opacity: .09 !important; }
}

View File

@ -1,75 +1,119 @@
/* CluPilot customer-portal design tokens refined enterprise, light, single
orange accent. Framework-neutral CSS custom properties (work in Tailwind v3
or v4). Values calibrated for AA contrast. See design handoff §6. */
/* CluPilot design tokens one set for every surface.
*
* Light, confident, one warm accent. The public site, the customer portal and
* the operator console draw from this file and differ only in density: same
* colours, same radii, same button hierarchy, same status vocabulary. A
* customer who signs in must not feel handed to a different product, and an
* operator switching between the two should not have to relearn anything.
*
* Framework-neutral custom properties (work in Tailwind v3 or v4). Contrast
* ratios are noted where they were the reason for the value.
*/
:root {
/* Surfaces (light, slightly cool) */
--bg: #f6f7f9;
/* Surfaces
The ground is a shade darker than the cards, so a card lifts off the
page without a shadow doing the work. */
--bg: #f6f6f8;
--surface: #ffffff;
--surface-2: #f1f3f5;
--surface-hover: #f8f9fb;
--surface-2: #fafafb;
--surface-hover: #f2f2f5;
/* Lines */
--border: #e4e7ec;
--border-strong: #d0d5dd;
/* Lines carry the structure. Depth is used sparingly and warmly. */
--border: #e9e9ee;
--border-strong: #dcdce4;
/* Text */
--text-strong: #101828;
--text: #344054;
--text-muted: #667085;
--text-faint: #98a2b3;
/* Text
Near-black rather than pure black: on a light ground pure black reads
as a printing error, not as emphasis. */
--text-strong: #17171c; /* headings — 16.4:1 on white */
--text: #43434e; /* body — 9.8:1 */
--text-muted: #6e6e7a; /* secondary copy — 5.0:1, AA for body text */
--text-faint: #b4b4be; /* decoration only: rules, placeholder glyphs */
/* Accent = the single brand / interaction tone: orange */
--accent: #f97316; /* brand tone: decorative fills, borders, tints, focus */
--accent-hover: #ea6a0c;
--accent-active: #c2560a; /* AA-safe fill for white text on orange (~5:1) */
--accent-press: #a8480a; /* darker press/hover for accent-fill buttons */
--accent-subtle: #fff4ec;
--accent-border: #fed7aa;
--accent-ring: rgba(249, 115, 22, .35);
/* Accent
Orange appears in FEW places, but one of them is allowed to be large
a whole closing panel. Frequency low, area high; the reverse reads as
editorial annotation rather than as brand. */
--accent: #f97316; /* rings, bars, chart strokes, the logo mark */
--accent-hover: #d95f0c;
--accent-active: #b8500a; /* solid fills and text — 5.0:1 with white */
--accent-press: #974208;
--accent-subtle: #fff3e9;
--accent-border: #e8cdb2;
--accent-ring: rgba(249, 115, 22, .38);
--on-accent: #ffffff;
/* Accent as TEXT on light surfaces (links, small labels) must be dark enough
for AA #f97316 on white is only ~2.9:1, so text uses --accent-active. */
--accent-text: #c2560a;
/* #f97316 as TEXT on white is only 2.9:1, so anything read uses the
darker tone. This is the single most common way the palette gets
broken, which is why the two have different names. */
--accent-text: #b8500a;
/* The ink plate: the dark surface used where the brand speaks rather than
the product the sign-in panel, the closing block of the public site.
Warm rather than blue-black, so it reads as ink, not as chrome. */
--plate: #17140f;
--plate-2: #211d16;
--plate-rule: #39332a;
--plate-text: #ded7c9;
--plate-muted: #93897a;
/* Ink fields
The dark surfaces: the site's contrast bands, the sign-in panel, the
operator sidebar. Neutral-warm, never pure black. */
--plate: #16151b;
--plate-2: #201f27;
--plate-rule: #33323d;
--plate-text: #c9c7d2;
--plate-muted: #918f9d; /* 4.6:1 on --plate */
/* Status semantics — only in badges/alerts, never a second decorative tone */
--success: #067a48; --success-bg: #ecfdf3; --success-border: #abefc6;
--success-bright: #30b15c; /* dots/rings/charts only (large marks, not small text) */
--warning: #b54708; --warning-bg: #fffaeb; --warning-border: #fedf89;
--danger: #b42318; --danger-bg: #fef3f2; --danger-border: #fecdca;
--info: #175cd3; --info-bg: #eff8ff; --info-border: #b2ddff;
/* Status
Independent of the brand. Amber must keep meaning "this needs you", so
"scheduled" and "informational" are blue next to an orange brand the
two are otherwise indistinguishable. */
--success: #1c7c50; --success-bg: #eaf6f0; --success-border: #c3e3d3;
--success-bright: #30b15c; /* dots, rings and charts — never small text */
--warning: #9a5b0b; --warning-bg: #fdf4e7; --warning-border: #eed9b3;
--danger: #a8302a; --danger-bg: #fbeeed; --danger-border: #eec9c6;
--info: #1e5aa8; --info-bg: #eef4fa; --info-border: #c3d6ea;
/* Typography */
/* Type
One family, used assertively. Mono is for operational data only
storage, dates, addresses, versions, invoice figures where tabular
figures let a column of numbers line up. Never for headings. */
--font-sans: "IBM Plex Sans", ui-sans-serif, system-ui, sans-serif;
--font-mono: "IBM Plex Mono", ui-monospace, monospace;
/* Display voice for headlines, shared with the public site so the two do
not look like two different companies. Never used for body copy. */
--font-serif: "IBM Plex Serif", Georgia, "Times New Roman", serif;
/* Kept for compatibility; the display voice is now the sans at weight 700.
A serif headline made every page read as a document. */
--font-serif: "IBM Plex Sans", ui-sans-serif, system-ui, sans-serif;
--text-xs: 12px; --text-sm: 13px; --text-base: 14px; --text-md: 15px;
--text-lg: 18px; --text-xl: 22px; --text-2xl: 28px; --text-3xl: 34px;
--text-xs: 12px; --text-sm: 13px; --text-base: 14.5px; --text-md: 16px;
--text-lg: 18px; --text-xl: 22px; --text-2xl: 30px; --text-3xl: 40px;
--lh-tight: 1.2; --lh-normal: 1.5;
--tracking-tight: -0.02em;
--tracking-label: 0.06em;
--lh-tight: 1.12; --lh-normal: 1.55;
--tracking-tight: -0.03em;
--tracking-label: 0.07em;
/* Radius / shadow / motion / focus */
--radius-sm: 6px; --radius: 8px; --radius-lg: 12px; --radius-xl: 20px; --radius-pill: 999px;
/* Radius
Scaled to object size. One value everywhere is what makes an interface
read as a design-system specification rather than as a product. */
--radius-sm: 9px; /* chips, small controls */
--radius: 11px; /* buttons, inputs */
--radius-lg: 16px; /* cards */
--radius-xl: 22px; /* full-width panels, product frames */
--radius-pill: 999px;
--shadow-xs: 0 1px 2px rgba(16, 24, 40, .05);
--shadow-sm: 0 1px 3px rgba(16, 24, 40, .10), 0 1px 2px rgba(16, 24, 40, .06);
--shadow-md: 0 4px 8px -2px rgba(16, 24, 40, .10), 0 2px 4px -2px rgba(16, 24, 40, .06);
/* Warm, broad and low a neutral grey drop shadow belongs to a different
design language and reads as cold against this ground. */
--shadow-xs: 0 1px 2px rgba(40, 24, 15, .04);
--shadow-sm: 0 2px 6px rgba(40, 24, 15, .06);
--shadow-md: 0 16px 44px -18px rgba(40, 24, 15, .20);
--dur-fast: 120ms; --dur: 180ms; --dur-slow: 240ms;
--ease: cubic-bezier(.2, .6, .2, 1);
--dur-fast: 120ms; --dur: 180ms; --dur-slow: 260ms;
--ease: cubic-bezier(.2, .7, .2, 1);
--focus-ring: 0 0 0 3px var(--accent-ring);
}
/*
* The small-caps label: the template calls it "the typographic signature,
* carried across all three surfaces". It is written once here so the size
* cannot drift apart between the panel, the console and the site which is
* exactly how it ended up at 11px in one place and 11.5px in another.
*/
.lbl {
font-family: var(--font-mono);
font-size: 11.5px;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--muted);
}

View File

@ -19,66 +19,69 @@
--}}
@verbatim
<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-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}
:root{
--paper:#f7f5f1; --card:#fffefb; --ink:#17140f; --ink-soft:#413a31;
--muted:#7b7367; --rule:#e3ded3; --rule-mid:#cbc3b4;
--accent:#f97316; --accent-text:#b8500a;
}
@media (prefers-color-scheme: dark){
/* 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;}
--bg:#f6f6f8; --card:#fff; --card-2:#fafafb;
--ink:#17171c; --body:#43434e; --muted:#6e6e7a;
--line:#e9e9ee; --line-2:#dcdce4;
--accent:#f97316; --accent-ink:#b8500a; --accent-wash:#fff3e9;
--plate:#16151b;
}
*{margin:0;padding:0;box-sizing:border-box}
html{-webkit-text-size-adjust:100%}
body{
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);
-webkit-font-smoothing:antialiased;
}
main{max-width:520px;width:100%}
.plate{border:1px solid var(--rule-mid);border-radius:3px;background:var(--card);position:relative}
/* Registration marks, as on the rest of the site. */
.plate::before,.plate::after{content:"+";position:absolute;font-family:"Plex Mono",monospace;font-size:.8rem;color:var(--rule-mid);line-height:1}
.plate::before{top:-6px;left:-6px}
.plate::after{bottom:-6px;right:-6px}
.head{
display:flex;justify-content:space-between;align-items:center;gap:14px;
padding:13px 20px;border-bottom:1px solid var(--rule);
font-family:"Plex Mono",monospace;font-size:.7rem;text-transform:uppercase;letter-spacing:.15em;color:var(--muted);
}
.head b{color:var(--accent-text);font-weight:400;display:inline-flex;align-items:center;gap:7px}
.dot{width:6px;height:6px;border-radius:50%;background:var(--accent);animation:pulse 1.8s ease-in-out infinite}
main{max-width:560px;width:100%}
.card{border:1px solid var(--line);border-radius:16px;background:var(--card);
box-shadow:0 16px 50px rgba(40,24,15,.10);overflow:hidden}
.head{display:flex;align-items:center;justify-content:space-between;gap:14px;
padding:16px 24px;border-bottom:1px solid var(--line);background:var(--card-2);
font-size:13px;font-weight:600;color:var(--muted)}
.head b{color:var(--accent-ink);font-family:"Plex Mono",monospace;font-weight:500}
.body{padding:30px 24px 28px}
.brand{display:inline-flex;align-items:center;gap:9px;font-size:17px;font-weight:700;
letter-spacing:-.02em;color:var(--ink)}
.brand i{font-style:normal;color:var(--accent-ink)}
.mark{width:26px;height:26px;border-radius:7px;flex:none;
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}}
@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>
@endverbatim
</head>
<body>
<main>
<div class="plate">
<div class="card">
<div class="head">
<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 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>
<p>{{ __('coming_soon.body') }}</p>
<p class="foot">{{ __('coming_soon.contact') }}

View File

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

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',
];
@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 }}
</div>

View File

@ -24,7 +24,7 @@
'info' => 'bg-info-bg border-info-border text-info',
][$tone];
@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>
{{ $slot }}
</span>

View File

@ -3,13 +3,18 @@
'size' => 'md',
'type' => 'button',
'loading' => false,
// Given an href this renders an anchor instead. A link that looks like a
// button must still BE a link: middle-click, "open in new tab" and the
// status bar all come from the element, not from the styling.
'href' => null,
])
@php
$base = 'inline-flex items-center justify-center gap-2 rounded font-semibold transition select-none disabled:opacity-50 disabled:pointer-events-none';
$base = 'inline-flex items-center justify-center gap-2 rounded-[10px] font-semibold leading-none transition select-none disabled:opacity-50 disabled:pointer-events-none';
$sizes = [
'sm' => 'px-3 py-1.5 text-sm',
'md' => 'px-4 py-2.5 text-sm',
'sm' => 'min-h-8 px-3 text-[13px]',
'md' => 'min-h-10 px-[18px] text-[14px]',
'lg' => 'min-h-[46px] px-[22px] text-[15px]',
];
// Accent buttons use the AA-safe darker fill (#c2560a) so white text passes
@ -19,21 +24,30 @@
'secondary' => 'border border-line-strong bg-surface text-body hover:bg-surface-hover',
'ghost' => 'text-body hover:bg-surface-hover',
'danger' => 'bg-danger text-on-accent hover:opacity-90',
// The document primary: ink on paper, warming to the accent on hover.
// Orange is the accent, not the loudest thing on the page — on a sheet
// full of hairlines a filled orange button is the only thing anyone
// sees, and it is rarely the most important thing on the page.
'ink' => 'bg-ink text-bg hover:bg-accent-text',
];
$classes = $base.' '.($sizes[$size] ?? $sizes['md']).' '.($variants[$variant] ?? $variants['primary']);
@endphp
<button
type="{{ $type }}"
{{ $attributes->merge(['class' => $classes]) }}
@disabled($loading)
@if ($loading) aria-busy="true" @endif
>
@if ($loading)
<svg class="size-4 animate-spin" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<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>
</svg>
@endif
{{ $slot }}
</button>
@if ($href !== null)
<a href="{{ $href }}" {{ $attributes->merge(['class' => $classes]) }}>{{ $slot }}</a>
@else
<button
type="{{ $type }}"
{{ $attributes->merge(['class' => $classes]) }}
@disabled($loading)
@if ($loading) aria-busy="true" @endif
>
@if ($loading)
<svg class="size-4 animate-spin" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<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>
</svg>
@endif
{{ $slot }}
</button>
@endif

View File

@ -1,11 +1,19 @@
@props([
'title' => null,
'padding' => 'p-6',
'padding' => 'p-5',
])
{{--
The unit every panel is built from. Depth is one warm, broad shadow a
neutral grey drop shadow belongs to a different design language and reads
as cold against this ground.
--}}
<div {{ $attributes->merge(['class' => 'rounded-lg border border-line bg-surface shadow-xs']) }}>
@if ($title)
<div class="border-b border-line px-6 py-3">
<h2 class="text-xs font-semibold uppercase tracking-wide text-faint">{{ $title }}</h2>
{{-- A real heading, not a small-caps label. It was set in --text-faint,
which is 2.8:1 on white: a decoration token used for something a
reader has to read. --}}
<div class="border-b border-line px-5 py-4">
<h2 class="text-md font-semibold text-ink">{{ $title }}</h2>
</div>
@endif
<div class="{{ $padding }}">

View File

@ -10,7 +10,7 @@
$errors ??= new \Illuminate\Support\ViewErrorBag;
$id = $attributes->get('id', $name);
$hasError = $errors->has($name);
$field = 'block w-full rounded border bg-surface px-3 py-2 text-sm text-ink placeholder:text-faint transition '
$field = 'block w-full rounded border bg-surface px-3.5 py-2.5 text-sm text-ink placeholder:text-faint transition '
.($hasError ? 'border-danger bg-danger-bg' : 'border-line');
@endphp
<div class="space-y-1.5">
@ -27,7 +27,7 @@
>
@if ($hint && ! $hasError)
<p class="text-xs text-faint">{{ $hint }}</p>
<p class="text-xs text-muted">{{ $hint }}</p>
@endif
@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
// items-center, not baseline: the icon is a block-level svg (Tailwind's
// preflight makes it one) and must sit BESIDE the label, never above it.
$base = 'flex items-center gap-3 rounded border-l-2 px-3 py-2 text-sm font-medium min-h-11';
// R18.
$base = 'relative flex min-h-11 items-center gap-3 rounded px-3 text-sm';
// The label is a flex row of its own so that an icon handed to the default
// slot instead of the icon slot still lands next to the text. Passing it in
// the default slot put a display:block svg into an inline span and pushed
// the label onto a second line — the sidebar entry then stood twice as tall
// as every other one. Layout must not depend on which slot was used.
// The label is a flex row of its own so an icon handed to the default slot
// instead of the icon slot still lands next to the text. Passing it in the
// default slot put a display:block svg into an inline span and pushed the
// label onto a second line — the entry then stood twice as tall as every
// other one. Layout must not depend on which slot was used.
$label = 'flex min-w-0 items-center gap-3';
// Active is a soft pill with a 3px accent tab flush at its left edge — a
// physical "you are here" marker rather than a colour wash. The tab is a
// pseudo-element via before:, so it cannot push the label around.
$state = $active
? 'bg-accent-subtle text-accent-text border-accent'
: 'text-muted border-transparent hover:bg-surface-hover hover:text-body';
? 'bg-surface-hover font-semibold text-ink before:absolute before:left-0 before:top-2.5 before:bottom-2.5 before:w-[3px] before:rounded-r before:bg-accent before:content-[\'\']'
: 'font-medium text-muted hover:bg-surface-hover hover:text-ink';
@endphp
@if ($disabled)
{{-- Section not built yet: render a non-interactive item, never a dead link. --}}
<span aria-disabled="true" {{ $attributes->merge(['class' => $base.' border-transparent text-faint cursor-not-allowed']) }}>
{{-- Section not built yet: a non-interactive item, never a dead link. --}}
<span aria-disabled="true" {{ $attributes->merge(['class' => $base.' cursor-not-allowed font-medium text-faint']) }}>
@isset($icon)<span class="shrink-0">{{ $icon }}</span>@endisset
<span class="{{ $label }}">{{ $slot }}</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
<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-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}
:root{
--paper:#f7f5f1; --card:#fffefb; --ink:#17140f; --ink-soft:#413a31;
--muted:#7b7367; --rule:#e3ded3; --rule-mid:#cbc3b4;
--accent:#f97316; --accent-text:#b8500a;
}
@media (prefers-color-scheme: dark){
/* 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;}
--bg:#f6f6f8; --card:#fff; --card-2:#fafafb;
--ink:#17171c; --body:#43434e; --muted:#6e6e7a;
--line:#e9e9ee; --line-2:#dcdce4;
--accent:#f97316; --accent-ink:#b8500a; --accent-wash:#fff3e9;
--plate:#16151b;
}
*{margin:0;padding:0;box-sizing:border-box}
html{-webkit-text-size-adjust:100%}
body{
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);
-webkit-font-smoothing:antialiased;
}
main{max-width:520px;width:100%}
.plate{border:1px solid var(--rule-mid);border-radius:3px;background:var(--card);position:relative}
/* Registration marks, as on the rest of the site. */
.plate::before,.plate::after{content:"+";position:absolute;font-family:"Plex Mono",monospace;font-size:.8rem;color:var(--rule-mid);line-height:1}
.plate::before{top:-6px;left:-6px}
.plate::after{bottom:-6px;right:-6px}
.head{
display:flex;justify-content:space-between;align-items:center;gap:14px;
padding:13px 20px;border-bottom:1px solid var(--rule);
font-family:"Plex Mono",monospace;font-size:.7rem;text-transform:uppercase;letter-spacing:.15em;color:var(--muted);
}
.head b{color:var(--accent-text);font-weight:400}
.body{padding:28px 20px 26px}
h1{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}
.hint{margin-top:16px;padding-top:14px;border-top:1px solid var(--rule);font-size:.86rem}
.acts{display:flex;flex-wrap:wrap;gap:10px;margin-top:24px}
.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}
.ink{background:var(--ink);color:var(--paper)}
.ink:hover{background:var(--accent-text);color:#fff}
.line{border:1px solid var(--rule-mid);color:var(--ink)}
main{max-width:560px;width:100%}
.card{border:1px solid var(--line);border-radius:16px;background:var(--card);
box-shadow:0 16px 50px rgba(40,24,15,.10);overflow:hidden}
.head{display:flex;align-items:center;justify-content:space-between;gap:14px;
padding:16px 24px;border-bottom:1px solid var(--line);background:var(--card-2);
font-size:13px;font-weight:600;color:var(--muted)}
.head b{color:var(--accent-ink);font-family:"Plex Mono",monospace;font-weight:500}
.body{padding:30px 24px 28px}
.brand{display:inline-flex;align-items:center;gap:9px;font-size:17px;font-weight:700;
letter-spacing:-.02em;color:var(--ink)}
.brand i{font-style:normal;color:var(--accent-ink)}
.mark{width:26px;height:26px;border-radius:7px;flex:none;
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)}
.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>
@endverbatim
</head>
<body>
<main>
<div class="plate">
<div class="card">
<div class="head"><span>{{ __('errors.heading') }}</span><b>{{ $code }}</b></div>
<div class="body">
<span class="brand"><span class="mark" aria-hidden="true"></span>Clu<i>Pilot</i></span>
<h1>{{ $title }}</h1>
@if ($body)
<p>{{ $body }}</p>
@ -108,7 +112,7 @@
</div>
</div>
</div>
<p class="mark">CluPilot Cloud</p>
<p class="mark-b">CluPilot Cloud</p>
</main>
</body>
</html>

View File

@ -1,103 +1,48 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="h-full">
<head>
<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 ?? __('admin.console') }}</title>
@vite(['resources/css/app.css', 'resources/js/app.js'])
@livewireStyles
<x-shell.head :title="$title ?? __('admin.console')" />
</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 }">
<div class="flex min-h-screen lg:h-screen lg:overflow-hidden">
<aside
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"
:class="nav ? 'translate-x-0' : '-translate-x-full'"
>
<div class="flex items-center justify-between px-3 pb-4">
<div 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 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>
{{--
theme-admin no longer swaps the palette. It used to load a complete dark set
its own surfaces, its own orange, its own status triad which made the
console a second product and forced every shared component to work in two
worlds. It now carries density only: smaller type, tighter rows, tighter
corners. Colour comes from one place.
--}}
<body class="theme-admin min-h-full bg-bg text-body antialiased" x-data="{ nav: false }" :class="nav && 'overflow-hidden lg:overflow-auto'">
@php $release = App\Services\Deployment\Release::current(); @endphp
<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">
<header class="flex h-14 shrink-0 items-center justify-between border-b border-line bg-surface px-4">
<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>
<x-shell.topbar :crumbs="array_values(array_filter([__('admin.console'), $title ?? \App\Support\Navigation::currentLabel(console: true)]))">
@auth
<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">
<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="hidden text-sm text-body sm:block">{{ auth()->user()->name }}</span>
<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="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 font-medium text-ink sm:block">{{ auth()->user()->name }}</span>
<x-ui.icon name="chevron-down" class="size-4 text-muted" />
</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') }}">
@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') }}
</button>
</form>
</div>
</div>
@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 }}
</main>
</div>
@ -114,13 +59,12 @@
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 translate-y-12"
role="status" aria-live="polite"
class="fixed bottom-6 left-1/2 z-[80] -translate-x-1/2 rounded-pill border border-line bg-surface-2 px-5 py-3 text-sm font-medium text-ink shadow-md"
class="fixed bottom-6 left-1/2 z-[80] -translate-x-1/2 rounded-pill bg-plate px-5 py-3 text-sm font-semibold text-white shadow-md"
>
<span x-text="msg"></span>
</div>
@livewire('wire-elements-modal')
@livewireScripts
</body>
</html>

View File

@ -1,16 +1,9 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="h-full">
<head>
<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
<x-shell.head :title="$title ?? config('app.name')" />
</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
$maintenanceWindows = collect();
if (auth()->check()) {
@ -21,12 +14,13 @@
}
}
@endphp
@if (auth()->check() && ! ($portalCustomer ?? null))
{{-- No customer behind this login (e.g. an operator account): say so, or
every customer action on these pages looks like a dead button. --}}
<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">
<x-ui.icon name="alert-triangle" class="size-4 shrink-0 text-warning" />
<span class="font-medium">{{ __('dashboard.no_customer_title') }}</span>
<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 text-warning" />
<span class="font-semibold">{{ __('dashboard.no_customer_title') }}</span>
<span class="text-muted">{{ __('dashboard.no_customer_hint') }}</span>
@if (auth()->user()->can('console.view'))
<a href="{{ route('admin.customers') }}" class="font-semibold text-accent-text hover:underline">{{ __('dashboard.no_customer_cta') }}</a>
@ -35,54 +29,20 @@
@endif
<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). --}}
<aside
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"
: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>
<x-shell.nav
prefix="dashboard.nav"
:groups="\App\Support\Navigation::portal()"
/>
<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">
<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>
{{-- 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. --}}
<x-shell.topbar :crumbs="array_values(array_filter([$portalCustomer?->name, $title ?? \App\Support\Navigation::currentLabel()]))">
{{-- 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'))
<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">
<x-ui.icon name="users" class="size-4 shrink-0 text-warning" />
<span class="truncate text-xs font-medium text-ink">{{ __('impersonate.banner', ['name' => auth()->user()->name]) }}</span>
<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 text-warning" />
<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">
@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">
@ -93,28 +53,25 @@
</div>
@endif
<div class="flex-1"></div>
{{-- Notices --}}
@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"
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') }}">
<x-ui.icon name="bell" class="size-5" />
<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>
<x-ui.icon name="bell" />
<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>
<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">
<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">
@foreach ($maintenanceWindows as $mw)
@php $isActive = now()->betweenIncluded($mw->starts_at, $mw->ends_at); @endphp
<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">
<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">
{{ $isActive
? __('maintenance.banner_active', ['end' => $mw->ends_at->isoFormat('LT')])
@ -130,15 +87,15 @@
@auth
<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">
<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="hidden text-sm text-body sm:block">{{ auth()->user()->name }}</span>
<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="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 font-medium text-ink sm:block">{{ auth()->user()->name }}</span>
<x-ui.icon name="chevron-down" class="size-4 text-muted" />
</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') }}">
@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') }}
</button>
@ -146,9 +103,9 @@
</div>
</div>
@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 }}
</main>
</div>
@ -168,7 +125,7 @@
x-transition:leave-end="opacity-0 translate-y-12"
role="status"
aria-live="polite"
class="fixed bottom-6 left-1/2 z-[80] -translate-x-1/2 rounded-pill bg-ink px-5 py-3 text-sm font-medium text-on-accent shadow-md"
class="fixed bottom-6 left-1/2 z-[80] -translate-x-1/2 rounded-pill bg-plate px-5 py-3 text-sm font-semibold text-white shadow-md"
>
<span x-text="msg"></span>
</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)
<div class="flex items-start gap-3">
<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">
<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" />

View File

@ -1,15 +1,15 @@
<div class="space-y-5">
<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>
</div>
<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">
<table class="w-full text-sm">
<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.plan') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('admin.col.mrr') }}</th>
@ -68,7 +68,7 @@
</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>
<div class="mt-3 h-56" wire:ignore><x-ui.chart :config="$plansChart" class="h-56" :label="__('admin.by_plan')" /></div>
</div>

View File

@ -1,18 +1,18 @@
<div class="space-y-5">
<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>
</div>
<div class="grid grid-cols-1 gap-5 lg:grid-cols-[1fr_320px] lg:items-start">
{{-- 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())
<p class="p-8 text-center text-sm text-muted">{{ __('datacenters.empty') }}</p>
@else
<table class="w-full text-sm">
<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.name') }}</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">
<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 }}
@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 class="px-4 py-3 font-mono text-xs text-muted">{{ $dc->hosts_count }}</td>
<td class="px-4 py-3">
@ -58,7 +58,7 @@
</div>
{{-- 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>
<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" />

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">
<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>

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">
<x-ui.icon name="arrow-left" class="size-4" />{{ __('hosts.back') }}
</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>
</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 />
<div class="space-y-1.5">

View File

@ -9,7 +9,7 @@
<x-ui.icon name="arrow-left" class="size-4" />{{ __('hosts.back') }}
</a>
<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>
</div>
<p class="mt-1 font-mono text-xs text-muted">{{ $host->public_ip }} · {{ $host->datacenter }}</p>
@ -41,8 +41,8 @@
@endphp
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3 animate-rise">
{{-- Health --}}
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs">
<p class="text-xs font-semibold uppercase tracking-wide text-faint">{{ __('hosts.detail.health') }}</p>
<div class="rounded-lg border border-line bg-surface p-5 shadow-xs">
<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 }}">
<span class="size-2.5 rounded-pill {{ $hdot }} {{ $health === 'online' ? 'animate-pulse' : '' }}" aria-hidden="true"></span>
{{ __('hosts.health.'.$health) }}
@ -53,8 +53,8 @@
</div>
{{-- Storage capacity --}}
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs">
<p class="text-xs font-semibold uppercase tracking-wide text-faint">{{ __('hosts.detail.storage') }}</p>
<div class="rounded-lg border border-line bg-surface p-5 shadow-xs">
<p class="text-xs font-semibold text-muted">{{ __('hosts.detail.storage') }}</p>
@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>
<div class="mt-2 h-2 overflow-hidden rounded-pill bg-surface-2">
@ -73,8 +73,8 @@
</div>
{{-- Compute --}}
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs">
<p class="text-xs font-semibold uppercase tracking-wide text-faint">{{ __('hosts.detail.compute') }}</p>
<div class="rounded-lg border border-line bg-surface p-5 shadow-xs">
<p class="text-xs font-semibold text-muted">{{ __('hosts.detail.compute') }}</p>
<div class="mt-2 grid grid-cols-2 gap-3">
<div>
<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')],
['detail.instances', (string) $instances->count()],
] 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="mt-1 truncate font-mono text-sm font-semibold text-ink">{{ $value }}</p>
</div>
@ -104,7 +104,7 @@
</div>
{{-- 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>
@if ($instances->isEmpty())
<p class="text-sm text-muted">{{ __('hosts.detail.no_instances') }}</p>
@ -128,7 +128,7 @@
</div>
@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" />
<div class="min-w-0">
<p class="text-sm font-semibold text-danger">{{ __('hosts.error_title') }}</p>
@ -138,7 +138,7 @@
@endif
<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>
@if ($run)
<x-ui.progress-stepper :steps="$steps" />
@ -147,7 +147,7 @@
@endif
</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>
@if ($events->isEmpty())
<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>
<div class="min-w-0">
<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.attempt') }} {{ $event->attempt }}
@if ($event->message) · <span class="break-words">{{ $event->message }}</span> @endif

View File

@ -1,7 +1,7 @@
<div class="space-y-5">
<div class="flex items-start justify-between gap-4 animate-rise">
<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>
</div>
<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="relative min-w-48 flex-1">
<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>
<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>
@ -34,16 +34,16 @@
</div>
@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>
<p class="mt-3 text-sm text-muted">{{ $total === 0 ? __('hosts.empty') : __('hosts.no_matches') }}</p>
</div>
@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">
<table class="w-full text-sm">
<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.datacenter') }}</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>
</div>
@else
<span class="text-xs text-faint">{{ __('hosts.unknown') }}</span>
<span class="text-xs text-muted">{{ __('hosts.unknown') }}</span>
@endif
</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>
</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">
<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="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>
</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">
<table class="w-full text-sm">
<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.customer') }}</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>
</tr>
@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
</tbody>
</table>

View File

@ -1,19 +1,19 @@
<div class="space-y-6">
<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>
</div>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_420px] lg:items-start">
{{-- 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())
<p class="p-8 text-center text-sm text-muted">{{ __('maintenance.empty') }}</p>
@else
<div class="overflow-x-auto">
<table class="w-full text-sm">
<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_when') }}</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-muted whitespace-nowrap">
{{ 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']]) }}
</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>
{{-- 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">
<h2 class="font-semibold text-ink">{{ __('maintenance.new_title') }}</h2>
<p class="mt-0.5 text-xs text-muted">{{ __('maintenance.new_sub') }}</p>
@ -81,14 +81,14 @@
<div>
<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') }}"
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>
<p class="mt-1 text-xs text-faint">{{ __('maintenance.field_public_hint') }}</p>
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-muted">{{ __('maintenance.field_public_hint') }}</p>
</div>
<div>
<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') }}"
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 class="space-y-3">
@ -97,7 +97,7 @@
<div>
<label class="text-sm font-medium text-body" for="{{ $field }}">{{ __('maintenance.'.$key) }}</label>
<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 }}"
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>
@ -109,7 +109,7 @@
{{-- Typing an end time by hand is the fiddliest part of this form;
these set it from the start time. --}}
<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)
<button type="button" wire:click="setDuration({{ $minutes }})"
class="rounded-pill border px-2.5 py-0.5 text-xs font-medium transition
@ -127,7 +127,7 @@
<div>
<div class="flex items-baseline justify-between">
<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>
@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">
@ -139,7 +139,7 @@
@if ($dcHosts->isNotEmpty())
<div class="p-2.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 }}
@if ($dcSelected)
<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 }}"
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="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>
@endforeach
</div>
@ -170,7 +170,7 @@
</div>
<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="primary" type="submit" size="sm" wire:loading.attr="disabled" wire:target="publish">{{ __('maintenance.publish_notify') }}</x-ui.button>
</div>

View File

@ -1,6 +1,6 @@
<div class="space-y-5">
<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
that is not computed is the most expensive kind of decoration. --}}
@if ($noticeCount === 0)
@ -19,8 +19,8 @@
@php $kd = ['[animation-delay:40ms]', '[animation-delay:80ms]', '[animation-delay:120ms]', '[animation-delay:160ms]']; @endphp
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
@foreach ($kpis as $i => $kpi)
<div class="rounded-xl 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>
<div class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise {{ $kd[$i] ?? '' }}">
<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>
@if (($kpi['sub'] ?? null) !== null)
<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">
{{-- 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>
<p class="text-sm text-muted">{{ __('admin.new_instances_sub') }}</p>
@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
<div class="mt-4 h-56" wire:ignore><x-ui.chart :config="$newInstances['config']" class="h-56" :label="__('admin.new_instances')" /></div>
@endif
</div>
{{-- 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>
<p class="text-sm text-muted">{{ __('admin.host_load_sub') }}</p>
<ul class="mt-4 flex-1 space-y-3.5">
@ -58,7 +58,7 @@
</div>
</li>
@empty
<li class="text-sm text-faint">{{ __('admin.no_hosts_yet') }}</li>
<li class="text-sm text-muted">{{ __('admin.no_hosts_yet') }}</li>
@endforelse
</ul>
</div>
@ -66,7 +66,7 @@
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
{{-- 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">
<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>
@ -82,13 +82,13 @@
<x-ui.badge :status="$r['status'] === 'failed' ? 'error' : 'provisioning'">{{ __('admin.run_status.'.$r['status']) }}</x-ui.badge>
</li>
@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
</ul>
</div>
{{-- 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>
<ul class="mt-2 space-y-2">
@forelse ($notices as $a)
@ -97,7 +97,7 @@
<span class="text-body">{{ $a['text'] }}</span>
</li>
@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
</ul>
</div>

View File

@ -7,7 +7,7 @@
<x-ui.icon name="arrow-left" class="size-3.5" />
{{ __('plans.back') }}
</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>
</div>
@ -30,7 +30,7 @@
$live = $windowOpen && $family->sales_enabled;
$scheduled = $version->isPublished() && $version->available_from->greaterThan($now);
@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' : '' }}">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
@ -105,7 +105,7 @@
['plans.f_template', $version->template_vmid ?? '—'],
] as [$label, $value])
<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>
</div>
@endforeach
@ -114,9 +114,9 @@
<div class="mt-4 flex flex-wrap gap-4 border-t border-line pt-3 text-sm">
@foreach ($version->prices->sortBy('term') as $price)
<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="text-xs text-faint">{{ __('plans.net') }}</span>
<span class="text-xs text-muted">{{ __('plans.net') }}</span>
</span>
@endforeach
</div>
@ -132,14 +132,14 @@
@endif
</div>
@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') }}
</p>
@endforelse
</div>
{{-- 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>
<p class="mt-1 text-sm text-muted">{{ __('plans.draft_body') }}</p>
@ -162,7 +162,7 @@
</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">
<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" />
@ -170,13 +170,13 @@
<x-ui.input name="templateVmid" type="number" min="100" max="999999999" :label="__('plans.f_template')" wire:model="templateVmid" />
</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">
<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" />
</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">
@foreach ($featureKeys as $key)
<div wire:key="feat-{{ $key }}">

View File

@ -1,19 +1,19 @@
<div class="space-y-5">
<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>
</div>
<div class="grid grid-cols-1 gap-5 lg:grid-cols-[1fr_320px] lg:items-start">
{{-- 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())
<p class="p-8 text-center text-sm text-muted">{{ __('plans.empty') }}</p>
@else
<div class="overflow-x-auto">
<table class="w-full text-sm">
<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.tier') }}</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">
<td class="px-4 py-3">
<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)
<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']]) }}
@ -38,12 +38,12 @@
@if ($family['live'])
v{{ $family['live']->version }}
@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')]) }}
</span>
@endif
@else
<span class="text-faint">{{ __('plans.no_live_version') }}</span>
<span class="text-muted">{{ __('plans.no_live_version') }}</span>
@endif
</td>
<td class="px-4 py-3">
@ -71,7 +71,7 @@
</div>
{{-- 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>
<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="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>
</div>
<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)
<p class="p-8 text-center text-sm text-muted">{{ __('admin.no_runs') }}</p>
@else
<div class="overflow-x-auto">
<table class="w-full text-sm">
<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.step') }}</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
<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'] }}
<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">
<div class="flex items-baseline gap-1.5">
<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 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-full rounded-pill {{ $r['failed'] ? 'bg-danger' : ($r['stale'] ? 'bg-warning' : 'bg-accent') }} transition-[width] duration-500" style="width: {{ $r['percent'] }}%"></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>
</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') }}
</button>
@else
<span class="text-xs text-faint"></span>
<span class="text-xs text-muted"></span>
@endif
</td>
</tr>
@ -60,9 +60,9 @@
@endif
</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">
<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)
<span class="font-mono text-xs {{ $panel['failed'] ? 'text-danger' : 'text-muted' }}">{{ $panel['percent'] }}%</span>
@endif
@ -72,7 +72,7 @@
<p class="mt-4 text-sm text-muted">{{ __('admin.no_runs') }}</p>
@else
<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) --}}
<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">
@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
<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>
@if ($panel['stale'] && ! $panel['failed'])
@ -106,7 +106,7 @@
</div>
@elseif ($panel['current'])
<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">
<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>
@ -116,7 +116,7 @@
@if ($panel['next'] && ! $panel['failed'])
<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">
<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>

View File

@ -1,14 +1,14 @@
<div class="space-y-5">
<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>
</div>
@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">
@foreach ($kpis as $i => $kpi)
<div class="rounded-xl 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>
<div class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise {{ $rd[$i] ?? '' }}">
<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-1 text-xs text-muted">{{ $kpi['sub'] }}</p>
</div>
@ -16,32 +16,32 @@
</div>
<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>
@forelse ($planCharts as $chart)
{{-- One ring per currency: a doughnut sums its slices, and a
total mixing euros with francs is not an amount. --}}
@if (count($planCharts) > 1)
<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
<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
<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
</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>
<ul class="divide-y divide-line">
@forelse ($payments as $p)
<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="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>
</li>
@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
</ul>
</div>

View File

@ -1,6 +1,6 @@
<div class="mx-auto max-w-3xl space-y-6">
<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>
</div>
@ -12,7 +12,7 @@
{{-- The second gate. Being signed in is not enough to read a payment
key: the realistic threat is an unlocked machine, and a session is
exactly what that hands over. --}}
<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>
<p class="mt-1.5 max-w-xl text-sm text-muted">{{ __('secrets.locked_body') }}</p>
@ -34,7 +34,7 @@
</div>
@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>
<h2 class="font-semibold text-ink">{{ $entry['label'] }}</h2>
@ -99,7 +99,7 @@
@endforeach
@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>
@if (! $check['ok'])

View File

@ -1,6 +1,6 @@
<div class="mx-auto max-w-3xl space-y-6">
<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>
</div>
@ -9,7 +9,7 @@
{{-- Polls itself: an operator watching an update run should not have to
reload the page to find out whether it finished. Fast while
something is happening, slow when nothing is. --}}
<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' }}>
<div class="flex flex-wrap items-start justify-between gap-4">
<div class="min-w-0">
@ -130,7 +130,7 @@
{{-- My account --}}
@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="min-w-0">
<div class="flex items-center gap-2">
@ -144,7 +144,7 @@
<p class="mt-1.5 max-w-xl text-sm text-muted">
{{ $sitePublic ? __('admin_settings.site_public_body') : __('admin_settings.site_hidden_body') }}
</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()]) }}
<span class="{{ $viewerOnVpn ? 'text-success' : '' }}">
{{ $viewerOnVpn ? __('admin_settings.site_via_vpn') : __('admin_settings.site_not_vpn') }}
@ -161,7 +161,7 @@
{{-- Who may reach this console --}}
@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="min-w-0">
<div class="flex items-center gap-2">
@ -175,7 +175,7 @@
<p class="mt-1 max-w-xl text-sm text-muted">
{{ $consoleRestricted ? __('admin_settings.console_locked_body') : __('admin_settings.console_open_body') }}
</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]) }}
</p>
</div>
@ -187,15 +187,15 @@
</div>
<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)
<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="text-xs text-faint">{{ __('admin_settings.console_vpn_note') }}</span>
<span class="text-xs text-muted">{{ __('admin_settings.console_vpn_note') }}</span>
</div>
@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)
<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>
@ -217,7 +217,7 @@
</div>
<x-ui.button type="submit" wire:loading.attr="disabled" wire:target="addConsoleIp">{{ __('admin_settings.console_add') }}</x-ui.button>
</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>
</div>
</div>
@ -226,7 +226,7 @@
{{-- Own password. There was no way to change one at all: an account created
with a generated password kept it until someone opened a shell. --}}
<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>
<h2 class="font-semibold text-ink">{{ __('admin_settings.password_title') }}</h2>
<p class="mt-1 text-sm text-muted">{{ __('admin_settings.password_sub') }}</p>
@ -246,13 +246,13 @@
</div>
</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>
<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="email" wire:model="email" :label="__('admin_settings.email')" type="email" />
</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">
<x-ui.button variant="primary" type="submit" wire:loading.attr="disabled" wire:target="saveAccount">{{ __('admin_settings.save') }}</x-ui.button>
</div>
@ -260,7 +260,7 @@
{{-- Staff & access (Owner-only) --}}
@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>
<h2 class="font-semibold text-ink">{{ __('admin_settings.staff_title') }}</h2>
<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.button>
</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)
<div class="rounded-lg border border-warning-border bg-warning-bg p-4">
@ -307,11 +307,11 @@
@endif
{{-- 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">
<table class="w-full text-sm">
<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.role') }}</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">
<td class="px-4 py-3">
<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 class="text-xs text-muted">{{ $s['email'] }}</p>
</td>
@ -344,7 +344,7 @@
yourself and an empty cell reads as a bug rather
than as a rule. --}}
@if ($s['self'])
<span class="text-xs text-faint"></span>
<span class="text-xs text-muted"></span>
@endif
@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>

View File

@ -3,12 +3,12 @@
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="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>
</div>
@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" />
<div class="text-sm">
<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. --}}
@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>
<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">
{{-- 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">
<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') }}
</span>
</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>
@endif
@elseif (! $peer->host)
<span class="text-sm text-faint">{{ __('vpn.no_owner') }}</span>
<span class="text-sm text-muted">{{ __('vpn.no_owner') }}</span>
@endif
</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">
<span class="text-body">{{ $peer->allowed_ip }}</span>
@if ($peer->endpoint)
<span class="text-faint">{{ $peer->endpoint }}</span>
<span class="text-muted">{{ $peer->endpoint }}</span>
@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">{{ __('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. --}}
@if (! $peer->hasStoredConfig() && $peer->kind === \App\Models\VpnPeer::KIND_STAFF && $peer->user_id === auth()->id())
<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" />
</span>
@endif
@ -193,7 +193,7 @@
<div class="space-y-5">
{{-- New access --}}
@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>
<x-ui.input name="name" wire:model="name" :label="__('vpn.name')" placeholder="Notebook Boban" />
@ -231,18 +231,18 @@
@endif
{{-- 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>
<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>
<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>
<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>
</div>

View File

@ -14,10 +14,10 @@
<div class="w-full max-w-sm animate-rise">
<div class="mb-9 flex items-center gap-2.5 lg:hidden">
<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>
<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>
@if (session('status'))

View File

@ -14,10 +14,10 @@
<div class="w-full max-w-sm animate-rise">
<div class="mb-9 flex items-center gap-2.5 lg:hidden">
<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>
<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>
<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="flex flex-wrap items-center gap-3 animate-rise">
<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>
</div>
<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 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>
<div class="mt-3 h-52" wire:ignore>
<x-ui.chart :config="$sizeChart" class="h-52" :label="__('backups.size_trend')" />
</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">
<table class="w-full text-sm">
<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_size') }}</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
<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>
</div>
{{-- 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>
<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>
</div>
<div class="text-right">
@ -44,7 +44,7 @@
{{-- Cart: "5 purchases pending" told nobody what they had ordered, and
offered no way to change their mind. --}}
@if ($pending->isNotEmpty())
<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">
<x-ui.icon name="receipt" class="size-4 shrink-0 text-info" />
<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">
<div class="min-w-0 flex-1">
<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')]) }}
</p>
</div>
<div class="text-right">
<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') }} ·
{{ $order->isRecurring() ? __('billing.cart.per_month') : __('billing.cart.once') }}
</p>
@ -103,7 +103,7 @@
@if ($recurringGross > 0)
{{-- The number that actually matters every month, kept apart
from a one-off traffic top-up in the same cart. --}}
<p class="pt-1 text-xs text-faint">
<p class="pt-1 text-xs text-muted">
{{ __('billing.cart.recurring_note', ['amount' => $eur($recurringGross)]) }}
</p>
@endif
@ -118,7 +118,7 @@
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
@foreach ($upgrades as $key)
@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">
<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>
@ -133,7 +133,7 @@
@endforeach
</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="text-xs text-faint">
<p class="text-xs text-muted">
{{ $tax->reverseCharge
? __('billing.net_reverse_charge')
: __('billing.net_hint', ['percent' => $tax->percentLabel()]) }}
@ -147,29 +147,82 @@
</div>
@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 --}}
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3 xl:grid-cols-4 animate-rise [animation-delay:180ms]">
{{-- 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">
<x-ui.icon name="database" class="size-5 text-accent-active" />
<p class="font-semibold text-ink">{{ __('billing.storage_title') }}</p>
</div>
<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">
{{ __('billing.storage_cta', ['gb' => $storage['gb']]) }}
</x-ui.button>
</div>
{{-- 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">
<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>
</div>
<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)
<p class="mt-2 font-mono text-xs text-muted">
{{ __('billing.traffic_used', [
@ -186,7 +239,7 @@
{{-- Add-ons --}}
@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">
<x-ui.icon name="box" class="size-5 text-muted" />
<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>
@endif
</p>
<p class="text-xs text-faint">
<p class="text-xs text-muted">
{{ $tax->reverseCharge
? __('billing.net_reverse_charge')
: __('billing.net_hint', ['percent' => $tax->percentLabel()]) }}
@ -221,5 +274,5 @@
@endforeach
</div>
<p class="text-xs text-faint">{{ __('billing.mock_note') }}</p>
<p class="text-xs text-muted">{{ __('billing.mock_note') }}</p>
</div>

View File

@ -1,12 +1,11 @@
<div class="space-y-6" x-data="{ msgs: @js(['soon' => __('cloud.action_toast')]) }">
<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>
</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">
<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">{{ $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>
@ -43,19 +42,17 @@
['cloud.seats', $instance['seats']],
['cloud.performance', $instance['performance']],
['cloud.location', $instance['location']],
['cloud.version', $instance['version'].' · '.__('cloud.current')],
['cloud.runtime', $instance['php']],
['cloud.storage', $storageLabel],
] as [$key, $val])
<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>
</div>
@endforeach
</dl>
</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>
<p class="text-sm text-muted">{{ __('cloud.storage_growth_sub') }}</p>
<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">
<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" />

View File

@ -1,4 +1,4 @@
<div class="rounded-xl bg-surface p-6">
<div class="rounded-lg bg-surface p-6">
@if ($blocked)
<div class="flex items-start gap-3">
<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>
@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">
<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>

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')]) }">
{{-- Header --}}
<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">{{ __('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">
<span class="size-2 rounded-pill bg-success-bright" aria-hidden="true"></span>{{ __('dashboard.system_ok') }}
</span>
<p class="w-full text-sm text-muted">{{ __('dashboard.as_of', ['time' => '08:42']) }}</p>
</div>
@php
use App\Support\Bytes;
use Illuminate\Support\Number;
$locale = app()->getLocale();
$money = fn (int $cents, string $currency) => Number::currency($cents / 100, in: $currency, locale: $locale);
@endphp
<div class="space-y-3.5">
{{-- 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) --}}
<livewire:customer-provisioning />
{{-- Traffic: the one allowance that throttles the service when it runs out,
so it gets a full-width band rather than a tile in the KPI row. --}}
@if ($traffic)
@php
$state = $traffic->state();
$bar = ['ok' => 'bg-accent', 'warn' => 'bg-warning', 'critical' => 'bg-warning', 'exhausted' => 'bg-danger'][$state];
$frame = [
'ok' => 'border-line',
'warn' => 'border-warning-border bg-warning-bg',
'critical' => 'border-warning-border bg-warning-bg',
'exhausted' => 'border-danger-border bg-danger-bg',
][$state];
@endphp
<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>
@if ($instance === null)
{{-- Said plainly rather than papered over with an empty record: a page
of dashes reads as broken, and "we are still setting it up" and
"you have not ordered yet" are different things. --}}
<section class="rounded-lg border border-line bg-surface p-8 shadow-xs animate-rise">
<h2 class="text-lg font-semibold text-ink">{{ __('dashboard.no_instance_label') }}</h2>
<p class="mt-2 max-w-prose text-md text-muted">{{ __('dashboard.no_instance_body') }}</p>
<div class="mt-6 flex flex-wrap gap-2.5">
<x-ui.button variant="ink" :href="route('billing')" wire:navigate>{{ __('dashboard.no_instance_cta') }}</x-ui.button>
<x-ui.button variant="secondary" :href="route('support')" wire:navigate>{{ __('dashboard.nav.support') }}</x-ui.button>
</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">
{{-- R4: width is data, not decoration the only inline style allowed. --}}
<div class="h-full rounded-pill {{ $bar }} transition-all duration-700"
style="width: {{ min(100, $traffic->percent()) }}%"></div>
</div>
<x-ui.metric
:label="__('dashboard.master.users')"
:value="$seats['used']"
:unit="$seats['total'] !== null ? __('dashboard.master.of_seats', ['total' => $seats['total']]) : null"
: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">
<p class="text-xs {{ $state === 'ok' ? 'text-muted' : 'text-body' }}">
@if ($state === 'exhausted')
{{ __('dashboard.traffic.exhausted') }}
@elseif ($state === 'ok')
{{ __('dashboard.traffic.remaining', ['amount' => \App\Support\Bytes::human($traffic->remainingBytes())]) }}
@else
{{ __('dashboard.traffic.almost', ['percent' => $traffic->percent()]) }}
@if ($traffic)
@php $state = $traffic->state(); @endphp
<x-ui.metric
:label="__('dashboard.traffic.label')"
:value="Number::format($traffic->usedBytes / 1024 ** 3, precision: 0, locale: $locale)"
:unit="'GB / '.Bytes::human($traffic->quotaBytes)"
:foot="__('dashboard.traffic.resets', ['days' => $traffic->daysUntilReset()])"
: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
</p>
@if ($state !== 'ok')
{{-- The offer belongs next to the warning: someone reading that
they are nearly out should not have to go looking. --}}
<a href="{{ route('billing') }}" wire:navigate
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">
<x-ui.icon name="plus" class="size-3.5" />{{ __('dashboard.traffic.buy') }}
</a>
@if ($state !== 'ok')
<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">
<x-ui.icon name="plus" class="size-3.5" />{{ __('dashboard.traffic.buy') }}
</a>
@endif
</x-ui.metric>
@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
</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
{{-- 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>

View File

@ -1,15 +1,15 @@
<div class="space-y-6" x-data="{ msgs: @js(['download' => __('invoices.download_toast')]) }">
<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>
</div>
<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">
<table class="w-full text-sm">
<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_date') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('invoices.col_amount') }}</th>
@ -33,12 +33,12 @@
</div>
<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]">
<p class="text-xs font-semibold uppercase tracking-wide text-faint">{{ __('invoices.next_charge') }}</p>
<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 text-muted">{{ __('invoices.next_charge') }}</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>
</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>
<div class="mt-3 h-48" wire:ignore>
<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="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>
</div>
{{-- 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>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<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
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>
<h2 class="font-semibold text-ink">{{ __('admin_settings.password_title') }}</h2>
<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
password confirmation hiding the buttons stops nobody who can post
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>
<div class="flex flex-wrap items-center gap-2">
@ -126,7 +126,7 @@
@endif
</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>
<h2 class="font-semibold text-ink">{{ __('settings.branding_title') }}</h2>
<p class="mt-1 text-sm text-muted">{{ __('settings.branding_sub') }}</p>
@ -162,12 +162,12 @@
@elseif ($logoUrl)
<img src="{{ $logoUrl }}" alt="" class="max-h-16 max-w-16 object-contain" />
@else
<span class="text-xs text-faint">{{ __('settings.brand_default') }}</span>
<span class="text-xs text-muted">{{ __('settings.brand_default') }}</span>
@endif
</div>
<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" />
<p class="text-xs text-faint">{{ __('settings.brand_logo_hint') }}</p>
<p class="text-xs text-muted">{{ __('settings.brand_logo_hint') }}</p>
@if ($logoUrl)
<button type="button" wire:click="removeLogo" class="self-start text-xs font-semibold text-danger hover:underline">{{ __('settings.brand_logo_remove') }}</button>
@endif
@ -177,7 +177,7 @@
</div>
@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
<div class="flex justify-end">
@ -186,7 +186,7 @@
</form>
{{-- 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>
@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="flex flex-wrap items-center gap-3 animate-rise">
<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>
</div>
<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'],
['shield-check', __('support.contact_sla'), __('support.contact_sla_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>
<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>
</div>
</div>
@ -28,29 +28,7 @@
</div>
{{-- Tickets --}}
<div class="overflow-hidden rounded-xl border border-line bg-surface shadow-xs animate-rise [animation-delay:240ms]">
<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="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.faq') }}</h2></div>
<ul class="divide-y divide-line">
@foreach ($faqs as $i => $f)

View File

@ -1,18 +1,18 @@
<div class="space-y-6">
<div class="flex flex-wrap items-end justify-between gap-4 animate-rise">
<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>
</div>
<div class="text-right">
<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-faint">{{ __('users.seats_note') }}</p>
<p class="text-xs text-muted">{{ __('users.seats_note') }}</p>
</div>
</div>
{{-- 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>
<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" />
@ -36,15 +36,17 @@
</form>
{{-- 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">
<table class="w-full text-sm">
<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.role') }}</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>
</thead>
<tbody>
@ -69,19 +71,36 @@
@php $sb = ['active' => 'active', 'invited' => 'provisioning', 'revoked' => 'suspended'][$seat->status] ?? 'info'; @endphp
<x-ui.badge :status="$sb">{{ __('users.status_'.$seat->status) }}</x-ui.badge>
</td>
@if ($hasActions)
<td class="px-4 py-3">
<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')
<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
@if ($seat->role !== 'owner')
<button type="button" wire:click="revoke('{{ $seat->uuid }}')" aria-label="{{ __('users.revoke') }}"
class="grid size-8 place-items-center rounded-md border border-line text-muted hover:border-danger hover:text-danger">
{{-- Pause first, remove second. Somebody
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" />
</button>
@endif
</div>
</td>
@endif
</tr>
@endforeach
</tbody>

View File

@ -8,27 +8,24 @@
<meta name="robots" content="noindex">
<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
<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-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}
:root{
--paper:#f7f5f1; --paper-2:#efebe3; --card:#fffefb;
--ink:#17140f; --ink-soft:#413a31; --muted:#7b7367; --faint:#a39a8c;
--rule:#e3ded3; --rule-mid:#cbc3b4;
--paper:#f6f6f8; --paper-2:#fafafb; --card:#ffffff;
--ink:#17171c; --ink-soft:#43434e; --muted:#6e6e7a; --faint:#a39a8c;
--rule:#e9e9ee; --rule-mid:#dcdce4;
--accent:#f97316; --accent-text:#b8500a;
--ok:#2f7d4f; --ok-bg:#eef7f1;
--warn:#a2600c; --warn-bg:#fdf5e7;
--bad:#a4372a; --bad-bg:#fdf1ef;
--unknown:#6c6459; --unknown-bg:#f1eee8;
--serif:"Plex Serif",Georgia,serif;
--sans:"Plex Sans",-apple-system,BlinkMacSystemFont,sans-serif;
--mono:"Plex Mono",ui-monospace,monospace;
--r:3px;
--r:16px;
}
*{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}
@ -37,9 +34,9 @@
.wrap{max-width:760px;margin:0 auto;padding:0 clamp(20px,5vw,32px)}
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)}
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{
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) {
$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))
->assertOk()
->assertSee('CluPilot');
->assertSee(__('admin.badge'))
->assertSee(__('admin.nav.overview'))
->assertSee(__('dashboard.logout'));
})->with($adminRoutes);

View File

@ -14,3 +14,37 @@ it('shows the dashboard to authenticated users', function () {
->assertSee('CluPilot')
->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.
Livewire\Livewire::actingAs($user)->test(Dashboard::class)
->assertSee(__('dashboard.traffic.title'))
->assertSee(__('dashboard.traffic.label'))
->assertDontSee(__('dashboard.traffic.buy'));
InstanceTraffic::query()->update(['tx_bytes' => (int) (1000 * 1000 ** 3 * 0.9)]);