feat(portal): service framing + gradient line charts + billing button fix

- plans gain seats + performance class; customer views show storage/seats/
  performance/features, no raw vCPU/RAM (admin keeps specs)
- billing: scoped wire:target + wire:key so one purchase no longer spins all
- chart island: line-colour->transparent gradient for filled line datasets;
  MRR / backups / invoices bar charts converted to gradient line

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/portal-design
nexxo 2026-07-25 14:18:08 +02:00
parent a3423a2cee
commit e3ab3cb501
13 changed files with 154 additions and 31 deletions

View File

@ -42,17 +42,21 @@ class Overview extends Component
],
],
'revenueChart' => [
'type' => 'bar',
'type' => 'line',
'data' => [
'labels' => $months,
'datasets' => [[
'label' => 'MRR',
'data' => [4100, 4460, 4900, 5300, 5600, 6050, 6300, 6700, 7050, 7300, 7620, 7842],
'backgroundColor' => 'token:accent/0.85', 'borderRadius' => 3,
'borderColor' => 'token:accent',
'fill' => true,
'tension' => 0.35,
'pointRadius' => 0,
'borderWidth' => 2,
]],
],
'options' => [
'scales' => ['x' => ['grid' => ['display' => false]], 'y' => ['grid' => ['color' => 'token:border']]],
'scales' => ['x' => ['grid' => ['display' => false]], 'y' => ['beginAtZero' => true, 'grid' => ['color' => 'token:border']]],
'plugins' => ['legend' => ['display' => false]],
],
],

View File

