One design system for every surface
The console was a second product. admin-tokens.css held a complete dark set — its own surfaces, its own orange, its own status triad — so every shared component had to work in two worlds, and the two halves of the application drifted apart in ways nobody could see from inside either one. That file now carries density and nothing else: smaller type, tighter rows, tighter corners. Colour comes from one place. The tokens themselves are rebuilt around what the redesign settled on: - Radii scaled to object size (9/11/16/22) instead of one value everywhere, which is what made the interface read as a design-system specification rather than as a product. - Warm, broad, low shadows. A neutral grey drop shadow belongs to a different design language and reads as cold on this ground. - --accent-press, which several rules already referenced and nothing defined. An undefined var() does not make a browser skip the declaration; it computes to , and for background-color that is transparent — the accent button lost its fill on hover and left white text on white. - No serif. A serif headline is what made every page read as a document, which is the impression the whole redesign exists to remove. --font-serif now points at the sans so nothing breaks while call sites are cleaned up. The two shells are one shell with two configurations, and both finally have a mobile navigation: the sidebar used to simply disappear below 900px, leaving no way to move around the application at all on a phone. The customer dashboard is bound to real records — instance, seats, traffic, backups, contract — instead of the fixtures it shipped with. Where something is not measured, storage consumption, it says what was contractually agreed rather than inventing a figure. This is the sheet a customer forwards to their auditor. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>feat/portal-design
parent
be082b7bf9
commit
6376f5e9fa
|
|
@ -3,12 +3,26 @@
|
||||||
namespace App\Livewire;
|
namespace App\Livewire;
|
||||||
|
|
||||||
use App\Livewire\Concerns\ResolvesCustomer;
|
use App\Livewire\Concerns\ResolvesCustomer;
|
||||||
|
use App\Models\Customer;
|
||||||
|
use App\Models\Datacenter;
|
||||||
|
use App\Models\Instance;
|
||||||
|
use App\Models\MaintenanceWindow;
|
||||||
|
use App\Models\Seat;
|
||||||
|
use App\Models\Subscription;
|
||||||
use App\Services\Traffic\TrafficMeter;
|
use App\Services\Traffic\TrafficMeter;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Number;
|
|
||||||
use Livewire\Attributes\Layout;
|
use Livewire\Attributes\Layout;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The customer's Betriebsblatt: what they have, and the evidence that it is
|
||||||
|
* being looked after.
|
||||||
|
*
|
||||||
|
* Every figure on this page comes from a record. Where something is not
|
||||||
|
* measured yet — storage consumption, restore tests — the page says nothing
|
||||||
|
* rather than showing a plausible number: this is the sheet a customer forwards
|
||||||
|
* to their auditor, and one invented line on it is worse than a short register.
|
||||||
|
*/
|
||||||
#[Layout('layouts.portal-app')]
|
#[Layout('layouts.portal-app')]
|
||||||
class Dashboard extends Component
|
class Dashboard extends Component
|
||||||
{
|
{
|
||||||
|
|
@ -16,118 +30,217 @@ class Dashboard extends Component
|
||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
{
|
{
|
||||||
// Real numbers where they exist: traffic is metered, unlike the
|
$customer = $this->customer();
|
||||||
// fixtures below, and a customer needs to see it before it runs out.
|
|
||||||
$instance = $this->customer()?->instances()->latest('id')->first();
|
|
||||||
$traffic = $instance !== null ? TrafficMeter::for($instance) : null;
|
|
||||||
|
|
||||||
// Locale-aware date / number formatting for the fixture values below.
|
// The instance that is actually in service. A cancelled one still has
|
||||||
$locale = app()->getLocale();
|
// rows, and would otherwise keep filling this page after it is gone.
|
||||||
$date = fn (string $iso, string $fmt) => Carbon::parse($iso)->locale($locale)->isoFormat($fmt);
|
$instance = $customer?->instances()
|
||||||
$gb = fn (float $v) => Number::format($v, precision: 1, locale: $locale).' GB';
|
->whereIn('status', ['active', 'provisioning', 'cancellation_scheduled'])
|
||||||
// Fixture data until the provisioning engine (separate handoff) supplies
|
->latest('id')
|
||||||
// real instances / metrics. Tenant-specific values stand in for DB rows.
|
->first();
|
||||||
$storageUsed = 235;
|
|
||||||
$storageQuota = 500;
|
|
||||||
$storagePercent = (int) round($storageUsed / max(1, $storageQuota) * 100);
|
|
||||||
|
|
||||||
// ~30 days of availability, gently trending near 100%.
|
$contract = $instance?->subscription;
|
||||||
$availability = [99.95, 99.97, 99.96, 99.98, 99.99, 99.97, 99.98, 99.99, 99.99, 99.98,
|
$maintenance = MaintenanceWindow::forInstance($instance)->first();
|
||||||
99.97, 99.99, 100, 99.98, 99.99, 99.99, 99.97, 99.98, 99.99, 100,
|
|
||||||
99.98, 99.99, 99.99, 99.98, 100, 99.99, 99.98, 99.99, 100, 99.98];
|
|
||||||
|
|
||||||
return view('livewire.dashboard', [
|
return view('livewire.dashboard', [
|
||||||
'traffic' => $traffic,
|
'customer' => $customer,
|
||||||
'storageUsed' => $storageUsed,
|
'instance' => $instance,
|
||||||
'storageQuota' => $storageQuota,
|
'contract' => $contract,
|
||||||
'storagePercent' => $storagePercent,
|
'domain' => $this->domain($instance),
|
||||||
'usersActive' => 8,
|
'location' => $this->location($instance),
|
||||||
'usersQuota' => 20,
|
'seats' => $this->seats($customer, $contract),
|
||||||
'lastBackup' => '03:12',
|
'traffic' => $instance !== null ? TrafficMeter::for($instance) : null,
|
||||||
'availability' => Number::format(99.98, precision: 2, locale: $locale),
|
'proofs' => $this->proofs($instance, $maintenance),
|
||||||
|
'openTasks' => $instance?->onboardingTasks()->where('done', false)->count() ?? 0,
|
||||||
'storageChart' => [
|
// What the plan costs is true whether or not another invoice is
|
||||||
'type' => 'doughnut',
|
// coming — a customer who has given notice still pays until the
|
||||||
'data' => [
|
// term ends and still needs the figure on their master record.
|
||||||
'datasets' => [[
|
'planPrice' => $contract === null ? null : [
|
||||||
'data' => [$storagePercent, 100 - $storagePercent],
|
'cents' => (int) $contract->price_cents,
|
||||||
'backgroundColor' => ['token:accent', 'token:surface-2'],
|
'currency' => (string) ($contract->currency ?: 'EUR'),
|
||||||
'borderWidth' => 0,
|
'term' => (string) $contract->term,
|
||||||
]],
|
|
||||||
],
|
|
||||||
'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'],
|
|
||||||
],
|
],
|
||||||
|
'nextInvoice' => $this->nextInvoice($contract, $instance),
|
||||||
|
'asOf' => Carbon::now(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The address the customer actually reaches their cloud at. */
|
||||||
|
private function domain(?Instance $instance): ?string
|
||||||
|
{
|
||||||
|
if ($instance === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $instance->custom_domain
|
||||||
|
?: ($instance->subdomain
|
||||||
|
? $instance->subdomain.'.'.config('provisioning.dns.zone', 'clupilot.com')
|
||||||
|
: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the data physically sits.
|
||||||
|
*
|
||||||
|
* Named from the datacenter register rather than from a translation string,
|
||||||
|
* because this is the line a customer copies into a processing record — it
|
||||||
|
* has to be the place their instance really runs on, not the place we
|
||||||
|
* usually sell.
|
||||||
|
*
|
||||||
|
* @return array{name: string, note: ?string}|null
|
||||||
|
*/
|
||||||
|
private function location(?Instance $instance): ?array
|
||||||
|
{
|
||||||
|
$code = $instance?->host?->datacenter;
|
||||||
|
|
||||||
|
if ($code === null || $code === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$datacenter = Datacenter::query()->where('code', $code)->first();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'name' => $datacenter?->name ?: $code,
|
||||||
|
'note' => $datacenter?->location,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seats in use against seats bought.
|
||||||
|
*
|
||||||
|
* Invited seats count as used: the licence is committed the moment the
|
||||||
|
* invitation goes out, and showing them as free is how a customer finds out
|
||||||
|
* at the worst moment that they cannot add the person in front of them.
|
||||||
|
*
|
||||||
|
* @return array{used: int, total: int|null}
|
||||||
|
*/
|
||||||
|
private function seats(?Customer $customer, ?Subscription $contract): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'used' => $customer === null ? 0 : Seat::query()
|
||||||
|
->where('customer_id', $customer->id)
|
||||||
|
->whereIn('status', ['active', 'invited'])
|
||||||
|
->count(),
|
||||||
|
'total' => $contract?->seats,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The proof register — what was done for this customer, and when.
|
||||||
|
*
|
||||||
|
* Only entries backed by a record. A row here is something the customer can
|
||||||
|
* be asked to evidence, so "we back up nightly" is not one; "the last backup
|
||||||
|
* completed at 03:12 and reported ok" is.
|
||||||
|
*
|
||||||
|
* @return array<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,
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,11 @@ return [
|
||||||
'badge' => 'Admin',
|
'badge' => 'Admin',
|
||||||
'to_portal' => 'Zum Kundenportal',
|
'to_portal' => 'Zum Kundenportal',
|
||||||
|
|
||||||
|
'nav_group' => [
|
||||||
|
'operations' => 'Betrieb',
|
||||||
|
'system' => 'System',
|
||||||
|
],
|
||||||
|
|
||||||
'nav' => [
|
'nav' => [
|
||||||
'overview' => 'Übersicht',
|
'overview' => 'Übersicht',
|
||||||
'customers' => 'Kunden',
|
'customers' => 'Kunden',
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ return [
|
||||||
'plan_line' => ':plan · :price €/Monat',
|
'plan_line' => ':plan · :price €/Monat',
|
||||||
'datacenter' => 'EU · Rechenzentrum Falkenstein',
|
'datacenter' => 'EU · Rechenzentrum Falkenstein',
|
||||||
'now' => 'jetzt',
|
'now' => 'jetzt',
|
||||||
|
'instance_label' => 'Instanz',
|
||||||
'open' => 'Cloud öffnen',
|
'open' => 'Cloud öffnen',
|
||||||
'status_active' => 'Aktiv',
|
'status_active' => 'Aktiv',
|
||||||
'status_provisioning' => 'Bereitstellung',
|
'status_provisioning' => 'Bereitstellung',
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,96 @@
|
||||||
return [
|
return [
|
||||||
'traffic' => [
|
'traffic' => [
|
||||||
'title' => 'Datenvolumen diesen Monat',
|
'title' => 'Datenvolumen diesen Monat',
|
||||||
|
'of' => 'von :total',
|
||||||
'resets' => 'setzt sich in :days Tagen zurück',
|
'resets' => 'setzt sich in :days Tagen zurück',
|
||||||
'remaining' => ':amount übrig',
|
'remaining' => ':amount übrig',
|
||||||
'almost' => ':percent % verbraucht — es wird knapp.',
|
'almost' => ':percent % verbraucht — es wird knapp.',
|
||||||
'exhausted' => 'Kontingent aufgebraucht. Ihre Nextcloud läuft weiter, aber langsamer, bis Sie nachbuchen oder der Monat wechselt.',
|
'exhausted' => 'Kontingent aufgebraucht. Ihre Nextcloud läuft weiter, aber langsamer, bis Sie nachbuchen oder der Monat wechselt.',
|
||||||
'buy' => 'Volumen nachbuchen',
|
'buy' => 'Volumen nachbuchen',
|
||||||
],
|
],
|
||||||
|
// Das Betriebsblatt: was der Kunde hat, und der Nachweis, dass es betreut
|
||||||
|
// wird. Jede Zeile kommt aus einem Datensatz — was nicht gemessen wird,
|
||||||
|
// steht hier auch nicht.
|
||||||
|
'sheet' => 'Betriebsblatt · Stand :when',
|
||||||
|
'title_running' => 'Ihre Cloud läuft.',
|
||||||
|
'title_pending' => 'Ihre Cloud wird eingerichtet.',
|
||||||
|
|
||||||
|
'instance_status' => [
|
||||||
|
'active' => 'Alle Dienste aktiv',
|
||||||
|
'provisioning' => 'Wird eingerichtet',
|
||||||
|
'cancellation_scheduled' => 'Kündigung vorgemerkt',
|
||||||
|
],
|
||||||
|
|
||||||
|
'no_instance_label' => 'Noch keine Instanz',
|
||||||
|
'no_instance_body' => 'Für dieses Konto läuft derzeit keine Cloud. Sobald eine Bestellung bezahlt ist, richten wir sie ein — dieser Bereich füllt sich dann von selbst.',
|
||||||
|
'no_instance_cta' => 'Paket wählen',
|
||||||
|
|
||||||
|
'master' => [
|
||||||
|
'label' => 'Stammblatt',
|
||||||
|
'package' => 'Paket',
|
||||||
|
'operation' => 'Betriebsform',
|
||||||
|
'location' => 'Serverstandort',
|
||||||
|
'users' => 'Benutzer',
|
||||||
|
'address' => 'Adresse',
|
||||||
|
'storage' => 'Speicher',
|
||||||
|
'storage_note' => 'vertraglich zugesichert',
|
||||||
|
'gb' => ':n GB',
|
||||||
|
'of_total' => ':used von :total',
|
||||||
|
'per_month' => ':amount netto / Monat',
|
||||||
|
'per_year' => ':amount netto / Jahr',
|
||||||
|
],
|
||||||
|
|
||||||
|
'proofs' => [
|
||||||
|
'label' => 'Nachweise',
|
||||||
|
'all' => 'Alle ansehen',
|
||||||
|
'empty' => 'Sobald die erste Sicherung gelaufen ist, steht sie hier.',
|
||||||
|
'when' => 'Zeitpunkt',
|
||||||
|
'what' => 'Vorgang',
|
||||||
|
'result' => 'Ergebnis',
|
||||||
|
'item' => [
|
||||||
|
'backup' => 'Sicherung',
|
||||||
|
'certificate' => 'Verschlüsselte Verbindung',
|
||||||
|
'maintenance' => 'Geplante Wartung',
|
||||||
|
'service_ends' => 'Vertragsende',
|
||||||
|
],
|
||||||
|
'state' => [
|
||||||
|
'ok' => 'Geprüft',
|
||||||
|
'planned' => 'Geplant',
|
||||||
|
'attention' => 'Prüfen',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
'invoice' => [
|
||||||
|
'label' => 'Nächste Abrechnung',
|
||||||
|
'plan' => 'Paket',
|
||||||
|
'addons' => 'Module',
|
||||||
|
'amount' => 'Betrag',
|
||||||
|
'net' => 'zzgl. USt.',
|
||||||
|
'due' => 'Fällig',
|
||||||
|
'term' => 'Abrechnung',
|
||||||
|
'all' => 'Rechnungen ansehen',
|
||||||
|
],
|
||||||
|
|
||||||
|
'term' => [
|
||||||
|
'monthly' => 'monatlich',
|
||||||
|
'yearly' => 'jährlich',
|
||||||
|
],
|
||||||
|
|
||||||
|
'setup' => [
|
||||||
|
'label' => 'Einrichtung',
|
||||||
|
'open' => '{1} Ein Schritt ist noch offen.|[2,*] :count Schritte sind noch offen.',
|
||||||
|
'continue' => 'Einrichtung fortsetzen',
|
||||||
|
],
|
||||||
|
|
||||||
|
'quick' => [
|
||||||
|
'add_user_title' => 'Benutzer hinzufügen',
|
||||||
|
'add_user_body' => 'Zugang für neue Mitarbeiter anlegen',
|
||||||
|
'restore_title' => 'Datei wiederherstellen',
|
||||||
|
'restore_body' => 'Aus den vorhandenen Sicherungen',
|
||||||
|
'grow_title' => 'Paket erweitern',
|
||||||
|
'grow_body' => 'Speicher, Benutzer oder Datenvolumen',
|
||||||
|
],
|
||||||
|
|
||||||
'title' => 'Übersicht',
|
'title' => 'Übersicht',
|
||||||
'subtitle' => 'Status Ihrer Cloud auf einen Blick.',
|
'subtitle' => 'Status Ihrer Cloud auf einen Blick.',
|
||||||
'greeting' => 'Guten Morgen, :name',
|
'greeting' => 'Guten Morgen, :name',
|
||||||
|
|
@ -26,6 +110,13 @@ return [
|
||||||
'support' => 'Support',
|
'support' => 'Support',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
// Zwei Gruppen: was der Kunde betreibt, und was im Vertrag steht.
|
||||||
|
'nav_group' => [
|
||||||
|
'contract' => 'Vertrag',
|
||||||
|
],
|
||||||
|
'nav_footer' => "Betreut aus Österreich\nAntwort am selben Werktag",
|
||||||
|
'signed_in_as' => 'Angemeldet als',
|
||||||
|
|
||||||
'open_nav' => 'Navigation öffnen',
|
'open_nav' => 'Navigation öffnen',
|
||||||
'close_nav' => 'Navigation schließen',
|
'close_nav' => 'Navigation schließen',
|
||||||
'logout' => 'Abmelden',
|
'logout' => 'Abmelden',
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,11 @@ return [
|
||||||
'badge' => 'Admin',
|
'badge' => 'Admin',
|
||||||
'to_portal' => 'To customer portal',
|
'to_portal' => 'To customer portal',
|
||||||
|
|
||||||
|
'nav_group' => [
|
||||||
|
'operations' => 'Operations',
|
||||||
|
'system' => 'System',
|
||||||
|
],
|
||||||
|
|
||||||
'nav' => [
|
'nav' => [
|
||||||
'overview' => 'Overview',
|
'overview' => 'Overview',
|
||||||
'customers' => 'Customers',
|
'customers' => 'Customers',
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ return [
|
||||||
'plan_line' => ':plan · €:price/mo',
|
'plan_line' => ':plan · €:price/mo',
|
||||||
'datacenter' => 'EU · Falkenstein data center',
|
'datacenter' => 'EU · Falkenstein data center',
|
||||||
'now' => 'now',
|
'now' => 'now',
|
||||||
|
'instance_label' => 'Instance',
|
||||||
'open' => 'Open cloud',
|
'open' => 'Open cloud',
|
||||||
'status_active' => 'Active',
|
'status_active' => 'Active',
|
||||||
'status_provisioning' => 'Provisioning',
|
'status_provisioning' => 'Provisioning',
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,96 @@
|
||||||
return [
|
return [
|
||||||
'traffic' => [
|
'traffic' => [
|
||||||
'title' => 'Data transfer this month',
|
'title' => 'Data transfer this month',
|
||||||
|
'of' => 'of :total',
|
||||||
'resets' => 'resets in :days days',
|
'resets' => 'resets in :days days',
|
||||||
'remaining' => ':amount left',
|
'remaining' => ':amount left',
|
||||||
'almost' => ':percent % used — getting tight.',
|
'almost' => ':percent % used — getting tight.',
|
||||||
'exhausted' => 'Allowance used up. Your Nextcloud keeps running, but slower, until you top up or the month rolls over.',
|
'exhausted' => 'Allowance used up. Your Nextcloud keeps running, but slower, until you top up or the month rolls over.',
|
||||||
'buy' => 'Add data volume',
|
'buy' => 'Add data volume',
|
||||||
],
|
],
|
||||||
|
// The operating sheet: what the customer has, and the evidence it is being
|
||||||
|
// looked after. Every line comes from a record — anything not measured does
|
||||||
|
// not appear here at all.
|
||||||
|
'sheet' => 'Operating sheet · as of :when',
|
||||||
|
'title_running' => 'Your cloud is running.',
|
||||||
|
'title_pending' => 'Your cloud is being set up.',
|
||||||
|
|
||||||
|
'instance_status' => [
|
||||||
|
'active' => 'All services running',
|
||||||
|
'provisioning' => 'Being set up',
|
||||||
|
'cancellation_scheduled' => 'Cancellation scheduled',
|
||||||
|
],
|
||||||
|
|
||||||
|
'no_instance_label' => 'No instance yet',
|
||||||
|
'no_instance_body' => 'No cloud is running for this account at the moment. As soon as an order is paid we set one up — this area then fills itself.',
|
||||||
|
'no_instance_cta' => 'Choose a package',
|
||||||
|
|
||||||
|
'master' => [
|
||||||
|
'label' => 'Master record',
|
||||||
|
'package' => 'Package',
|
||||||
|
'operation' => 'Operating mode',
|
||||||
|
'location' => 'Server location',
|
||||||
|
'users' => 'Users',
|
||||||
|
'address' => 'Address',
|
||||||
|
'storage' => 'Storage',
|
||||||
|
'storage_note' => 'contractually guaranteed',
|
||||||
|
'gb' => ':n GB',
|
||||||
|
'of_total' => ':used of :total',
|
||||||
|
'per_month' => ':amount net / month',
|
||||||
|
'per_year' => ':amount net / year',
|
||||||
|
],
|
||||||
|
|
||||||
|
'proofs' => [
|
||||||
|
'label' => 'Evidence',
|
||||||
|
'all' => 'View all',
|
||||||
|
'empty' => 'Once the first backup has run, it appears here.',
|
||||||
|
'when' => 'Time',
|
||||||
|
'what' => 'Event',
|
||||||
|
'result' => 'Result',
|
||||||
|
'item' => [
|
||||||
|
'backup' => 'Backup',
|
||||||
|
'certificate' => 'Encrypted connection',
|
||||||
|
'maintenance' => 'Scheduled maintenance',
|
||||||
|
'service_ends' => 'Contract ends',
|
||||||
|
],
|
||||||
|
'state' => [
|
||||||
|
'ok' => 'Verified',
|
||||||
|
'planned' => 'Scheduled',
|
||||||
|
'attention' => 'Check',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
'invoice' => [
|
||||||
|
'label' => 'Next invoice',
|
||||||
|
'plan' => 'Package',
|
||||||
|
'addons' => 'Modules',
|
||||||
|
'amount' => 'Amount',
|
||||||
|
'net' => 'plus VAT',
|
||||||
|
'due' => 'Due',
|
||||||
|
'term' => 'Billed',
|
||||||
|
'all' => 'View invoices',
|
||||||
|
],
|
||||||
|
|
||||||
|
'term' => [
|
||||||
|
'monthly' => 'monthly',
|
||||||
|
'yearly' => 'yearly',
|
||||||
|
],
|
||||||
|
|
||||||
|
'setup' => [
|
||||||
|
'label' => 'Setup',
|
||||||
|
'open' => '{1} One step is still open.|[2,*] :count steps are still open.',
|
||||||
|
'continue' => 'Continue setup',
|
||||||
|
],
|
||||||
|
|
||||||
|
'quick' => [
|
||||||
|
'add_user_title' => 'Add a user',
|
||||||
|
'add_user_body' => 'Create access for a new colleague',
|
||||||
|
'restore_title' => 'Restore a file',
|
||||||
|
'restore_body' => 'From the backups on record',
|
||||||
|
'grow_title' => 'Extend the package',
|
||||||
|
'grow_body' => 'Storage, users or data allowance',
|
||||||
|
],
|
||||||
|
|
||||||
'title' => 'Overview',
|
'title' => 'Overview',
|
||||||
'subtitle' => 'Your cloud status at a glance.',
|
'subtitle' => 'Your cloud status at a glance.',
|
||||||
'greeting' => 'Good morning, :name',
|
'greeting' => 'Good morning, :name',
|
||||||
|
|
@ -26,6 +110,13 @@ return [
|
||||||
'support' => 'Support',
|
'support' => 'Support',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
// Two groups: what the customer operates, and what the contract says.
|
||||||
|
'nav_group' => [
|
||||||
|
'contract' => 'Contract',
|
||||||
|
],
|
||||||
|
'nav_footer' => "Operated from Austria\nAnswered the same working day",
|
||||||
|
'signed_in_as' => 'Signed in as',
|
||||||
|
|
||||||
'open_nav' => 'Open navigation',
|
'open_nav' => 'Open navigation',
|
||||||
'close_nav' => 'Close navigation',
|
'close_nav' => 'Close navigation',
|
||||||
'logout' => 'Sign out',
|
'logout' => 'Sign out',
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,40 @@
|
||||||
/* CluPilot admin console — "Tactical Terminal": dark graphite, signal-orange,
|
/* CluPilot operator console — density, not a second palette.
|
||||||
ops status triad. Scoped to .theme-admin so it overrides the light portal
|
*
|
||||||
tokens only inside the admin layout — every shared component adapts for free. */
|
* This file used to hold a complete dark "Tactical Terminal" set: its own
|
||||||
|
* surfaces, its own orange, its own status triad. That made the console a
|
||||||
|
* second product. Two colours for "healthy", two oranges, two ideas of what a
|
||||||
|
* card is — and every shared component had to work in both.
|
||||||
|
*
|
||||||
|
* The console is now the same light system as everything else. What genuinely
|
||||||
|
* differs is DENSITY: an operator scans forty rows, a customer reads four. So
|
||||||
|
* this file changes spacing and type size, and nothing about colour.
|
||||||
|
*
|
||||||
|
* Dark mode is a preference, not the operator aesthetic. Light stays the
|
||||||
|
* canonical expression on every surface.
|
||||||
|
*/
|
||||||
.theme-admin {
|
.theme-admin {
|
||||||
--bg: #0b0e13;
|
/* One step down across the board. The scale is the same scale — a console
|
||||||
--surface: #12161d;
|
that used different type sizes as well as different spacing stops being
|
||||||
--surface-2: #1a2029;
|
recognisably the same application. */
|
||||||
--surface-hover: #1c232d;
|
--text-base: 13.5px;
|
||||||
|
--text-md: 15px;
|
||||||
|
--text-2xl: 26px;
|
||||||
|
--text-3xl: 32px;
|
||||||
|
|
||||||
--border: #232b37;
|
/* Tighter corners on a denser grid: a 16px radius around a 40px-high row
|
||||||
--border-strong: #313b4a;
|
looks inflated when there are twenty of them. */
|
||||||
|
--radius-lg: 13px;
|
||||||
--text-strong: #eef1f6;
|
--radius-xl: 16px;
|
||||||
--text: #c2cad6;
|
}
|
||||||
--text-muted: #8b95a5;
|
|
||||||
--text-faint: #626d7e;
|
/* Table rows an operator scans rather than reads: 40–44px, against the 48–56px
|
||||||
|
the portal uses. Written here rather than in the views so a future table
|
||||||
/* signal orange — brighter for dark; dark text sits on the fill (AA) */
|
inherits it instead of restating it. */
|
||||||
--accent: #ff7a2f;
|
.theme-admin table td {
|
||||||
--accent-hover: #ff8f4d;
|
padding-top: 11px;
|
||||||
--accent-active: #ff7a2f;
|
padding-bottom: 11px;
|
||||||
--accent-press: #e8630f;
|
}
|
||||||
--accent-subtle: rgba(255, 122, 47, .14);
|
|
||||||
--accent-border: rgba(255, 122, 47, .32);
|
.theme-admin table th {
|
||||||
--accent-ring: rgba(255, 122, 47, .45);
|
padding-bottom: 10px;
|
||||||
--on-accent: #0b0e13;
|
|
||||||
--accent-text: #ff9152;
|
|
||||||
|
|
||||||
--success: #35d07f; --success-bg: rgba(53, 208, 127, .13); --success-border: rgba(53, 208, 127, .32); --success-bright: #35d07f;
|
|
||||||
--warning: #e8b931; --warning-bg: rgba(232, 185, 49, .13); --warning-border: rgba(232, 185, 49, .32);
|
|
||||||
--danger: #ff5247; --danger-bg: rgba(255, 82, 71, .13); --danger-border: rgba(255, 82, 71, .32);
|
|
||||||
--info: #5b9bff; --info-bg: rgba(91, 155, 255, .13); --info-border: rgba(91, 155, 255, .32);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,8 @@
|
||||||
@import '@fontsource/ibm-plex-mono/400.css';
|
@import '@fontsource/ibm-plex-mono/400.css';
|
||||||
@import '@fontsource/ibm-plex-mono/500.css';
|
@import '@fontsource/ibm-plex-mono/500.css';
|
||||||
@import '@fontsource/ibm-plex-mono/600.css';
|
@import '@fontsource/ibm-plex-mono/600.css';
|
||||||
/* Serif is the display voice — headlines only, matching the marketing site. */
|
/* No serif: a serif headline made every page read as a document, which is
|
||||||
@import '@fontsource/ibm-plex-serif/400.css';
|
exactly the impression the redesign removes. One family, used assertively. */
|
||||||
@import '@fontsource/ibm-plex-serif/600.css';
|
|
||||||
|
|
||||||
/* Design tokens, then Tailwind v3 layers. */
|
/* Design tokens, then Tailwind v3 layers. */
|
||||||
@import './portal-tokens.css';
|
@import './portal-tokens.css';
|
||||||
|
|
@ -53,48 +52,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer components {
|
|
||||||
/* Registration marks — the corner crosses on a printed form. A pseudo
|
|
||||||
element rather than markup: they are decoration, and a screen reader
|
|
||||||
announcing "plus, plus" on every panel would be noise. Tailwind cannot
|
|
||||||
express ::before content, so this is the one place it lives in CSS. */
|
|
||||||
.doc-marks {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.doc-marks::before,
|
|
||||||
.doc-marks::after {
|
|
||||||
content: "+";
|
|
||||||
position: absolute;
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 0.78rem;
|
|
||||||
line-height: 1;
|
|
||||||
color: var(--border-strong);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.doc-marks::before {
|
|
||||||
top: -6px;
|
|
||||||
left: -6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.doc-marks::after {
|
|
||||||
bottom: -6px;
|
|
||||||
right: -6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The rule that precedes an eyebrow label, in the accent. Same reasoning:
|
|
||||||
decorative, and it has to sit on the text baseline of a flex row. */
|
|
||||||
.doc-eyebrow::before {
|
|
||||||
content: "";
|
|
||||||
display: block;
|
|
||||||
width: 22px;
|
|
||||||
height: 1px;
|
|
||||||
background: var(--accent);
|
|
||||||
flex: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* iOS zooms the whole page when a field smaller than 16px takes focus, and
|
/* iOS zooms the whole page when a field smaller than 16px takes focus, and
|
||||||
* then leaves the page zoomed — every tap into a form throws the layout about.
|
* then leaves the page zoomed — every tap into a form throws the layout about.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -1,75 +1,105 @@
|
||||||
/* CluPilot customer-portal design tokens — refined enterprise, light, single
|
/* CluPilot design tokens — one set for every surface.
|
||||||
orange accent. Framework-neutral CSS custom properties (work in Tailwind v3
|
*
|
||||||
or v4). Values calibrated for AA contrast. See design handoff §6. */
|
* Light, confident, one warm accent. The public site, the customer portal and
|
||||||
|
* the operator console draw from this file and differ only in density: same
|
||||||
|
* colours, same radii, same button hierarchy, same status vocabulary. A
|
||||||
|
* customer who signs in must not feel handed to a different product, and an
|
||||||
|
* operator switching between the two should not have to relearn anything.
|
||||||
|
*
|
||||||
|
* Framework-neutral custom properties (work in Tailwind v3 or v4). Contrast
|
||||||
|
* ratios are noted where they were the reason for the value.
|
||||||
|
*/
|
||||||
:root {
|
:root {
|
||||||
/* Surfaces (light, slightly cool) */
|
/* ── Surfaces ─────────────────────────────────────────────────────────
|
||||||
--bg: #f6f7f9;
|
The ground is a shade darker than the cards, so a card lifts off the
|
||||||
|
page without a shadow doing the work. */
|
||||||
|
--bg: #f6f6f8;
|
||||||
--surface: #ffffff;
|
--surface: #ffffff;
|
||||||
--surface-2: #f1f3f5;
|
--surface-2: #fafafb;
|
||||||
--surface-hover: #f8f9fb;
|
--surface-hover: #f2f2f5;
|
||||||
|
|
||||||
/* Lines */
|
/* Lines carry the structure. Depth is used sparingly and warmly. */
|
||||||
--border: #e4e7ec;
|
--border: #e9e9ee;
|
||||||
--border-strong: #d0d5dd;
|
--border-strong: #dcdce4;
|
||||||
|
|
||||||
/* Text */
|
/* ── Text ─────────────────────────────────────────────────────────────
|
||||||
--text-strong: #101828;
|
Near-black rather than pure black: on a light ground pure black reads
|
||||||
--text: #344054;
|
as a printing error, not as emphasis. */
|
||||||
--text-muted: #667085;
|
--text-strong: #17171c; /* headings — 16.4:1 on white */
|
||||||
--text-faint: #98a2b3;
|
--text: #43434e; /* body — 9.8:1 */
|
||||||
|
--text-muted: #6e6e7a; /* secondary copy — 5.0:1, AA for body text */
|
||||||
|
--text-faint: #b4b4be; /* decoration only: rules, placeholder glyphs */
|
||||||
|
|
||||||
/* Accent = the single brand / interaction tone: orange */
|
/* ── Accent ───────────────────────────────────────────────────────────
|
||||||
--accent: #f97316; /* brand tone: decorative fills, borders, tints, focus */
|
Orange appears in FEW places, but one of them is allowed to be large —
|
||||||
--accent-hover: #ea6a0c;
|
a whole closing panel. Frequency low, area high; the reverse reads as
|
||||||
--accent-active: #c2560a; /* AA-safe fill for white text on orange (~5:1) */
|
editorial annotation rather than as brand. */
|
||||||
--accent-press: #a8480a; /* darker press/hover for accent-fill buttons */
|
--accent: #f97316; /* rings, bars, chart strokes, the logo mark */
|
||||||
--accent-subtle: #fff4ec;
|
--accent-hover: #d95f0c;
|
||||||
--accent-border: #fed7aa;
|
--accent-active: #b8500a; /* solid fills and text — 5.0:1 with white */
|
||||||
--accent-ring: rgba(249, 115, 22, .35);
|
--accent-press: #974208;
|
||||||
|
--accent-subtle: #fff3e9;
|
||||||
|
--accent-border: #e8cdb2;
|
||||||
|
--accent-ring: rgba(249, 115, 22, .38);
|
||||||
--on-accent: #ffffff;
|
--on-accent: #ffffff;
|
||||||
/* Accent as TEXT on light surfaces (links, small labels) must be dark enough
|
/* #f97316 as TEXT on white is only 2.9:1, so anything read uses the
|
||||||
for AA — #f97316 on white is only ~2.9:1, so text uses --accent-active. */
|
darker tone. This is the single most common way the palette gets
|
||||||
--accent-text: #c2560a;
|
broken, which is why the two have different names. */
|
||||||
|
--accent-text: #b8500a;
|
||||||
|
|
||||||
/* The ink plate: the dark surface used where the brand speaks rather than
|
/* ── Ink fields ───────────────────────────────────────────────────────
|
||||||
the product — the sign-in panel, the closing block of the public site.
|
The dark surfaces: the site's contrast bands, the sign-in panel, the
|
||||||
Warm rather than blue-black, so it reads as ink, not as chrome. */
|
operator sidebar. Neutral-warm, never pure black. */
|
||||||
--plate: #17140f;
|
--plate: #16151b;
|
||||||
--plate-2: #211d16;
|
--plate-2: #201f27;
|
||||||
--plate-rule: #39332a;
|
--plate-rule: #33323d;
|
||||||
--plate-text: #ded7c9;
|
--plate-text: #c9c7d2;
|
||||||
--plate-muted: #93897a;
|
--plate-muted: #918f9d; /* 4.6:1 on --plate */
|
||||||
|
|
||||||
/* Status semantics — only in badges/alerts, never a second decorative tone */
|
/* ── Status ───────────────────────────────────────────────────────────
|
||||||
--success: #067a48; --success-bg: #ecfdf3; --success-border: #abefc6;
|
Independent of the brand. Amber must keep meaning "this needs you", so
|
||||||
--success-bright: #30b15c; /* dots/rings/charts only (large marks, not small text) */
|
"scheduled" and "informational" are blue — next to an orange brand the
|
||||||
--warning: #b54708; --warning-bg: #fffaeb; --warning-border: #fedf89;
|
two are otherwise indistinguishable. */
|
||||||
--danger: #b42318; --danger-bg: #fef3f2; --danger-border: #fecdca;
|
--success: #1c7c50; --success-bg: #eaf6f0; --success-border: #c3e3d3;
|
||||||
--info: #175cd3; --info-bg: #eff8ff; --info-border: #b2ddff;
|
--success-bright: #30b15c; /* dots, rings and charts — never small text */
|
||||||
|
--warning: #9a5b0b; --warning-bg: #fdf4e7; --warning-border: #eed9b3;
|
||||||
|
--danger: #a8302a; --danger-bg: #fbeeed; --danger-border: #eec9c6;
|
||||||
|
--info: #1e5aa8; --info-bg: #eef4fa; --info-border: #c3d6ea;
|
||||||
|
|
||||||
/* Typography */
|
/* ── Type ─────────────────────────────────────────────────────────────
|
||||||
|
One family, used assertively. Mono is for operational data only —
|
||||||
|
storage, dates, addresses, versions, invoice figures — where tabular
|
||||||
|
figures let a column of numbers line up. Never for headings. */
|
||||||
--font-sans: "IBM Plex Sans", ui-sans-serif, system-ui, sans-serif;
|
--font-sans: "IBM Plex Sans", ui-sans-serif, system-ui, sans-serif;
|
||||||
--font-mono: "IBM Plex Mono", ui-monospace, monospace;
|
--font-mono: "IBM Plex Mono", ui-monospace, monospace;
|
||||||
/* Display voice for headlines, shared with the public site so the two do
|
/* Kept for compatibility; the display voice is now the sans at weight 700.
|
||||||
not look like two different companies. Never used for body copy. */
|
A serif headline made every page read as a document. */
|
||||||
--font-serif: "IBM Plex Serif", Georgia, "Times New Roman", serif;
|
--font-serif: "IBM Plex Sans", ui-sans-serif, system-ui, sans-serif;
|
||||||
|
|
||||||
--text-xs: 12px; --text-sm: 13px; --text-base: 14px; --text-md: 15px;
|
--text-xs: 12px; --text-sm: 13px; --text-base: 14.5px; --text-md: 16px;
|
||||||
--text-lg: 18px; --text-xl: 22px; --text-2xl: 28px; --text-3xl: 34px;
|
--text-lg: 18px; --text-xl: 22px; --text-2xl: 30px; --text-3xl: 40px;
|
||||||
|
|
||||||
--lh-tight: 1.2; --lh-normal: 1.5;
|
--lh-tight: 1.12; --lh-normal: 1.55;
|
||||||
--tracking-tight: -0.02em;
|
--tracking-tight: -0.03em;
|
||||||
--tracking-label: 0.06em;
|
--tracking-label: 0.07em;
|
||||||
|
|
||||||
/* Radius / shadow / motion / focus */
|
/* ── Radius ───────────────────────────────────────────────────────────
|
||||||
--radius-sm: 6px; --radius: 8px; --radius-lg: 12px; --radius-xl: 20px; --radius-pill: 999px;
|
Scaled to object size. One value everywhere is what makes an interface
|
||||||
|
read as a design-system specification rather than as a product. */
|
||||||
|
--radius-sm: 9px; /* chips, small controls */
|
||||||
|
--radius: 11px; /* buttons, inputs */
|
||||||
|
--radius-lg: 16px; /* cards */
|
||||||
|
--radius-xl: 22px; /* full-width panels, product frames */
|
||||||
|
--radius-pill: 999px;
|
||||||
|
|
||||||
--shadow-xs: 0 1px 2px rgba(16, 24, 40, .05);
|
/* Warm, broad and low — a neutral grey drop shadow belongs to a different
|
||||||
--shadow-sm: 0 1px 3px rgba(16, 24, 40, .10), 0 1px 2px rgba(16, 24, 40, .06);
|
design language and reads as cold against this ground. */
|
||||||
--shadow-md: 0 4px 8px -2px rgba(16, 24, 40, .10), 0 2px 4px -2px rgba(16, 24, 40, .06);
|
--shadow-xs: 0 1px 2px rgba(40, 24, 15, .04);
|
||||||
|
--shadow-sm: 0 2px 6px rgba(40, 24, 15, .06);
|
||||||
|
--shadow-md: 0 16px 44px -18px rgba(40, 24, 15, .20);
|
||||||
|
|
||||||
--dur-fast: 120ms; --dur: 180ms; --dur-slow: 240ms;
|
--dur-fast: 120ms; --dur: 180ms; --dur-slow: 260ms;
|
||||||
--ease: cubic-bezier(.2, .6, .2, 1);
|
--ease: cubic-bezier(.2, .7, .2, 1);
|
||||||
|
|
||||||
--focus-ring: 0 0 0 3px var(--accent-ring);
|
--focus-ring: 0 0 0 3px var(--accent-ring);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,66 +19,69 @@
|
||||||
--}}
|
--}}
|
||||||
@verbatim
|
@verbatim
|
||||||
<style>
|
<style>
|
||||||
@font-face{font-family:"Plex Serif";src:url("/fonts/ibm-plex-serif-latin-600-normal.woff2") format("woff2");font-weight:600;font-display:swap}
|
|
||||||
@font-face{font-family:"Plex Sans";src:url("/fonts/ibm-plex-sans-latin-400-normal.woff2") format("woff2");font-weight:400;font-display:swap}
|
@font-face{font-family:"Plex Sans";src:url("/fonts/ibm-plex-sans-latin-400-normal.woff2") format("woff2");font-weight:400;font-display:swap}
|
||||||
|
@font-face{font-family:"Plex Sans";src:url("/fonts/ibm-plex-sans-latin-600-normal.woff2") format("woff2");font-weight:600;font-display:swap}
|
||||||
@font-face{font-family:"Plex Mono";src:url("/fonts/ibm-plex-mono-latin-400-normal.woff2") format("woff2");font-weight:400;font-display:swap}
|
@font-face{font-family:"Plex Mono";src:url("/fonts/ibm-plex-mono-latin-400-normal.woff2") format("woff2");font-weight:400;font-display:swap}
|
||||||
|
|
||||||
:root{
|
:root{
|
||||||
--paper:#f7f5f1; --card:#fffefb; --ink:#17140f; --ink-soft:#413a31;
|
--bg:#f6f6f8; --card:#fff; --card-2:#fafafb;
|
||||||
--muted:#7b7367; --rule:#e3ded3; --rule-mid:#cbc3b4;
|
--ink:#17171c; --body:#43434e; --muted:#6e6e7a;
|
||||||
--accent:#f97316; --accent-text:#b8500a;
|
--line:#e9e9ee; --line-2:#dcdce4;
|
||||||
}
|
--accent:#f97316; --accent-ink:#b8500a; --accent-wash:#fff3e9;
|
||||||
@media (prefers-color-scheme: dark){
|
--plate:#16151b;
|
||||||
/* The accent has to lighten too: #b8500a is chosen for contrast against
|
|
||||||
paper, and on the ink plate it is the small text that stops being
|
|
||||||
readable first. */
|
|
||||||
:root{--paper:#17140f; --card:#211d16; --ink:#fffefb; --ink-soft:#ded7c9;
|
|
||||||
--muted:#93897a; --rule:#39332a; --rule-mid:#4a4238;
|
|
||||||
--accent-text:#f0a06a;}
|
|
||||||
}
|
}
|
||||||
*{margin:0;padding:0;box-sizing:border-box}
|
*{margin:0;padding:0;box-sizing:border-box}
|
||||||
|
html{-webkit-text-size-adjust:100%}
|
||||||
body{
|
body{
|
||||||
font-family:"Plex Sans",-apple-system,BlinkMacSystemFont,sans-serif;
|
font-family:"Plex Sans",-apple-system,BlinkMacSystemFont,sans-serif;
|
||||||
background:var(--paper);color:var(--ink-soft);line-height:1.6;
|
background:var(--bg);color:var(--body);line-height:1.55;
|
||||||
min-height:100vh;display:grid;place-items:center;padding:clamp(24px,6vw,64px);
|
min-height:100vh;display:grid;place-items:center;padding:clamp(24px,6vw,64px);
|
||||||
-webkit-font-smoothing:antialiased;
|
-webkit-font-smoothing:antialiased;
|
||||||
}
|
}
|
||||||
main{max-width:520px;width:100%}
|
main{max-width:560px;width:100%}
|
||||||
.plate{border:1px solid var(--rule-mid);border-radius:3px;background:var(--card);position:relative}
|
.card{border:1px solid var(--line);border-radius:16px;background:var(--card);
|
||||||
/* Registration marks, as on the rest of the site. */
|
box-shadow:0 16px 50px rgba(40,24,15,.10);overflow:hidden}
|
||||||
.plate::before,.plate::after{content:"+";position:absolute;font-family:"Plex Mono",monospace;font-size:.8rem;color:var(--rule-mid);line-height:1}
|
.head{display:flex;align-items:center;justify-content:space-between;gap:14px;
|
||||||
.plate::before{top:-6px;left:-6px}
|
padding:16px 24px;border-bottom:1px solid var(--line);background:var(--card-2);
|
||||||
.plate::after{bottom:-6px;right:-6px}
|
font-size:13px;font-weight:600;color:var(--muted)}
|
||||||
.head{
|
.head b{color:var(--accent-ink);font-family:"Plex Mono",monospace;font-weight:500}
|
||||||
display:flex;justify-content:space-between;align-items:center;gap:14px;
|
.body{padding:30px 24px 28px}
|
||||||
padding:13px 20px;border-bottom:1px solid var(--rule);
|
.brand{display:inline-flex;align-items:center;gap:9px;font-size:17px;font-weight:700;
|
||||||
font-family:"Plex Mono",monospace;font-size:.7rem;text-transform:uppercase;letter-spacing:.15em;color:var(--muted);
|
letter-spacing:-.02em;color:var(--ink)}
|
||||||
}
|
.brand i{font-style:normal;color:var(--accent-ink)}
|
||||||
.head b{color:var(--accent-text);font-weight:400;display:inline-flex;align-items:center;gap:7px}
|
.mark{width:26px;height:26px;border-radius:7px;flex:none;
|
||||||
.dot{width:6px;height:6px;border-radius:50%;background:var(--accent);animation:pulse 1.8s ease-in-out infinite}
|
background:linear-gradient(135deg,#fb923c,#f97316 55%,#c2560a)}
|
||||||
|
h1{margin-top:18px;font-weight:700;font-size:clamp(1.6rem,4vw,2.1rem);
|
||||||
|
letter-spacing:-.032em;line-height:1.1;color:var(--ink)}
|
||||||
|
p{margin-top:12px;color:var(--muted);font-size:15px}
|
||||||
|
.hint{margin-top:18px;padding-top:16px;border-top:1px solid var(--line);font-size:14px}
|
||||||
|
.acts{display:flex;flex-wrap:wrap;gap:10px;margin-top:26px}
|
||||||
|
.btn{display:inline-flex;align-items:center;min-height:46px;padding:0 22px;border-radius:12px;
|
||||||
|
font-size:15px;font-weight:600;text-decoration:none;border:1.5px solid transparent;
|
||||||
|
transition:background .18s,border-color .18s}
|
||||||
|
.ink{background:var(--accent-ink);color:#fff}
|
||||||
|
.ink:hover{background:#974208}
|
||||||
|
.line{border-color:var(--line-2);color:var(--ink)}
|
||||||
|
.line:hover{border-color:var(--ink)}
|
||||||
|
.foot{margin-top:12px;font-size:14px}
|
||||||
|
.foot a{color:var(--accent-ink);font-weight:600;text-decoration:none}
|
||||||
|
.foot a:hover{text-decoration:underline}
|
||||||
|
.mark-b{margin-top:22px;font-size:12.5px;color:var(--muted);text-align:center}
|
||||||
|
.dot{width:7px;height:7px;border-radius:50%;background:var(--accent);animation:pulse 1.8s ease-in-out infinite}
|
||||||
@keyframes pulse{50%{opacity:.35}}
|
@keyframes pulse{50%{opacity:.35}}
|
||||||
@media (prefers-reduced-motion: reduce){.dot{animation:none}}
|
@media (prefers-reduced-motion: reduce){.dot{animation:none}}
|
||||||
.body{padding:30px 20px 28px}
|
|
||||||
.brand{font-family:"Plex Serif",Georgia,serif;font-weight:600;font-size:1.1rem;letter-spacing:-.02em;color:var(--ink)}
|
|
||||||
.brand i{font-style:normal;color:var(--accent-text)}
|
|
||||||
h1{margin-top:16px;font-family:"Plex Serif",Georgia,serif;font-weight:600;
|
|
||||||
font-size:clamp(1.5rem,4vw,2rem);letter-spacing:-.025em;line-height:1.15;color:var(--ink)}
|
|
||||||
p{margin-top:12px;color:var(--muted);font-size:.95rem}
|
|
||||||
.foot{margin-top:18px;padding-top:16px;border-top:1px solid var(--rule);font-size:.86rem}
|
|
||||||
.foot a{color:var(--accent-text);font-weight:500;text-decoration:none}
|
|
||||||
.foot a:hover{text-decoration:underline}
|
|
||||||
</style>
|
</style>
|
||||||
@endverbatim
|
@endverbatim
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main>
|
<main>
|
||||||
<div class="plate">
|
<div class="card">
|
||||||
<div class="head">
|
<div class="head">
|
||||||
<span>{{ __('coming_soon.label') }}</span>
|
<span>{{ __('coming_soon.label') }}</span>
|
||||||
<b><span class="dot" aria-hidden="true"></span>{{ __('coming_soon.state') }}</b>
|
<b style="display:inline-flex;align-items:center;gap:8px"><span class="dot" aria-hidden="true"></span>{{ __('coming_soon.state') }}</b>
|
||||||
</div>
|
</div>
|
||||||
<div class="body">
|
<div class="body">
|
||||||
<span class="brand">Clu<i>Pilot</i> Cloud</span>
|
<span class="brand"><span class="mark" aria-hidden="true"></span>Clu<i>Pilot</i> Cloud</span>
|
||||||
<h1>{{ __('coming_soon.title') }}</h1>
|
<h1>{{ __('coming_soon.title') }}</h1>
|
||||||
<p>{{ __('coming_soon.body') }}</p>
|
<p>{{ __('coming_soon.body') }}</p>
|
||||||
<p class="foot">{{ __('coming_soon.contact') }}
|
<p class="foot">{{ __('coming_soon.contact') }}
|
||||||
|
|
|
||||||
|
|
@ -14,24 +14,20 @@
|
||||||
would only push it under the fold.
|
would only push it under the fold.
|
||||||
--}}
|
--}}
|
||||||
<aside class="relative hidden w-[44%] shrink-0 overflow-hidden bg-plate lg:flex lg:flex-col lg:justify-between lg:p-12 xl:p-14">
|
<aside class="relative hidden w-[44%] shrink-0 overflow-hidden bg-plate lg:flex lg:flex-col lg:justify-between lg:p-12 xl:p-14">
|
||||||
{{-- Survey grid, faded out towards the bottom. --}}
|
{{-- One warm glow, the same one the public site's dark bands carry. --}}
|
||||||
<div aria-hidden="true" class="pointer-events-none absolute inset-0 opacity-60"
|
<div aria-hidden="true" class="pointer-events-none absolute inset-0"
|
||||||
style="background-image:linear-gradient(var(--plate-rule) 1px,transparent 1px),linear-gradient(90deg,var(--plate-rule) 1px,transparent 1px);
|
style="background:radial-gradient(ellipse 52% 46% at 24% 18%,rgba(249,115,22,.20),transparent 68%);"></div>
|
||||||
background-size:76px 76px;
|
|
||||||
mask-image:radial-gradient(ellipse 78% 62% at 30% 8%,#000 0%,transparent 72%);
|
|
||||||
-webkit-mask-image:radial-gradient(ellipse 78% 62% at 30% 8%,#000 0%,transparent 72%);"></div>
|
|
||||||
|
|
||||||
<div class="relative flex items-center gap-2.5">
|
<div class="relative flex items-center gap-2.5">
|
||||||
<x-ui.logo class="size-8" />
|
<x-ui.logo class="size-8" />
|
||||||
<span class="font-serif text-xl font-semibold tracking-tight text-white">CluPilot</span>
|
<span class="text-xl font-bold tracking-tight text-white">CluPilot</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<p class="mb-6 flex items-center gap-3 font-mono text-[11px] uppercase tracking-[0.16em] text-plate-muted">
|
<p class="mb-7 inline-flex items-center gap-2 rounded-pill px-3.5 py-1.5 text-xs font-semibold"
|
||||||
<span class="h-px w-6 bg-accent"></span>{{ __('auth.brand_eyebrow') }}
|
style="background:rgba(249,115,22,.16);color:#ffb27a">{{ __('auth.brand_eyebrow') }}</p>
|
||||||
</p>
|
|
||||||
|
|
||||||
<h2 class="max-w-md font-serif text-4xl font-semibold leading-[1.08] tracking-tight text-white xl:text-[2.9rem]">{{ $headline }}</h2>
|
<h2 class="max-w-md text-4xl font-bold leading-[1.08] tracking-tight text-white xl:text-[2.9rem]">{{ $headline }}</h2>
|
||||||
<p class="mt-5 max-w-sm leading-relaxed text-plate-text">{{ $sub }}</p>
|
<p class="mt-5 max-w-sm leading-relaxed text-plate-text">{{ $sub }}</p>
|
||||||
|
|
||||||
@if ($facts !== [])
|
@if ($facts !== [])
|
||||||
|
|
@ -46,5 +42,5 @@
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="relative font-mono text-[11px] uppercase tracking-[0.14em] text-plate-muted">{{ __('auth.brand_footer') }}</p>
|
<p class="relative text-xs text-plate-muted">{{ __('auth.brand_footer') }}</p>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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 font-mono text-[11px] uppercase tracking-[0.07em] text-muted">{{ $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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -7,6 +7,6 @@
|
||||||
'danger' => 'bg-danger-bg border-danger-border text-danger',
|
'danger' => 'bg-danger-bg border-danger-border text-danger',
|
||||||
];
|
];
|
||||||
@endphp
|
@endphp
|
||||||
<div role="alert" {{ $attributes->merge(['class' => 'rounded border px-3 py-2 text-sm '.($variants[$variant] ?? $variants['info'])]) }}>
|
<div role="alert" {{ $attributes->merge(['class' => 'rounded border px-4 py-3 text-sm '.($variants[$variant] ?? $variants['info'])]) }}>
|
||||||
{{ $slot }}
|
{{ $slot }}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@
|
||||||
'info' => 'bg-info-bg border-info-border text-info',
|
'info' => 'bg-info-bg border-info-border text-info',
|
||||||
][$tone];
|
][$tone];
|
||||||
@endphp
|
@endphp
|
||||||
<span {{ $attributes->merge(['class' => 'inline-flex items-center gap-1.5 rounded-pill border px-2.5 py-0.5 text-xs font-medium '.$classes]) }}>
|
<span {{ $attributes->merge(['class' => 'inline-flex items-center gap-1.5 rounded-pill border px-2.5 py-1 text-xs font-semibold '.$classes]) }}>
|
||||||
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>
|
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>
|
||||||
{{ $slot }}
|
{{ $slot }}
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,10 @@
|
||||||
'size' => 'md',
|
'size' => 'md',
|
||||||
'type' => 'button',
|
'type' => 'button',
|
||||||
'loading' => false,
|
'loading' => false,
|
||||||
|
// Given an href this renders an anchor instead. A link that looks like a
|
||||||
|
// button must still BE a link: middle-click, "open in new tab" and the
|
||||||
|
// status bar all come from the element, not from the styling.
|
||||||
|
'href' => null,
|
||||||
])
|
])
|
||||||
@php
|
@php
|
||||||
$base = 'inline-flex items-center justify-center gap-2 rounded font-semibold transition select-none disabled:opacity-50 disabled:pointer-events-none';
|
$base = 'inline-flex items-center justify-center gap-2 rounded font-semibold transition select-none disabled:opacity-50 disabled:pointer-events-none';
|
||||||
|
|
@ -19,10 +23,18 @@
|
||||||
'secondary' => 'border border-line-strong bg-surface text-body hover:bg-surface-hover',
|
'secondary' => 'border border-line-strong bg-surface text-body hover:bg-surface-hover',
|
||||||
'ghost' => 'text-body hover:bg-surface-hover',
|
'ghost' => 'text-body hover:bg-surface-hover',
|
||||||
'danger' => 'bg-danger text-on-accent hover:opacity-90',
|
'danger' => 'bg-danger text-on-accent hover:opacity-90',
|
||||||
|
// The document primary: ink on paper, warming to the accent on hover.
|
||||||
|
// Orange is the accent, not the loudest thing on the page — on a sheet
|
||||||
|
// full of hairlines a filled orange button is the only thing anyone
|
||||||
|
// sees, and it is rarely the most important thing on the page.
|
||||||
|
'ink' => 'bg-ink text-bg hover:bg-accent-text',
|
||||||
];
|
];
|
||||||
|
|
||||||
$classes = $base.' '.($sizes[$size] ?? $sizes['md']).' '.($variants[$variant] ?? $variants['primary']);
|
$classes = $base.' '.($sizes[$size] ?? $sizes['md']).' '.($variants[$variant] ?? $variants['primary']);
|
||||||
@endphp
|
@endphp
|
||||||
|
@if ($href !== null)
|
||||||
|
<a href="{{ $href }}" {{ $attributes->merge(['class' => $classes]) }}>{{ $slot }}</a>
|
||||||
|
@else
|
||||||
<button
|
<button
|
||||||
type="{{ $type }}"
|
type="{{ $type }}"
|
||||||
{{ $attributes->merge(['class' => $classes]) }}
|
{{ $attributes->merge(['class' => $classes]) }}
|
||||||
|
|
@ -37,3 +49,4 @@
|
||||||
@endif
|
@endif
|
||||||
{{ $slot }}
|
{{ $slot }}
|
||||||
</button>
|
</button>
|
||||||
|
@endif
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,19 @@
|
||||||
@props([
|
@props([
|
||||||
'title' => null,
|
'title' => null,
|
||||||
'padding' => 'p-6',
|
'padding' => 'p-5',
|
||||||
])
|
])
|
||||||
|
{{--
|
||||||
|
The unit every panel is built from. Depth is one warm, broad shadow — a
|
||||||
|
neutral grey drop shadow belongs to a different design language and reads
|
||||||
|
as cold against this ground.
|
||||||
|
--}}
|
||||||
<div {{ $attributes->merge(['class' => 'rounded-lg border border-line bg-surface shadow-xs']) }}>
|
<div {{ $attributes->merge(['class' => 'rounded-lg border border-line bg-surface shadow-xs']) }}>
|
||||||
@if ($title)
|
@if ($title)
|
||||||
<div class="border-b border-line px-6 py-3">
|
{{-- A real heading, not a small-caps label. It was set in --text-faint,
|
||||||
<h2 class="text-xs font-semibold uppercase tracking-wide text-faint">{{ $title }}</h2>
|
which is 2.8:1 on white: a decoration token used for something a
|
||||||
|
reader has to read. --}}
|
||||||
|
<div class="border-b border-line px-5 py-4">
|
||||||
|
<h2 class="text-md font-semibold text-ink">{{ $title }}</h2>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
<div class="{{ $padding }}">
|
<div class="{{ $padding }}">
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
$errors ??= new \Illuminate\Support\ViewErrorBag;
|
$errors ??= new \Illuminate\Support\ViewErrorBag;
|
||||||
$id = $attributes->get('id', $name);
|
$id = $attributes->get('id', $name);
|
||||||
$hasError = $errors->has($name);
|
$hasError = $errors->has($name);
|
||||||
$field = 'block w-full rounded border bg-surface px-3 py-2 text-sm text-ink placeholder:text-faint transition '
|
$field = 'block w-full rounded border bg-surface px-3.5 py-2.5 text-sm text-ink placeholder:text-faint transition '
|
||||||
.($hasError ? 'border-danger bg-danger-bg' : 'border-line');
|
.($hasError ? 'border-danger bg-danger-bg' : 'border-line');
|
||||||
@endphp
|
@endphp
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
|
|
@ -27,7 +27,7 @@
|
||||||
>
|
>
|
||||||
|
|
||||||
@if ($hint && ! $hasError)
|
@if ($hint && ! $hasError)
|
||||||
<p class="text-xs text-faint">{{ $hint }}</p>
|
<p class="text-xs text-muted">{{ $hint }}</p>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@error($name)
|
@error($name)
|
||||||
|
|
|
||||||
|
|
@ -6,21 +6,26 @@
|
||||||
@php
|
@php
|
||||||
// items-center, not baseline: the icon is a block-level svg (Tailwind's
|
// items-center, not baseline: the icon is a block-level svg (Tailwind's
|
||||||
// preflight makes it one) and must sit BESIDE the label, never above it.
|
// preflight makes it one) and must sit BESIDE the label, never above it.
|
||||||
$base = 'flex items-center gap-3 rounded border-l-2 px-3 py-2 text-sm font-medium min-h-11';
|
// R18.
|
||||||
|
$base = 'relative flex min-h-11 items-center gap-3 rounded px-3 text-sm';
|
||||||
|
|
||||||
// The label is a flex row of its own so that an icon handed to the default
|
// The label is a flex row of its own so an icon handed to the default slot
|
||||||
// slot instead of the icon slot still lands next to the text. Passing it in
|
// instead of the icon slot still lands next to the text. Passing it in the
|
||||||
// the default slot put a display:block svg into an inline span and pushed
|
// default slot put a display:block svg into an inline span and pushed the
|
||||||
// the label onto a second line — the sidebar entry then stood twice as tall
|
// label onto a second line — the entry then stood twice as tall as every
|
||||||
// as every other one. Layout must not depend on which slot was used.
|
// other one. Layout must not depend on which slot was used.
|
||||||
$label = 'flex min-w-0 items-center gap-3';
|
$label = 'flex min-w-0 items-center gap-3';
|
||||||
|
|
||||||
|
// Active is a soft pill with a 3px accent tab flush at its left edge — a
|
||||||
|
// physical "you are here" marker rather than a colour wash. The tab is a
|
||||||
|
// pseudo-element via before:, so it cannot push the label around.
|
||||||
$state = $active
|
$state = $active
|
||||||
? 'bg-accent-subtle text-accent-text border-accent'
|
? 'bg-surface-hover font-semibold text-ink before:absolute before:left-0 before:top-2.5 before:bottom-2.5 before:w-[3px] before:rounded-r before:bg-accent before:content-[\'\']'
|
||||||
: 'text-muted border-transparent hover:bg-surface-hover hover:text-body';
|
: 'font-medium text-muted hover:bg-surface-hover hover:text-ink';
|
||||||
@endphp
|
@endphp
|
||||||
@if ($disabled)
|
@if ($disabled)
|
||||||
{{-- Section not built yet: render a non-interactive item, never a dead link. --}}
|
{{-- Section not built yet: a non-interactive item, never a dead link. --}}
|
||||||
<span aria-disabled="true" {{ $attributes->merge(['class' => $base.' border-transparent text-faint cursor-not-allowed']) }}>
|
<span aria-disabled="true" {{ $attributes->merge(['class' => $base.' cursor-not-allowed font-medium text-faint']) }}>
|
||||||
@isset($icon)<span class="shrink-0">{{ $icon }}</span>@endisset
|
@isset($icon)<span class="shrink-0">{{ $icon }}</span>@endisset
|
||||||
<span class="{{ $label }}">{{ $slot }}</span>
|
<span class="{{ $label }}">{{ $slot }}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -37,62 +37,66 @@
|
||||||
--}}
|
--}}
|
||||||
@verbatim
|
@verbatim
|
||||||
<style>
|
<style>
|
||||||
@font-face{font-family:"Plex Serif";src:url("/fonts/ibm-plex-serif-latin-600-normal.woff2") format("woff2");font-weight:600;font-display:swap}
|
|
||||||
@font-face{font-family:"Plex Sans";src:url("/fonts/ibm-plex-sans-latin-400-normal.woff2") format("woff2");font-weight:400;font-display:swap}
|
@font-face{font-family:"Plex Sans";src:url("/fonts/ibm-plex-sans-latin-400-normal.woff2") format("woff2");font-weight:400;font-display:swap}
|
||||||
|
@font-face{font-family:"Plex Sans";src:url("/fonts/ibm-plex-sans-latin-600-normal.woff2") format("woff2");font-weight:600;font-display:swap}
|
||||||
@font-face{font-family:"Plex Mono";src:url("/fonts/ibm-plex-mono-latin-400-normal.woff2") format("woff2");font-weight:400;font-display:swap}
|
@font-face{font-family:"Plex Mono";src:url("/fonts/ibm-plex-mono-latin-400-normal.woff2") format("woff2");font-weight:400;font-display:swap}
|
||||||
|
|
||||||
:root{
|
:root{
|
||||||
--paper:#f7f5f1; --card:#fffefb; --ink:#17140f; --ink-soft:#413a31;
|
--bg:#f6f6f8; --card:#fff; --card-2:#fafafb;
|
||||||
--muted:#7b7367; --rule:#e3ded3; --rule-mid:#cbc3b4;
|
--ink:#17171c; --body:#43434e; --muted:#6e6e7a;
|
||||||
--accent:#f97316; --accent-text:#b8500a;
|
--line:#e9e9ee; --line-2:#dcdce4;
|
||||||
}
|
--accent:#f97316; --accent-ink:#b8500a; --accent-wash:#fff3e9;
|
||||||
@media (prefers-color-scheme: dark){
|
--plate:#16151b;
|
||||||
/* The accent has to lighten too: #b8500a is chosen for contrast against
|
|
||||||
paper, and on the ink plate it is the small text that stops being
|
|
||||||
readable first. */
|
|
||||||
:root{--paper:#17140f; --card:#211d16; --ink:#fffefb; --ink-soft:#ded7c9;
|
|
||||||
--muted:#93897a; --rule:#39332a; --rule-mid:#4a4238;
|
|
||||||
--accent-text:#f0a06a;}
|
|
||||||
}
|
}
|
||||||
*{margin:0;padding:0;box-sizing:border-box}
|
*{margin:0;padding:0;box-sizing:border-box}
|
||||||
|
html{-webkit-text-size-adjust:100%}
|
||||||
body{
|
body{
|
||||||
font-family:"Plex Sans",-apple-system,BlinkMacSystemFont,sans-serif;
|
font-family:"Plex Sans",-apple-system,BlinkMacSystemFont,sans-serif;
|
||||||
background:var(--paper);color:var(--ink-soft);line-height:1.6;
|
background:var(--bg);color:var(--body);line-height:1.55;
|
||||||
min-height:100vh;display:grid;place-items:center;padding:clamp(24px,6vw,64px);
|
min-height:100vh;display:grid;place-items:center;padding:clamp(24px,6vw,64px);
|
||||||
-webkit-font-smoothing:antialiased;
|
-webkit-font-smoothing:antialiased;
|
||||||
}
|
}
|
||||||
main{max-width:520px;width:100%}
|
main{max-width:560px;width:100%}
|
||||||
.plate{border:1px solid var(--rule-mid);border-radius:3px;background:var(--card);position:relative}
|
.card{border:1px solid var(--line);border-radius:16px;background:var(--card);
|
||||||
/* Registration marks, as on the rest of the site. */
|
box-shadow:0 16px 50px rgba(40,24,15,.10);overflow:hidden}
|
||||||
.plate::before,.plate::after{content:"+";position:absolute;font-family:"Plex Mono",monospace;font-size:.8rem;color:var(--rule-mid);line-height:1}
|
.head{display:flex;align-items:center;justify-content:space-between;gap:14px;
|
||||||
.plate::before{top:-6px;left:-6px}
|
padding:16px 24px;border-bottom:1px solid var(--line);background:var(--card-2);
|
||||||
.plate::after{bottom:-6px;right:-6px}
|
font-size:13px;font-weight:600;color:var(--muted)}
|
||||||
.head{
|
.head b{color:var(--accent-ink);font-family:"Plex Mono",monospace;font-weight:500}
|
||||||
display:flex;justify-content:space-between;align-items:center;gap:14px;
|
.body{padding:30px 24px 28px}
|
||||||
padding:13px 20px;border-bottom:1px solid var(--rule);
|
.brand{display:inline-flex;align-items:center;gap:9px;font-size:17px;font-weight:700;
|
||||||
font-family:"Plex Mono",monospace;font-size:.7rem;text-transform:uppercase;letter-spacing:.15em;color:var(--muted);
|
letter-spacing:-.02em;color:var(--ink)}
|
||||||
}
|
.brand i{font-style:normal;color:var(--accent-ink)}
|
||||||
.head b{color:var(--accent-text);font-weight:400}
|
.mark{width:26px;height:26px;border-radius:7px;flex:none;
|
||||||
.body{padding:28px 20px 26px}
|
background:linear-gradient(135deg,#fb923c,#f97316 55%,#c2560a)}
|
||||||
h1{font-family:"Plex Serif",Georgia,serif;font-weight:600;font-size:clamp(1.5rem,4vw,2rem);
|
h1{margin-top:18px;font-weight:700;font-size:clamp(1.6rem,4vw,2.1rem);
|
||||||
letter-spacing:-.025em;line-height:1.15;color:var(--ink)}
|
letter-spacing:-.032em;line-height:1.1;color:var(--ink)}
|
||||||
p{margin-top:12px;color:var(--muted);font-size:.95rem}
|
p{margin-top:12px;color:var(--muted);font-size:15px}
|
||||||
.hint{margin-top:16px;padding-top:14px;border-top:1px solid var(--rule);font-size:.86rem}
|
.hint{margin-top:18px;padding-top:16px;border-top:1px solid var(--line);font-size:14px}
|
||||||
.acts{display:flex;flex-wrap:wrap;gap:10px;margin-top:24px}
|
.acts{display:flex;flex-wrap:wrap;gap:10px;margin-top:26px}
|
||||||
.btn{display:inline-flex;align-items:center;border-radius:3px;font-size:.88rem;font-weight:500;padding:10px 18px;text-decoration:none;transition:background .18s,border-color .18s}
|
.btn{display:inline-flex;align-items:center;min-height:46px;padding:0 22px;border-radius:12px;
|
||||||
.ink{background:var(--ink);color:var(--paper)}
|
font-size:15px;font-weight:600;text-decoration:none;border:1.5px solid transparent;
|
||||||
.ink:hover{background:var(--accent-text);color:#fff}
|
transition:background .18s,border-color .18s}
|
||||||
.line{border:1px solid var(--rule-mid);color:var(--ink)}
|
.ink{background:var(--accent-ink);color:#fff}
|
||||||
|
.ink:hover{background:#974208}
|
||||||
|
.line{border-color:var(--line-2);color:var(--ink)}
|
||||||
.line:hover{border-color:var(--ink)}
|
.line:hover{border-color:var(--ink)}
|
||||||
.mark{margin-top:22px;font-family:"Plex Mono",monospace;font-size:.72rem;color:var(--muted);text-align:center}
|
.foot{margin-top:12px;font-size:14px}
|
||||||
|
.foot a{color:var(--accent-ink);font-weight:600;text-decoration:none}
|
||||||
|
.foot a:hover{text-decoration:underline}
|
||||||
|
.mark-b{margin-top:22px;font-size:12.5px;color:var(--muted);text-align:center}
|
||||||
|
.dot{width:7px;height:7px;border-radius:50%;background:var(--accent);animation:pulse 1.8s ease-in-out infinite}
|
||||||
|
@keyframes pulse{50%{opacity:.35}}
|
||||||
|
@media (prefers-reduced-motion: reduce){.dot{animation:none}}
|
||||||
</style>
|
</style>
|
||||||
@endverbatim
|
@endverbatim
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main>
|
<main>
|
||||||
<div class="plate">
|
<div class="card">
|
||||||
<div class="head"><span>{{ __('errors.heading') }}</span><b>{{ $code }}</b></div>
|
<div class="head"><span>{{ __('errors.heading') }}</span><b>{{ $code }}</b></div>
|
||||||
<div class="body">
|
<div class="body">
|
||||||
|
<span class="brand"><span class="mark" aria-hidden="true"></span>Clu<i>Pilot</i></span>
|
||||||
<h1>{{ $title }}</h1>
|
<h1>{{ $title }}</h1>
|
||||||
@if ($body)
|
@if ($body)
|
||||||
<p>{{ $body }}</p>
|
<p>{{ $body }}</p>
|
||||||
|
|
@ -108,7 +112,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="mark">CluPilot Cloud</p>
|
<p class="mark-b">CluPilot Cloud</p>
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -1,103 +1,67 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="h-full">
|
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="h-full">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<x-shell.head :title="$title ?? __('admin.console')" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
|
||||||
<link rel="apple-touch-icon" href="/favicon.svg">
|
|
||||||
<title>{{ $title ?? __('admin.console') }}</title>
|
|
||||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
|
||||||
@livewireStyles
|
|
||||||
</head>
|
</head>
|
||||||
{{-- theme-admin swaps every design token to the dark Tactical-Terminal set. --}}
|
{{--
|
||||||
<body class="theme-admin min-h-full bg-bg text-body antialiased" x-data="{ nav: false }">
|
theme-admin no longer swaps the palette. It used to load a complete dark set
|
||||||
|
— 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 class="flex min-h-screen lg:h-screen lg:overflow-hidden">
|
<div class="flex min-h-screen lg:h-screen lg:overflow-hidden">
|
||||||
<aside
|
<x-shell.nav
|
||||||
class="fixed inset-y-0 left-0 z-40 flex w-60 flex-col overflow-y-auto border-r border-line bg-surface px-3 py-4 transition-transform lg:static lg:h-screen lg:translate-x-0"
|
prefix="admin.nav"
|
||||||
:class="nav ? 'translate-x-0' : '-translate-x-full'"
|
console
|
||||||
>
|
:footer="$release->label()"
|
||||||
<div class="flex items-center justify-between px-3 pb-4">
|
:groups="[
|
||||||
<div class="flex items-center gap-2">
|
['label' => null, 'items' => [
|
||||||
<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.overview', 'gauge', 'overview', null],
|
||||||
['admin.customers', 'users', 'customers', null],
|
['admin.customers', 'users', 'customers', null],
|
||||||
['admin.instances', 'box', 'instances', null],
|
['admin.instances', 'box', 'instances', null],
|
||||||
['admin.hosts', 'server', 'hosts', null],
|
['admin.hosts', 'server', 'hosts', null],
|
||||||
['admin.datacenters', 'database', 'datacenters', null],
|
['admin.datacenters', 'database', 'datacenters', null],
|
||||||
['admin.plans', 'tag', 'plans', 'plans.manage'],
|
['admin.plans', 'tag', 'plans', 'plans.manage'],
|
||||||
|
]],
|
||||||
|
['label' => __('admin.nav_group.operations'), 'items' => [
|
||||||
['admin.provisioning', 'activity', 'provisioning', null],
|
['admin.provisioning', 'activity', 'provisioning', null],
|
||||||
['admin.maintenance', 'alert-triangle', 'maintenance', null],
|
['admin.maintenance', 'alert-triangle', 'maintenance', null],
|
||||||
['admin.vpn', 'shield', 'vpn', null],
|
['admin.vpn', 'shield', 'vpn', null],
|
||||||
['admin.revenue', 'trending-up', 'revenue', null],
|
['admin.revenue', 'trending-up', 'revenue', null],
|
||||||
] as [$route, $icon, $key, $capability])
|
]],
|
||||||
@continue($capability !== null && ! auth()->user()?->can($capability))
|
['label' => __('admin.nav_group.system'), 'items' => [
|
||||||
<x-ui.nav-item :href="route($route)" :active="\App\Support\AdminArea::routeIs($route)">
|
['admin.secrets', 'lock', 'secrets', 'secrets.manage'],
|
||||||
<x-slot:icon><x-ui.icon :name="$icon" /></x-slot:icon>
|
['admin.settings', 'settings', 'settings', null],
|
||||||
{{ __('admin.nav.'.$key) }}
|
]],
|
||||||
</x-ui.nav-item>
|
]"
|
||||||
@endforeach
|
/>
|
||||||
</nav>
|
|
||||||
<div class="mt-auto space-y-1 border-t border-line pt-4">
|
|
||||||
@can('secrets.manage')
|
|
||||||
{{-- Only where the capability is held: "can open the console"
|
|
||||||
must not mean "can read the payment key". --}}
|
|
||||||
<x-ui.nav-item :href="route('admin.secrets')" :active="\App\Support\AdminArea::routeIs('admin.secrets')">
|
|
||||||
<x-slot:icon><x-ui.icon name="lock" /></x-slot:icon>
|
|
||||||
{{ __('admin.nav.secrets') }}
|
|
||||||
</x-ui.nav-item>
|
|
||||||
@endcan
|
|
||||||
<x-ui.nav-item :href="route('admin.settings')" :active="\App\Support\AdminArea::routeIs('admin.settings')">
|
|
||||||
<x-slot:icon><x-ui.icon name="settings" /></x-slot:icon>
|
|
||||||
{{ __('admin.nav.settings') }}
|
|
||||||
</x-ui.nav-item>
|
|
||||||
{{-- What is actually deployed, so a bug report names the right
|
|
||||||
code. `-dev` means somewhere after the release, not on it. --}}
|
|
||||||
@php $release = App\Services\Deployment\Release::current(); @endphp
|
|
||||||
<p class="px-3 pt-2 font-mono text-[11px] leading-tight text-faint"
|
|
||||||
title="{{ __('admin.version_hint') }}">{{ $release->label() }}</p>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<div x-show="nav" x-cloak @click="nav = false" class="fixed inset-0 z-30 bg-black/40 lg:hidden"></div>
|
|
||||||
|
|
||||||
<div class="flex min-w-0 flex-1 flex-col lg:h-screen lg:overflow-hidden">
|
<div class="flex min-w-0 flex-1 flex-col lg:h-screen lg:overflow-hidden">
|
||||||
<header class="flex h-14 shrink-0 items-center justify-between border-b border-line bg-surface px-4">
|
<x-shell.topbar :crumbs="[__('admin.console'), $title ?? __('admin.nav.overview')]">
|
||||||
<button type="button" class="lg:hidden inline-flex min-h-11 min-w-11 items-center justify-center rounded text-muted hover:bg-surface-hover" @click="nav = true" aria-label="{{ __('dashboard.open_nav') }}">
|
|
||||||
<x-ui.icon name="menu" />
|
|
||||||
</button>
|
|
||||||
<p class="hidden font-mono text-xs text-faint sm:block">{{ __('admin.console') }}</p>
|
|
||||||
<div class="flex-1"></div>
|
|
||||||
|
|
||||||
@auth
|
@auth
|
||||||
<div class="relative" x-data="{ open: false }" @keydown.escape="open = false">
|
<div class="relative" x-data="{ open: false }" @keydown.escape="open = false">
|
||||||
<button type="button" class="flex items-center gap-2 rounded px-2 py-1.5 hover:bg-surface-hover" @click="open = !open" :aria-expanded="open" aria-haspopup="menu">
|
<button type="button" class="flex min-h-11 items-center gap-2.5 rounded px-2 hover:bg-surface-hover" @click="open = !open" :aria-expanded="open" aria-haspopup="menu">
|
||||||
<span class="flex size-8 items-center justify-center rounded-pill bg-accent-subtle text-sm font-semibold text-accent-text">{{ \Illuminate\Support\Str::upper(\Illuminate\Support\Str::substr(auth()->user()->name, 0, 1)) }}</span>
|
<span class="grid size-8 place-items-center rounded-pill bg-accent-subtle text-sm font-bold text-accent-text">{{ \Illuminate\Support\Str::upper(\Illuminate\Support\Str::substr(auth()->user()->name, 0, 1)) }}</span>
|
||||||
<span class="hidden text-sm text-body sm:block">{{ auth()->user()->name }}</span>
|
<span class="hidden text-sm font-medium text-ink sm:block">{{ auth()->user()->name }}</span>
|
||||||
<x-ui.icon name="chevron-down" class="size-4 text-muted" />
|
<x-ui.icon name="chevron-down" class="size-4 text-muted" />
|
||||||
</button>
|
</button>
|
||||||
<div x-show="open" x-cloak @click.outside="open = false" class="absolute right-0 mt-1 w-48 rounded-lg border border-line bg-surface py-1 shadow-md">
|
<div x-show="open" x-cloak @click.outside="open = false" class="absolute right-0 mt-1 w-48 overflow-hidden rounded-lg border border-line bg-surface py-1 shadow-md">
|
||||||
<form method="POST" action="{{ route('logout') }}">
|
<form method="POST" action="{{ route('logout') }}">
|
||||||
@csrf
|
@csrf
|
||||||
<button type="submit" class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-body hover:bg-surface-hover">
|
<button type="submit" class="flex min-h-11 w-full items-center gap-2.5 px-3.5 text-left text-sm text-body hover:bg-surface-hover">
|
||||||
<x-ui.icon name="log-out" class="size-4" />{{ __('dashboard.logout') }}
|
<x-ui.icon name="log-out" class="size-4" />{{ __('dashboard.logout') }}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@endauth
|
@endauth
|
||||||
</header>
|
</x-shell.topbar>
|
||||||
|
|
||||||
<main class="mx-auto w-full max-w-[1240px] flex-1 p-6 lg:overflow-y-auto lg:p-8">
|
<main class="mx-auto w-full max-w-[1320px] flex-1 px-4 py-6 lg:overflow-y-auto lg:px-7 lg:py-7">
|
||||||
{{ $slot }}
|
{{ $slot }}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -114,13 +78,12 @@
|
||||||
x-transition:leave-start="opacity-100 translate-y-0"
|
x-transition:leave-start="opacity-100 translate-y-0"
|
||||||
x-transition:leave-end="opacity-0 translate-y-12"
|
x-transition:leave-end="opacity-0 translate-y-12"
|
||||||
role="status" aria-live="polite"
|
role="status" aria-live="polite"
|
||||||
class="fixed bottom-6 left-1/2 z-[80] -translate-x-1/2 rounded-pill border border-line bg-surface-2 px-5 py-3 text-sm font-medium text-ink shadow-md"
|
class="fixed bottom-6 left-1/2 z-[80] -translate-x-1/2 rounded-pill bg-plate px-5 py-3 text-sm font-semibold text-white shadow-md"
|
||||||
>
|
>
|
||||||
<span x-text="msg"></span>
|
<span x-text="msg"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@livewire('wire-elements-modal')
|
@livewire('wire-elements-modal')
|
||||||
|
|
||||||
@livewireScripts
|
@livewireScripts
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,9 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="h-full">
|
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="h-full">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<x-shell.head :title="$title ?? config('app.name')" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
|
||||||
<link rel="apple-touch-icon" href="/favicon.svg">
|
|
||||||
<title>{{ $title ?? config('app.name') }}</title>
|
|
||||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
|
||||||
@livewireStyles
|
|
||||||
</head>
|
</head>
|
||||||
<body class="min-h-full bg-bg text-body antialiased" x-data="{ nav: false }">
|
<body class="min-h-full bg-bg text-body antialiased" x-data="{ nav: false }" :class="nav && 'overflow-hidden lg:overflow-auto'">
|
||||||
@php
|
@php
|
||||||
$maintenanceWindows = collect();
|
$maintenanceWindows = collect();
|
||||||
if (auth()->check()) {
|
if (auth()->check()) {
|
||||||
|
|
@ -21,12 +14,13 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
@if (auth()->check() && ! ($portalCustomer ?? null))
|
@if (auth()->check() && ! ($portalCustomer ?? null))
|
||||||
{{-- No customer behind this login (e.g. an operator account): say so, or
|
{{-- No customer behind this login (e.g. an operator account): say so, or
|
||||||
every customer action on these pages looks like a dead button. --}}
|
every customer action on these pages looks like a dead button. --}}
|
||||||
<div class="flex flex-wrap items-center justify-center gap-x-3 gap-y-1 border-b border-warning-border bg-warning-bg px-4 py-2 text-center text-sm text-ink">
|
<div class="flex flex-wrap items-center justify-center gap-x-3 gap-y-1 border-b border-warning-border bg-warning-bg px-4 py-2.5 text-center text-sm text-ink">
|
||||||
<x-ui.icon name="alert-triangle" class="size-4 shrink-0 text-warning" />
|
<x-ui.icon name="alert-triangle" class="size-4 text-warning" />
|
||||||
<span class="font-medium">{{ __('dashboard.no_customer_title') }}</span>
|
<span class="font-semibold">{{ __('dashboard.no_customer_title') }}</span>
|
||||||
<span class="text-muted">{{ __('dashboard.no_customer_hint') }}</span>
|
<span class="text-muted">{{ __('dashboard.no_customer_hint') }}</span>
|
||||||
@if (auth()->user()->can('console.view'))
|
@if (auth()->user()->can('console.view'))
|
||||||
<a href="{{ route('admin.customers') }}" class="font-semibold text-accent-text hover:underline">{{ __('dashboard.no_customer_cta') }}</a>
|
<a href="{{ route('admin.customers') }}" class="font-semibold text-accent-text hover:underline">{{ __('dashboard.no_customer_cta') }}</a>
|
||||||
|
|
@ -35,54 +29,34 @@
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<div class="flex min-h-screen lg:h-screen lg:overflow-hidden">
|
<div class="flex min-h-screen lg:h-screen lg:overflow-hidden">
|
||||||
{{-- Sidebar: fixed drawer on small screens, fixed full-height column on large (R7). --}}
|
<x-shell.nav
|
||||||
<aside
|
prefix="dashboard.nav"
|
||||||
class="fixed inset-y-0 left-0 z-40 flex w-60 flex-col overflow-y-auto border-r border-line bg-surface px-3 py-4 transition-transform lg:static lg:h-screen lg:translate-x-0"
|
:footer="__('dashboard.nav_footer')"
|
||||||
:class="nav ? 'translate-x-0' : '-translate-x-full'"
|
:groups="[
|
||||||
>
|
['label' => null, 'items' => [
|
||||||
<div class="flex items-center justify-between px-3 pb-4">
|
['dashboard', 'gauge', 'overview', null],
|
||||||
<span class="flex items-center gap-2">
|
['cloud', 'cloud', 'cloud', null],
|
||||||
<x-ui.logo class="size-7" />
|
['users', 'users', 'users', null],
|
||||||
<span class="font-mono text-lg font-semibold tracking-tight text-ink">CluPilot</span>
|
['backups', 'database', 'backups', null],
|
||||||
</span>
|
]],
|
||||||
<button type="button" class="lg:hidden text-muted" @click="nav = false" aria-label="{{ __('dashboard.close_nav') }}">
|
['label' => __('dashboard.nav_group.contract'), 'items' => [
|
||||||
<x-ui.icon name="chevron-down" class="size-5 rotate-90" />
|
['billing', 'box', 'billing', null],
|
||||||
</button>
|
['invoices', 'receipt', 'invoices', null],
|
||||||
</div>
|
['settings', 'settings', 'settings', null],
|
||||||
<nav class="space-y-1">
|
['support', 'life-buoy', 'support', null],
|
||||||
@foreach ([
|
]],
|
||||||
['dashboard', 'gauge', 'overview'],
|
]"
|
||||||
['cloud', 'cloud', 'cloud'],
|
/>
|
||||||
['users', 'users', 'users'],
|
|
||||||
['backups', 'database', 'backups'],
|
|
||||||
['invoices', 'receipt', 'invoices'],
|
|
||||||
['billing', 'box', 'billing'],
|
|
||||||
['settings', 'settings', 'settings'],
|
|
||||||
['support', 'life-buoy', 'support'],
|
|
||||||
] as [$route, $icon, $key])
|
|
||||||
<x-ui.nav-item :href="route($route)" :active="request()->routeIs($route)">
|
|
||||||
<x-slot:icon><x-ui.icon :name="$icon" /></x-slot:icon>
|
|
||||||
{{ __('dashboard.nav.'.$key) }}
|
|
||||||
</x-ui.nav-item>
|
|
||||||
@endforeach
|
|
||||||
</nav>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
{{-- Mobile backdrop --}}
|
|
||||||
<div x-show="nav" x-cloak @click="nav = false" class="fixed inset-0 z-30 bg-ink/20 lg:hidden"></div>
|
|
||||||
|
|
||||||
<div class="flex min-w-0 flex-1 flex-col lg:h-screen lg:overflow-hidden">
|
<div class="flex min-w-0 flex-1 flex-col lg:h-screen lg:overflow-hidden">
|
||||||
<header class="flex h-14 shrink-0 items-center justify-between border-b border-line bg-surface px-4">
|
<x-shell.topbar :crumbs="array_values(array_filter([$portalCustomer?->name, $title ?? __('dashboard.title')]))">
|
||||||
<button type="button" class="lg:hidden inline-flex min-h-11 min-w-11 items-center justify-center rounded text-muted hover:bg-surface-hover" @click="nav = true" aria-label="{{ __('dashboard.open_nav') }}">
|
{{-- Impersonation is a MODE, not a notice: it stays visible
|
||||||
<x-ui.icon name="menu" />
|
rather than hiding in a dropdown, because acting as someone
|
||||||
</button>
|
else without noticing is how mistakes happen. --}}
|
||||||
{{-- Impersonation is a MODE, not a notice: it stays visible rather
|
|
||||||
than hiding in a dropdown, because acting as someone else
|
|
||||||
without noticing is how mistakes happen. --}}
|
|
||||||
@if (session('impersonator_id'))
|
@if (session('impersonator_id'))
|
||||||
<div class="ml-2 flex min-w-0 items-center gap-2 rounded-pill border border-warning-border bg-warning-bg px-3 py-1">
|
<div class="flex min-w-0 items-center gap-2 rounded-pill border border-warning-border bg-warning-bg px-3 py-1">
|
||||||
<x-ui.icon name="users" class="size-4 shrink-0 text-warning" />
|
<x-ui.icon name="users" class="size-4 text-warning" />
|
||||||
<span class="truncate text-xs font-medium text-ink">{{ __('impersonate.banner', ['name' => auth()->user()->name]) }}</span>
|
<span class="truncate text-xs font-semibold text-ink">{{ __('impersonate.banner', ['name' => auth()->user()->name]) }}</span>
|
||||||
<form method="POST" action="{{ route('impersonate.leave') }}" class="inline shrink-0">
|
<form method="POST" action="{{ route('impersonate.leave') }}" class="inline shrink-0">
|
||||||
@csrf
|
@csrf
|
||||||
<button type="submit" class="inline-flex items-center gap-1 rounded-pill px-2 py-0.5 text-xs font-semibold text-warning hover:underline">
|
<button type="submit" class="inline-flex items-center gap-1 rounded-pill px-2 py-0.5 text-xs font-semibold text-warning hover:underline">
|
||||||
|
|
@ -93,28 +67,25 @@
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<div class="flex-1"></div>
|
|
||||||
|
|
||||||
{{-- Notices --}}
|
|
||||||
@if ($maintenanceWindows->isNotEmpty())
|
@if ($maintenanceWindows->isNotEmpty())
|
||||||
<div class="relative mr-1" x-data="{ open: false }" @keydown.escape="open = false">
|
<div class="relative" x-data="{ open: false }" @keydown.escape="open = false">
|
||||||
<button type="button" @click="open = !open" :aria-expanded="open" aria-haspopup="menu"
|
<button type="button" @click="open = !open" :aria-expanded="open" aria-haspopup="menu"
|
||||||
class="relative grid size-9 place-items-center rounded-md text-muted transition hover:bg-surface-hover hover:text-ink"
|
class="relative grid size-10 place-items-center rounded text-muted transition hover:bg-surface-hover hover:text-ink"
|
||||||
aria-label="{{ __('maintenance.notices') }}">
|
aria-label="{{ __('maintenance.notices') }}">
|
||||||
<x-ui.icon name="bell" class="size-5" />
|
<x-ui.icon name="bell" />
|
||||||
<span class="absolute right-1 top-1 grid size-4 place-items-center rounded-pill bg-warning text-[10px] font-bold text-white">{{ $maintenanceWindows->count() }}</span>
|
<span class="absolute right-1.5 top-1.5 grid size-4 place-items-center rounded-pill bg-accent text-[10px] font-bold text-white ring-2 ring-surface">{{ $maintenanceWindows->count() }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div x-show="open" x-cloak @click.outside="open = false"
|
<div x-show="open" x-cloak @click.outside="open = false"
|
||||||
class="absolute right-0 z-50 mt-1 w-80 overflow-hidden rounded-lg border border-line bg-surface shadow-md">
|
class="absolute right-0 z-50 mt-1 w-80 overflow-hidden rounded-lg border border-line bg-surface shadow-md">
|
||||||
<p class="border-b border-line px-4 py-2.5 text-xs font-semibold uppercase tracking-wide text-faint">{{ __('maintenance.notices') }}</p>
|
<p class="border-b border-line px-4 py-3 text-sm font-semibold text-ink">{{ __('maintenance.notices') }}</p>
|
||||||
<div class="max-h-80 divide-y divide-line overflow-y-auto">
|
<div class="max-h-80 divide-y divide-line overflow-y-auto">
|
||||||
@foreach ($maintenanceWindows as $mw)
|
@foreach ($maintenanceWindows as $mw)
|
||||||
@php $isActive = now()->betweenIncluded($mw->starts_at, $mw->ends_at); @endphp
|
@php $isActive = now()->betweenIncluded($mw->starts_at, $mw->ends_at); @endphp
|
||||||
<div class="flex gap-2.5 px-4 py-3">
|
<div class="flex gap-2.5 px-4 py-3">
|
||||||
<x-ui.icon name="alert-triangle" class="mt-0.5 size-4 shrink-0 {{ $isActive ? 'text-warning' : 'text-info' }}" />
|
<x-ui.icon name="alert-triangle" class="mt-0.5 size-4 {{ $isActive ? 'text-warning' : 'text-info' }}" />
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<p class="text-sm font-medium text-ink">{{ $mw->title }}</p>
|
<p class="text-sm font-semibold text-ink">{{ $mw->title }}</p>
|
||||||
<p class="mt-0.5 text-xs leading-relaxed text-muted">
|
<p class="mt-0.5 text-xs leading-relaxed text-muted">
|
||||||
{{ $isActive
|
{{ $isActive
|
||||||
? __('maintenance.banner_active', ['end' => $mw->ends_at->isoFormat('LT')])
|
? __('maintenance.banner_active', ['end' => $mw->ends_at->isoFormat('LT')])
|
||||||
|
|
@ -130,15 +101,15 @@
|
||||||
|
|
||||||
@auth
|
@auth
|
||||||
<div class="relative" x-data="{ open: false }" @keydown.escape="open = false">
|
<div class="relative" x-data="{ open: false }" @keydown.escape="open = false">
|
||||||
<button type="button" class="flex items-center gap-2 rounded px-2 py-1.5 hover:bg-surface-hover" @click="open = !open" :aria-expanded="open" aria-haspopup="menu">
|
<button type="button" class="flex min-h-11 items-center gap-2.5 rounded px-2 hover:bg-surface-hover" @click="open = !open" :aria-expanded="open" aria-haspopup="menu">
|
||||||
<span class="flex size-8 items-center justify-center rounded-pill bg-accent-subtle text-sm font-semibold text-accent-text">{{ \Illuminate\Support\Str::upper(\Illuminate\Support\Str::substr(auth()->user()->name, 0, 1)) }}</span>
|
<span class="grid size-8 place-items-center rounded-pill bg-accent-subtle text-sm font-bold text-accent-text">{{ \Illuminate\Support\Str::upper(\Illuminate\Support\Str::substr(auth()->user()->name, 0, 1)) }}</span>
|
||||||
<span class="hidden text-sm text-body sm:block">{{ auth()->user()->name }}</span>
|
<span class="hidden text-sm font-medium text-ink sm:block">{{ auth()->user()->name }}</span>
|
||||||
<x-ui.icon name="chevron-down" class="size-4 text-muted" />
|
<x-ui.icon name="chevron-down" class="size-4 text-muted" />
|
||||||
</button>
|
</button>
|
||||||
<div x-show="open" x-cloak @click.outside="open = false" class="absolute right-0 mt-1 w-48 rounded-lg border border-line bg-surface py-1 shadow-md">
|
<div x-show="open" x-cloak @click.outside="open = false" class="absolute right-0 mt-1 w-48 overflow-hidden rounded-lg border border-line bg-surface py-1 shadow-md">
|
||||||
<form method="POST" action="{{ route('logout') }}">
|
<form method="POST" action="{{ route('logout') }}">
|
||||||
@csrf
|
@csrf
|
||||||
<button type="submit" class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-body hover:bg-surface-hover">
|
<button type="submit" class="flex min-h-11 w-full items-center gap-2.5 px-3.5 text-left text-sm text-body hover:bg-surface-hover">
|
||||||
<x-ui.icon name="log-out" class="size-4" />
|
<x-ui.icon name="log-out" class="size-4" />
|
||||||
{{ __('dashboard.logout') }}
|
{{ __('dashboard.logout') }}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -146,9 +117,9 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@endauth
|
@endauth
|
||||||
</header>
|
</x-shell.topbar>
|
||||||
|
|
||||||
<main class="mx-auto w-full max-w-[1200px] flex-1 p-6 lg:overflow-y-auto lg:p-8">
|
<main class="mx-auto w-full max-w-[1240px] flex-1 px-4 py-6 lg:overflow-y-auto lg:px-8 lg:py-8">
|
||||||
{{ $slot }}
|
{{ $slot }}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -168,7 +139,7 @@
|
||||||
x-transition:leave-end="opacity-0 translate-y-12"
|
x-transition:leave-end="opacity-0 translate-y-12"
|
||||||
role="status"
|
role="status"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
class="fixed bottom-6 left-1/2 z-[80] -translate-x-1/2 rounded-pill bg-ink px-5 py-3 text-sm font-medium text-on-accent shadow-md"
|
class="fixed bottom-6 left-1/2 z-[80] -translate-x-1/2 rounded-pill bg-plate px-5 py-3 text-sm font-semibold text-white shadow-md"
|
||||||
>
|
>
|
||||||
<span x-text="msg"></span>
|
<span x-text="msg"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,10 @@
|
||||||
<div class="w-full max-w-sm animate-rise">
|
<div class="w-full max-w-sm animate-rise">
|
||||||
<div class="mb-9 flex items-center gap-2.5 lg:hidden">
|
<div class="mb-9 flex items-center gap-2.5 lg:hidden">
|
||||||
<x-ui.logo class="size-8" />
|
<x-ui.logo class="size-8" />
|
||||||
<span class="font-serif text-lg font-semibold tracking-tight text-ink">CluPilot</span>
|
<span class="text-lg font-bold tracking-tight text-ink">Clu<span class="text-accent-text">Pilot</span></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 class="font-serif text-[1.75rem] font-semibold leading-tight tracking-tight text-ink">{{ __('auth.login_title') }}</h1>
|
<h1 class="text-3xl font-bold leading-tight tracking-tight text-ink">{{ __('auth.login_title') }}</h1>
|
||||||
<p class="mt-2 text-sm text-muted">{{ __('auth.login_subtitle') }}</p>
|
<p class="mt-2 text-sm text-muted">{{ __('auth.login_subtitle') }}</p>
|
||||||
|
|
||||||
@if (session('status'))
|
@if (session('status'))
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,10 @@
|
||||||
<div class="w-full max-w-sm animate-rise">
|
<div class="w-full max-w-sm animate-rise">
|
||||||
<div class="mb-9 flex items-center gap-2.5 lg:hidden">
|
<div class="mb-9 flex items-center gap-2.5 lg:hidden">
|
||||||
<x-ui.logo class="size-8" />
|
<x-ui.logo class="size-8" />
|
||||||
<span class="font-serif text-lg font-semibold tracking-tight text-ink">CluPilot</span>
|
<span class="text-lg font-bold tracking-tight text-ink">Clu<span class="text-accent-text">Pilot</span></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 class="font-serif text-[1.75rem] font-semibold leading-tight tracking-tight text-ink">{{ __('auth.register_title') }}</h1>
|
<h1 class="text-3xl font-bold leading-tight tracking-tight text-ink">{{ __('auth.register_title') }}</h1>
|
||||||
<p class="mt-2 text-sm text-muted">{{ __('auth.register_subtitle') }}</p>
|
<p class="mt-2 text-sm text-muted">{{ __('auth.register_subtitle') }}</p>
|
||||||
|
|
||||||
<form method="POST" action="{{ route('register.store') }}" class="mt-7 space-y-5">
|
<form method="POST" action="{{ route('register.store') }}" class="mt-7 space-y-5">
|
||||||
|
|
|
||||||
|
|
@ -1,272 +1,262 @@
|
||||||
<div class="space-y-4" x-data="{ msgs: @js(['addon' => __('dashboard.modules.add_toast'), 'restore' => __('dashboard.backups.restore_toast')]) }">
|
@php
|
||||||
{{-- Header --}}
|
use App\Support\Bytes;
|
||||||
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 animate-rise">
|
use Illuminate\Support\Number;
|
||||||
<h1 class="text-2xl font-semibold tracking-tight text-ink">{{ __('dashboard.greeting', ['name' => auth()->user()->name]) }}</h1>
|
|
||||||
<span class="ml-auto inline-flex items-center gap-2 rounded-pill bg-success-bg px-3.5 py-1.5 text-xs font-semibold text-success">
|
$locale = app()->getLocale();
|
||||||
<span class="size-2 rounded-pill bg-success-bright" aria-hidden="true"></span>{{ __('dashboard.system_ok') }}
|
$money = fn (int $cents, string $currency) => Number::currency($cents / 100, in: $currency, locale: $locale);
|
||||||
|
@endphp
|
||||||
|
<div class="space-y-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="flex flex-wrap items-end gap-4 animate-rise">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<span class="inline-flex items-center gap-2 rounded-pill bg-accent-subtle px-3 py-1.5 text-xs font-semibold text-accent-text">
|
||||||
|
{{ __('dashboard.sheet', ['when' => $asOf->locale($locale)->isoFormat('LL, LT')]) }}
|
||||||
</span>
|
</span>
|
||||||
<p class="w-full text-sm text-muted">{{ __('dashboard.as_of', ['time' => '08:42']) }}</p>
|
<h1 class="mt-3 text-2xl font-bold tracking-tight text-ink sm:text-3xl">
|
||||||
|
{{ $instance !== null ? __('dashboard.title_running') : __('dashboard.title_pending') }}
|
||||||
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if ($instance !== null)
|
||||||
|
<div class="flex flex-wrap items-center gap-2.5">
|
||||||
|
<span @class([
|
||||||
|
'inline-flex items-center gap-2 rounded-pill border px-3.5 py-1.5 text-xs font-semibold',
|
||||||
|
'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" :href="'https://'.$domain" target="_blank" rel="noopener">
|
||||||
|
<x-ui.icon name="external-link" class="size-4" />{{ __('dashboard.cloud.open') }}
|
||||||
|
</x-ui.button>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</header>
|
||||||
|
|
||||||
{{-- Live provisioning progress (only while a run is in flight) --}}
|
{{-- Live provisioning progress (only while a run is in flight) --}}
|
||||||
<livewire:customer-provisioning />
|
<livewire:customer-provisioning />
|
||||||
|
|
||||||
{{-- Traffic: the one allowance that throttles the service when it runs out,
|
@if ($instance === null)
|
||||||
so it gets a full-width band rather than a tile in the KPI row. --}}
|
{{-- Said plainly rather than papered over with an empty record: a page
|
||||||
@if ($traffic)
|
of dashes reads as broken, and "we are still setting it up" and
|
||||||
@php
|
"you have not ordered yet" are different things. --}}
|
||||||
$state = $traffic->state();
|
<section class="rounded-lg border border-line bg-surface p-8 shadow-xs animate-rise">
|
||||||
$bar = ['ok' => 'bg-accent', 'warn' => 'bg-warning', 'critical' => 'bg-warning', 'exhausted' => 'bg-danger'][$state];
|
<h2 class="text-lg font-semibold text-ink">{{ __('dashboard.no_instance_label') }}</h2>
|
||||||
$frame = [
|
<p class="mt-2 max-w-prose text-md text-muted">{{ __('dashboard.no_instance_body') }}</p>
|
||||||
'ok' => 'border-line',
|
<div class="mt-6 flex flex-wrap gap-2.5">
|
||||||
'warn' => 'border-warning-border bg-warning-bg',
|
<x-ui.button variant="ink" :href="route('billing')" wire:navigate>{{ __('dashboard.no_instance_cta') }}</x-ui.button>
|
||||||
'critical' => 'border-warning-border bg-warning-bg',
|
<x-ui.button variant="secondary" :href="route('support')" wire:navigate>{{ __('dashboard.nav.support') }}</x-ui.button>
|
||||||
'exhausted' => 'border-danger-border bg-danger-bg',
|
|
||||||
][$state];
|
|
||||||
@endphp
|
|
||||||
<div class="rounded-xl border {{ $frame }} bg-surface p-5 shadow-xs animate-rise">
|
|
||||||
<div class="flex flex-wrap items-baseline justify-between gap-2">
|
|
||||||
<div class="flex items-baseline gap-2">
|
|
||||||
<h2 class="text-sm font-semibold text-ink">{{ __('dashboard.traffic.title') }}</h2>
|
|
||||||
<span class="text-xs text-muted">{{ __('dashboard.traffic.resets', ['days' => $traffic->daysUntilReset()]) }}</span>
|
|
||||||
</div>
|
|
||||||
<p class="font-mono text-sm text-body">
|
|
||||||
{{ \App\Support\Bytes::human($traffic->usedBytes) }}
|
|
||||||
<span class="text-faint">/ {{ \App\Support\Bytes::human($traffic->quotaBytes) }}</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
@else
|
||||||
|
{{-- ── What the customer has ─────────────────────────────────────── --}}
|
||||||
|
<section class="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
|
@if ($instance->quota_gb)
|
||||||
|
<article class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:60ms]">
|
||||||
|
<p class="font-mono text-[11.5px] uppercase tracking-[0.07em] text-muted">{{ __('dashboard.master.storage') }}</p>
|
||||||
|
<p class="mt-2 font-mono text-2xl font-semibold tracking-tight text-ink">{{ __('dashboard.master.gb', ['n' => $instance->quota_gb]) }}</p>
|
||||||
|
{{-- Consumption is not metered yet. An invented "used"
|
||||||
|
figure on the sheet a customer forwards to their
|
||||||
|
auditor is the one thing this page must never do. --}}
|
||||||
|
<p class="mt-1.5 text-xs text-muted">{{ __('dashboard.master.storage_note') }}</p>
|
||||||
|
</article>
|
||||||
|
@endif
|
||||||
|
|
||||||
<div class="mt-3 h-2 overflow-hidden rounded-pill bg-surface-2">
|
<article class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:100ms]">
|
||||||
{{-- R4: width is data, not decoration — the only inline style allowed. --}}
|
<p class="font-mono text-[11.5px] uppercase tracking-[0.07em] text-muted">{{ __('dashboard.master.users') }}</p>
|
||||||
<div class="h-full rounded-pill {{ $bar }} transition-all duration-700"
|
<p class="mt-2 font-mono text-2xl font-semibold tracking-tight text-ink">
|
||||||
|
{{ $seats['total'] !== null
|
||||||
|
? __('dashboard.master.of_total', ['used' => $seats['used'], 'total' => $seats['total']])
|
||||||
|
: $seats['used'] }}
|
||||||
|
</p>
|
||||||
|
@if ($seats['total'])
|
||||||
|
<div class="mt-3 h-1.5 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
|
||||||
|
</article>
|
||||||
|
|
||||||
|
@if ($traffic)
|
||||||
|
@php $state = $traffic->state(); @endphp
|
||||||
|
<article class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:140ms]">
|
||||||
|
<p class="font-mono text-[11.5px] uppercase tracking-[0.07em] text-muted">{{ __('dashboard.traffic.title') }}</p>
|
||||||
|
<p class="mt-2 font-mono text-2xl font-semibold tracking-tight text-ink">{{ Bytes::human($traffic->usedBytes) }}</p>
|
||||||
|
<div class="mt-3 h-1.5 overflow-hidden rounded-pill bg-surface-hover">
|
||||||
|
<div @class([
|
||||||
|
'h-full rounded-pill',
|
||||||
|
'bg-accent' => $state === 'ok',
|
||||||
|
'bg-warning' => $state === 'warn' || $state === 'critical',
|
||||||
|
'bg-danger' => $state === 'exhausted',
|
||||||
|
])
|
||||||
style="width: {{ min(100, $traffic->percent()) }}%"></div>
|
style="width: {{ min(100, $traffic->percent()) }}%"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="mt-2 font-mono text-xs text-muted">{{ __('dashboard.traffic.of', ['total' => Bytes::human($traffic->quotaBytes)]) }}</p>
|
||||||
<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()]) }}
|
|
||||||
@endif
|
|
||||||
</p>
|
|
||||||
@if ($state !== 'ok')
|
@if ($state !== 'ok')
|
||||||
{{-- The offer belongs next to the warning: someone reading that
|
{{-- The offer belongs next to the warning: someone
|
||||||
they are nearly out should not have to go looking. --}}
|
reading that they are nearly out should not have to
|
||||||
<a href="{{ route('billing') }}" wire:navigate
|
go looking for the way to fix it. --}}
|
||||||
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">
|
<a href="{{ route('billing') }}" wire:navigate class="mt-2.5 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') }}
|
<x-ui.icon name="plus" class="size-3.5" />{{ __('dashboard.traffic.buy') }}
|
||||||
</a>
|
</a>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</article>
|
||||||
</div>
|
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
{{-- KPI row --}}
|
@if ($location)
|
||||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
<article class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:180ms]">
|
||||||
{{-- Storage ring --}}
|
<p class="font-mono text-[11.5px] uppercase tracking-[0.07em] text-muted">{{ __('dashboard.master.location') }}</p>
|
||||||
<div class="flex items-center gap-4 rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:60ms]">
|
<p class="mt-2 text-lg font-semibold tracking-tight text-ink">{{ $location['name'] }}</p>
|
||||||
<div class="relative size-24 shrink-0" wire:ignore>
|
@if ($location['note'])
|
||||||
<x-ui.chart :config="$storageChart" class="size-24" :label="__('dashboard.kpi.storage')" />
|
<p class="mt-1.5 text-xs text-muted">{{ $location['note'] }}</p>
|
||||||
<div class="pointer-events-none absolute inset-0 grid place-items-center text-center">
|
@endif
|
||||||
<div>
|
</article>
|
||||||
<span class="font-mono text-lg font-semibold text-ink">{{ $storagePercent }}%</span>
|
@endif
|
||||||
<span class="block text-[0.62rem] text-muted">{{ __('dashboard.occupied') }}</span>
|
</section>
|
||||||
</div>
|
|
||||||
</div>
|
<section class="grid gap-4 lg:grid-cols-[minmax(0,1.5fr)_minmax(0,1fr)] lg:items-start">
|
||||||
</div>
|
{{-- ── The proof register ────────────────────────────────────── --}}
|
||||||
<div>
|
<article class="overflow-hidden rounded-lg border border-line bg-surface shadow-xs animate-rise [animation-delay:200ms]">
|
||||||
<p class="text-xs font-semibold uppercase tracking-wide text-faint">{{ __('dashboard.kpi.storage') }}</p>
|
<div class="flex items-center justify-between gap-4 border-b border-line px-5 py-4">
|
||||||
<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>
|
<h2 class="text-md font-semibold text-ink">{{ __('dashboard.proofs.label') }}</h2>
|
||||||
<p class="mt-0.5 text-xs text-muted">{{ __('dashboard.storage_week') }}</p>
|
<a href="{{ route('backups') }}" wire:navigate class="text-sm font-semibold text-accent-text hover:underline">{{ __('dashboard.proofs.all') }}</a>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- Users --}}
|
@if ($proofs === [])
|
||||||
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:120ms]">
|
<p class="px-5 py-8 text-sm text-muted">{{ __('dashboard.proofs.empty') }}</p>
|
||||||
<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
|
@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>
|
<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
|
@endif
|
||||||
</li>
|
</article>
|
||||||
@endforeach
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{{-- Activity feed --}}
|
<div class="space-y-4">
|
||||||
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:480ms]">
|
{{-- ── The contract ───────────────────────────────────────── --}}
|
||||||
<div class="flex items-center">
|
<article class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:240ms]">
|
||||||
<h2 class="font-semibold text-ink">{{ __('dashboard.activity') }}</h2>
|
<h2 class="text-md font-semibold text-ink">{{ __('dashboard.master.label') }}</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>
|
<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>
|
</div>
|
||||||
<ul class="mt-2 divide-y divide-line">
|
@if ($contract?->performance)
|
||||||
@foreach ($activity as $a)
|
<div class="flex items-baseline justify-between gap-4 border-b border-line py-2.5">
|
||||||
<li class="flex items-start gap-3 py-2.5 text-sm">
|
<dt class="text-sm text-muted">{{ __('dashboard.master.operation') }}</dt>
|
||||||
<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>
|
<dd class="text-right text-sm font-semibold text-ink">{{ __('billing.perf.'.$contract->performance) }}</dd>
|
||||||
<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>
|
</div>
|
||||||
</li>
|
@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>
|
||||||
|
</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
|
@endforeach
|
||||||
</ul>
|
</section>
|
||||||
</div>
|
@endif
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -8,27 +8,24 @@
|
||||||
<meta name="robots" content="noindex">
|
<meta name="robots" content="noindex">
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||||
|
|
||||||
<link rel="preload" as="font" type="font/woff2" href="/fonts/ibm-plex-serif-latin-600-normal.woff2" crossorigin>
|
|
||||||
@verbatim
|
@verbatim
|
||||||
<style>
|
<style>
|
||||||
@font-face{font-family:"Plex Serif";src:url("/fonts/ibm-plex-serif-latin-600-normal.woff2") format("woff2");font-weight:600;font-style:normal;font-display:swap}
|
|
||||||
@font-face{font-family:"Plex Sans";src:url("/fonts/ibm-plex-sans-latin-400-normal.woff2") format("woff2");font-weight:400;font-style:normal;font-display:swap}
|
@font-face{font-family:"Plex Sans";src:url("/fonts/ibm-plex-sans-latin-400-normal.woff2") format("woff2");font-weight:400;font-style:normal;font-display:swap}
|
||||||
@font-face{font-family:"Plex Sans";src:url("/fonts/ibm-plex-sans-latin-500-normal.woff2") format("woff2");font-weight:500;font-style:normal;font-display:swap}
|
@font-face{font-family:"Plex Sans";src:url("/fonts/ibm-plex-sans-latin-500-normal.woff2") format("woff2");font-weight:500;font-style:normal;font-display:swap}
|
||||||
@font-face{font-family:"Plex Mono";src:url("/fonts/ibm-plex-mono-latin-400-normal.woff2") format("woff2");font-weight:400;font-style:normal;font-display:swap}
|
@font-face{font-family:"Plex Mono";src:url("/fonts/ibm-plex-mono-latin-400-normal.woff2") format("woff2");font-weight:400;font-style:normal;font-display:swap}
|
||||||
|
|
||||||
:root{
|
:root{
|
||||||
--paper:#f7f5f1; --paper-2:#efebe3; --card:#fffefb;
|
--paper:#f6f6f8; --paper-2:#fafafb; --card:#ffffff;
|
||||||
--ink:#17140f; --ink-soft:#413a31; --muted:#7b7367; --faint:#a39a8c;
|
--ink:#17171c; --ink-soft:#43434e; --muted:#6e6e7a; --faint:#a39a8c;
|
||||||
--rule:#e3ded3; --rule-mid:#cbc3b4;
|
--rule:#e9e9ee; --rule-mid:#dcdce4;
|
||||||
--accent:#f97316; --accent-text:#b8500a;
|
--accent:#f97316; --accent-text:#b8500a;
|
||||||
--ok:#2f7d4f; --ok-bg:#eef7f1;
|
--ok:#2f7d4f; --ok-bg:#eef7f1;
|
||||||
--warn:#a2600c; --warn-bg:#fdf5e7;
|
--warn:#a2600c; --warn-bg:#fdf5e7;
|
||||||
--bad:#a4372a; --bad-bg:#fdf1ef;
|
--bad:#a4372a; --bad-bg:#fdf1ef;
|
||||||
--unknown:#6c6459; --unknown-bg:#f1eee8;
|
--unknown:#6c6459; --unknown-bg:#f1eee8;
|
||||||
--serif:"Plex Serif",Georgia,serif;
|
|
||||||
--sans:"Plex Sans",-apple-system,BlinkMacSystemFont,sans-serif;
|
--sans:"Plex Sans",-apple-system,BlinkMacSystemFont,sans-serif;
|
||||||
--mono:"Plex Mono",ui-monospace,monospace;
|
--mono:"Plex Mono",ui-monospace,monospace;
|
||||||
--r:3px;
|
--r:16px;
|
||||||
}
|
}
|
||||||
*{margin:0;padding:0;box-sizing:border-box}
|
*{margin:0;padding:0;box-sizing:border-box}
|
||||||
body{font-family:var(--sans);background:var(--paper);color:var(--ink-soft);font-size:16px;line-height:1.6;-webkit-font-smoothing:antialiased}
|
body{font-family:var(--sans);background:var(--paper);color:var(--ink-soft);font-size:16px;line-height:1.6;-webkit-font-smoothing:antialiased}
|
||||||
|
|
@ -37,9 +34,9 @@
|
||||||
|
|
||||||
.wrap{max-width:760px;margin:0 auto;padding:0 clamp(20px,5vw,32px)}
|
.wrap{max-width:760px;margin:0 auto;padding:0 clamp(20px,5vw,32px)}
|
||||||
header{padding:clamp(48px,8vw,80px) 0 0}
|
header{padding:clamp(48px,8vw,80px) 0 0}
|
||||||
.mark{font-family:var(--serif);font-weight:600;font-size:1.12rem;letter-spacing:-.02em;color:var(--ink)}
|
.mark{font-weight:700;font-size:1.12rem;letter-spacing:-.02em;color:var(--ink)}
|
||||||
.mark i{font-style:normal;color:var(--accent-text)}
|
.mark i{font-style:normal;color:var(--accent-text)}
|
||||||
h1{font-family:var(--serif);font-weight:600;font-size:clamp(2rem,4.5vw,2.8rem);letter-spacing:-.028em;line-height:1.1;color:var(--ink);margin-top:28px}
|
h1{font-weight:700;font-size:clamp(2rem,4.5vw,2.8rem);letter-spacing:-.028em;line-height:1.1;color:var(--ink);margin-top:28px}
|
||||||
|
|
||||||
.overall{
|
.overall{
|
||||||
display:flex;align-items:center;gap:13px;margin-top:22px;padding:16px 20px;
|
display:flex;align-items:center;gap:13px;margin-top:22px;padding:16px 20px;
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,14 @@ it('forbids non-admin users from the admin console', function (string $route) {
|
||||||
it('renders the admin console for admins', function (string $route) {
|
it('renders the admin console for admins', function (string $route) {
|
||||||
$admin = User::factory()->create(['is_admin' => true]);
|
$admin = User::factory()->create(['is_admin' => true]);
|
||||||
|
|
||||||
|
// Asserted on the console badge and the sign-out control rather than on the
|
||||||
|
// wordmark: the wordmark is split across an element so its second half can
|
||||||
|
// carry the accent, so "CluPilot" is not a contiguous string in the markup.
|
||||||
|
// These two say what the assertion actually meant — the console shell, with
|
||||||
|
// its navigation, rendered for this route.
|
||||||
$this->actingAs($admin)->get(route($route))
|
$this->actingAs($admin)->get(route($route))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('CluPilot');
|
->assertSee(__('admin.badge'))
|
||||||
|
->assertSee(__('admin.nav.overview'))
|
||||||
|
->assertSee(__('dashboard.logout'));
|
||||||
})->with($adminRoutes);
|
})->with($adminRoutes);
|
||||||
|
|
|
||||||
|
|
@ -14,3 +14,37 @@ it('shows the dashboard to authenticated users', function () {
|
||||||
->assertSee('CluPilot')
|
->assertSee('CluPilot')
|
||||||
->assertSee(__('dashboard.title'));
|
->assertSee(__('dashboard.title'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows no next invoice once the customer has given notice', function () {
|
||||||
|
// Cancelling leaves the subscription active until the term runs out, so
|
||||||
|
// `current_period_end` becomes the day service ENDS rather than the day the
|
||||||
|
// next charge falls. Billing a renewal that will never happen, on the sheet
|
||||||
|
// a customer checks their invoices against, is the worst line this page
|
||||||
|
// could carry.
|
||||||
|
$user = App\Models\User::factory()->create();
|
||||||
|
$customer = App\Models\Customer::factory()->create(['user_id' => $user->id]);
|
||||||
|
|
||||||
|
$instance = App\Models\Instance::factory()->create([
|
||||||
|
'customer_id' => $customer->id,
|
||||||
|
'status' => 'cancellation_scheduled',
|
||||||
|
'cancel_requested_at' => now(),
|
||||||
|
'service_ends_at' => now()->addMonth(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
App\Models\Subscription::factory()->create([
|
||||||
|
'customer_id' => $customer->id,
|
||||||
|
'instance_id' => $instance->id,
|
||||||
|
'status' => 'active',
|
||||||
|
'price_cents' => 17900,
|
||||||
|
'currency' => 'EUR',
|
||||||
|
'term' => 'monthly',
|
||||||
|
'current_period_end' => now()->addMonth(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(App\Livewire\Dashboard::class)
|
||||||
|
->assertViewHas('nextInvoice', null)
|
||||||
|
// The price is still on the master record — they pay it until the end.
|
||||||
|
->assertViewHas('planPrice', fn (?array $p) => $p !== null && $p['cents'] === 17900)
|
||||||
|
->assertDontSee(__('dashboard.invoice.label'));
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue