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

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

View File

@ -7,6 +7,7 @@ use App\Models\Customer;
use App\Models\Order;
use App\Models\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)]);