@ -32,14 +32,17 @@ class Backups extends Component
'rows' => $rows,
'lastTest' => $date('2026-07-01', 'LL'),
'sizeChart' => [
'type' => 'bar',
'type' => 'line',
'data' => [
'labels' => array_map(fn ($i) => $date(Carbon::parse('2026-07-24')->subDays(13 - $i)->toDateString(), 'D.'), range(0, 13)),
'datasets' => [[
'label' => 'GB',
'data' => $sizes,
'backgroundColor' => 'token:accent/0.85',
'borderRadius' => 4,
'borderColor' => 'token:accent',
'fill' => true,
'tension' => 0.35,
'pointRadius' => 0,
'borderWidth' => 2,
]],
],
'options' => [

View File

@ -91,6 +91,7 @@ class Billing extends Component
'current' => $plans[$currentKey] ?? [],
'instance' => $instance,
'plans' => $plans,
'features' => (array) config('provisioning.plan_features'),
'upgrades' => $upgrades,
'storage' => (array) config('provisioning.storage_addon'),
'addons' => (array) config('provisioning.addons'),

View File

@ -25,8 +25,8 @@ class Cloud extends Component
'php' => 'PHP 8.3 · MariaDB 11.4',
'storageUsed' => 235,
'storageQuota' => 500,
'ram' => '8 GB',
'vcpu' => '4 vCPU',
'seats' => __('billing.seats_count', ['count' => 25]),
'performance' => __('billing.perf.enhanced'),
],
'storageChart' => [
'type' => 'line',

View File

@ -32,14 +32,17 @@ class Invoices extends Component
'nextCharge' => $date('2026-08-01'),
'nextAmount' => $eur(198),
'spendChart' => [
'type' => 'bar',
'type' => 'line',
'data' => [
'labels' => $months,
'datasets' => [[
'label' => 'EUR',
'data' => [179, 179, 198, 198, 198],
'backgroundColor' => 'token:accent/0.85',
'borderRadius' => 4,
'borderColor' => 'token:accent',
'fill' => true,
'tension' => 0.35,
'pointRadius' => 0,
'borderWidth' => 2,
]],
],
'options' => [

View File

@ -47,11 +47,14 @@ return [
],
// Plan → resource sizing + monthly price + Nextcloud blueprint template VMID.
// quota_gb/disk_gb/ram_mb/cores drive infrastructure placement and are
// ADMIN-ONLY. Customers compare seats, storage, performance class and
// features — never raw vCPU/RAM (see docs/specs/2026-07-25-portal-d-*).
'plans' => [
'start' => ['quota_gb' => 100, 'disk_gb' => 120, 'ram_mb' => 4096, 'cores' => 2, 'price_cents' => 4900, 'template_vmid' => 9000],
'team' => ['quota_gb' => 500, 'disk_gb' => 540, 'ram_mb' => 8192, 'cores' => 4, 'price_cents' => 17900, 'template_vmid' => 9000],
'business' => ['quota_gb' => 1000, 'disk_gb' => 1050, 'ram_mb' => 16384, 'cores' => 8, 'price_cents' => 39900, 'template_vmid' => 9000],
'enterprise' => ['quota_gb' => 2000, 'disk_gb' => 2100, 'ram_mb' => 32768, 'cores' => 16, 'price_cents' => 79900, 'template_vmid' => 9000],
'start' => ['quota_gb' => 100, 'disk_gb' => 120, 'ram_mb' => 4096, 'cores' => 2, 'seats' => 5, 'performance' => 'standard', 'price_cents' => 4900, 'template_vmid' => 9000],
'team' => ['quota_gb' => 500, 'disk_gb' => 540, 'ram_mb' => 8192, 'cores' => 4, 'seats' => 25, 'performance' => 'enhanced', 'price_cents' => 17900, 'template_vmid' => 9000],
'business' => ['quota_gb' => 1000, 'disk_gb' => 1050, 'ram_mb' => 16384, 'cores' => 8, 'seats' => 100, 'performance' => 'high', 'price_cents' => 39900, 'template_vmid' => 9000],
'enterprise' => ['quota_gb' => 2000, 'disk_gb' => 2100, 'ram_mb' => 32768, 'cores' => 16, 'seats' => 500, 'performance' => 'dedicated', 'price_cents' => 79900, 'template_vmid' => 9000],
],
// Extra storage add-on (per unit) and the add-on catalogue (labels in lang/*/billing.php).
@ -62,6 +65,15 @@ return [
'collabora_pro' => ['price_cents' => 1900],
],
// Customer-facing feature bullets per plan (labels in lang/*/billing.php →
// billing.feature.<key>). Cumulative tiers: each plan lists its own bullets.
'plan_features' => [
'start' => ['managed_updates', 'daily_backups', 'monitoring', 'subdomain'],
'team' => ['managed_updates', 'daily_backups', 'monitoring', 'subdomain', 'office', 'branding', 'priority_support'],
'business' => ['managed_updates', 'daily_backups', 'monitoring', 'custom_domain', 'office', 'branding', 'priority_support', 'extended_retention', 'audit_log'],
'enterprise' => ['managed_updates', 'daily_backups', 'monitoring', 'custom_domain', 'office', 'branding', 'premium_sla', 'extended_retention', 'audit_log', 'onboarding'],
],
'dns' => [
'provider' => 'hetzner',
'token' => env('HETZNER_DNS_TOKEN', ''),

View File

@ -7,10 +7,33 @@ return [
'per_month' => 'pro Monat',
'month_short' => 'Mon.',
'storage' => 'Speicher',
'cores' => 'vCPU',
'ram' => 'RAM',
'seats' => 'Benutzer',
'seats_count' => 'bis zu :count Benutzer',
'performance' => 'Leistung',
'status' => 'Status',
'status_active' => 'Aktiv',
'perf' => [
'standard' => 'Standard',
'enhanced' => 'Erweitert',
'high' => 'Hoch',
'dedicated' => 'Dediziert',
],
'feature' => [
'managed_updates' => 'Verwaltete Updates',
'daily_backups' => 'Tägliche Backups',
'monitoring' => 'Monitoring rund um die Uhr',
'subdomain' => 'Eigene CluPilot-Subdomain',
'custom_domain' => 'Eigene Domain',
'office' => 'Office-Integration (Collabora)',
'branding' => 'Eigenes Branding',
'priority_support' => 'Priority-Support',
'premium_sla' => 'Premium-SLA',
'extended_retention' => 'Längere Backup-Aufbewahrung',
'audit_log' => 'Audit-Log',
'onboarding' => 'Onboarding-Unterstützung',
],
'pending_note' => ':count Kauf vorgemerkt — wird nach Zahlung aktiviert.',
'upgrade_title' => 'Upgrade',
'upgrade_cta' => 'Upgraden',

View File

@ -13,7 +13,8 @@ return [
'version' => 'Version',
'current' => 'aktuell',
'runtime' => 'Laufzeit',
'resources' => 'Ressourcen',
'seats' => 'Benutzer',
'performance' => 'Leistung',
'storage' => 'Speicher',
'storage_growth' => 'Speicherverlauf',
'storage_growth_sub' => 'Belegter Speicher der letzten 12 Wochen.',

View File

@ -7,10 +7,33 @@ return [
'per_month' => 'per month',
'month_short' => 'mo',
'storage' => 'Storage',
'cores' => 'vCPU',
'ram' => 'RAM',
'seats' => 'Users',
'seats_count' => 'up to :count users',
'performance' => 'Performance',
'status' => 'Status',
'status_active' => 'Active',
'perf' => [
'standard' => 'Standard',
'enhanced' => 'Enhanced',
'high' => 'High',
'dedicated' => 'Dedicated',
],
'feature' => [
'managed_updates' => 'Managed updates',
'daily_backups' => 'Daily backups',
'monitoring' => 'Round-the-clock monitoring',
'subdomain' => 'Own CluPilot subdomain',
'custom_domain' => 'Custom domain',
'office' => 'Office integration (Collabora)',
'branding' => 'Custom branding',
'priority_support' => 'Priority support',
'premium_sla' => 'Premium SLA',
'extended_retention' => 'Longer backup retention',
'audit_log' => 'Audit log',
'onboarding' => 'Onboarding assistance',
],
'pending_note' => ':count purchase pending — activated after payment.',
'upgrade_title' => 'Upgrade',
'upgrade_cta' => 'Upgrade',

View File

@ -13,7 +13,8 @@ return [
'version' => 'Version',
'current' => 'up to date',
'runtime' => 'Runtime',
'resources' => 'Resources',
'seats' => 'Users',
'performance' => 'Performance',
'storage' => 'Storage',
'storage_growth' => 'Storage over time',
'storage_growth_sub' => 'Used storage over the last 12 weeks.',

View File

@ -33,6 +33,45 @@ function resolveTokens(node, css) {
return node;
}
// Turn any resolved colour (#hex, rgb(), rgba()) into an rgba() with the given alpha.
function toRgba(color, alpha) {
if (typeof color !== 'string') return color;
if (color.startsWith('#')) {
let hex = color.slice(1);
if (hex.length === 3) hex = hex.split('').map((c) => c + c).join('');
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
const m = color.match(/rgba?\(([^)]+)\)/);
if (m) {
const [r, g, b] = m[1].split(',').map((n) => n.trim());
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
return color;
}
// Every filled line dataset gets a vertical gradient from its line colour to
// transparent — the "colour fades to nothing under the line" look (R3/token-driven).
function applyLineGradients(cfg) {
const isLine = (ds) => (ds.type || cfg.type) === 'line';
for (const ds of cfg.data?.datasets ?? []) {
if (!isLine(ds) || !ds.fill) continue;
const base = Array.isArray(ds.borderColor) ? ds.borderColor[0] : ds.borderColor;
if (!base) continue;
ds.backgroundColor = (ctx) => {
const { chart } = ctx;
const area = chart.chartArea;
if (!area) return toRgba(base, 0.18); // first paint before layout
const g = chart.ctx.createLinearGradient(0, area.top, 0, area.bottom);
g.addColorStop(0, toRgba(base, 0.32));
g.addColorStop(1, toRgba(base, 0));
return g;
};
}
}
document.addEventListener('alpine:init', () => {
// ── Segmented OTP input ──────────────────────────────────────────────
window.Alpine.data('otpInput', ({ length = 6 } = {}) => ({
@ -82,6 +121,7 @@ document.addEventListener('alpine:init', () => {
Chart.defaults.borderColor = css.getPropertyValue('--border').trim() || '#e4e7ec';
const cfg = resolveTokens(config, css);
applyLineGradients(cfg);
cfg.options = {
responsive: true,
maintainAspectRatio: false,

View File

@ -22,8 +22,8 @@
<div class="grid grid-cols-2 gap-px bg-line sm:grid-cols-4">
@foreach ([
['billing.storage', ($current['quota_gb'] ?? 0).' GB'],
['billing.cores', $current['cores'] ?? '—'],
['billing.ram', isset($current['ram_mb']) ? round($current['ram_mb'] / 1024).' GB' : '—'],
['billing.seats', $current['seats'] ?? '—'],
['billing.performance', __('billing.perf.'.($current['performance'] ?? 'standard'))],
['billing.status', __('billing.status_active')],
] as [$label, $value])
<div class="bg-surface px-6 py-4">
@ -48,11 +48,22 @@
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
@foreach ($upgrades as $key)
@php $p = $plans[$key]; @endphp
<div class="flex flex-col rounded-xl border border-line bg-surface p-5 shadow-xs transition hover:border-accent-border">
<p class="font-semibold text-ink">{{ __('billing.plan.'.$key) }}</p>
<p class="mt-1 text-sm text-muted">{{ $p['quota_gb'] }} GB · {{ $p['cores'] }} vCPU · {{ round($p['ram_mb'] / 1024) }} GB RAM</p>
<div wire:key="upgrade-{{ $key }}" class="flex flex-col rounded-xl border border-line bg-surface p-5 shadow-xs transition hover:border-accent-border">
<div class="flex items-baseline justify-between">
<p class="font-semibold text-ink">{{ __('billing.plan.'.$key) }}</p>
<span class="rounded-pill bg-accent-subtle px-2 py-0.5 text-xs font-semibold text-accent-text">{{ __('billing.perf.'.($p['performance'] ?? 'standard')) }}</span>
</div>
<p class="mt-1 text-sm text-muted">{{ $p['quota_gb'] }} GB · {{ __('billing.seats_count', ['count' => $p['seats'] ?? 0]) }}</p>
<ul class="mt-3 space-y-1.5">
@foreach (array_slice($features[$key] ?? [], 0, 4) as $f)
<li class="flex items-center gap-2 text-sm text-body">
<x-ui.icon name="check" class="size-4 shrink-0 text-success-bright" />
{{ __('billing.feature.'.$f) }}
</li>
@endforeach
</ul>
<p class="mt-4 text-xl font-semibold text-ink">{{ $eur($p['price_cents']) }}<span class="text-sm font-normal text-muted"> / {{ __('billing.month_short') }}</span></p>
<x-ui.button variant="primary" size="sm" class="mt-4 w-full" wire:click="purchase('upgrade', '{{ $key }}')" wire:loading.attr="disabled">
<x-ui.button variant="primary" size="sm" class="mt-4 w-full" wire:click="purchase('upgrade', '{{ $key }}')" wire:target="purchase('upgrade', '{{ $key }}')" wire:loading.attr="disabled">
{{ __('billing.upgrade_cta') }}
</x-ui.button>
</div>
@ -70,21 +81,21 @@
<p class="font-semibold text-ink">{{ __('billing.storage_title') }}</p>
</div>
<p class="mt-2 text-sm text-body">{{ __('billing.storage_body', ['gb' => $storage['gb'], 'price' => $eur($storage['price_cents'])]) }}</p>
<x-ui.button variant="primary" size="sm" class="mt-4 w-full" wire:click="purchase('storage')" wire:loading.attr="disabled">
<x-ui.button variant="primary" size="sm" class="mt-4 w-full" wire:click="purchase('storage')" wire:target="purchase('storage')" wire:loading.attr="disabled">
{{ __('billing.storage_cta', ['gb' => $storage['gb']]) }}
</x-ui.button>
</div>
{{-- Add-ons --}}
@foreach ($addons as $key => $addon)
<div class="flex flex-col rounded-xl border border-line bg-surface p-5 shadow-xs">
<div wire:key="addon-{{ $key }}" class="flex flex-col rounded-xl border border-line bg-surface p-5 shadow-xs">
<div class="flex items-center gap-2">
<x-ui.icon name="box" class="size-5 text-muted" />
<p class="font-semibold text-ink">{{ __('billing.addon.'.$key.'.name') }}</p>
</div>
<p class="mt-2 flex-1 text-sm text-muted">{{ __('billing.addon.'.$key.'.desc') }}</p>
<p class="mt-3 text-lg font-semibold text-ink">{{ $eur($addon['price_cents']) }}<span class="text-sm font-normal text-muted"> / {{ __('billing.month_short') }}</span></p>
<x-ui.button variant="secondary" size="sm" class="mt-3 w-full" wire:click="purchase('addon', '{{ $key }}')" wire:loading.attr="disabled">
<x-ui.button variant="secondary" size="sm" class="mt-3 w-full" wire:click="purchase('addon', '{{ $key }}')" wire:target="purchase('addon', '{{ $key }}')" wire:loading.attr="disabled">
{{ __('billing.addon_cta') }}
</x-ui.button>
</div>

View File

@ -20,10 +20,11 @@
<dl class="mt-4 grid grid-cols-1 gap-4 border-t border-line pt-4 sm:grid-cols-2 lg:grid-cols-3">
@foreach ([
['cloud.plan', $instance['plan']],
['cloud.seats', $instance['seats']],
['cloud.performance', $instance['performance']],
['cloud.location', $instance['location']],
['cloud.version', $instance['version'].' · '.__('cloud.current')],
['cloud.runtime', $instance['php']],
['cloud.resources', $instance['vcpu'].' · '.$instance['ram']],
['cloud.storage', $storageLabel],
] as [$key, $val])
<div>