279 lines
17 KiB
PHP
279 lines
17 KiB
PHP
<?php use Illuminate\Support\Number; ?>
|
|
<div class="space-y-6" x-data="{ msgs: @js(['purchased' => __('billing.purchased')]) }">
|
|
@php $loc = app()->getLocale(); $eur = fn ($c) => Number::currency($c / 100, in: 'EUR', locale: $loc); @endphp
|
|
|
|
<div class="animate-rise">
|
|
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('billing.title') }}</h1>
|
|
<p class="mt-1 text-sm text-muted">{{ __('billing.subtitle') }}</p>
|
|
</div>
|
|
|
|
{{-- Current plan --}}
|
|
<div class="overflow-hidden rounded-lg border border-line bg-surface shadow-xs animate-rise [animation-delay:60ms]">
|
|
<div class="flex flex-wrap items-center justify-between gap-4 border-b border-line bg-surface-2 px-6 py-4">
|
|
<div>
|
|
<p class="text-xs font-semibold uppercase tracking-wider text-muted">{{ __('billing.current_plan') }}</p>
|
|
<p class="mt-0.5 text-lg font-semibold text-ink">{{ __('billing.plan.'.$currentKey) }}</p>
|
|
</div>
|
|
<div class="text-right">
|
|
<p class="text-2xl font-semibold text-ink">{{ $eur($current['price_cents'] ?? 0) }}</p>
|
|
<p class="text-xs text-muted">{{ __('billing.net_per_month') }}</p>
|
|
@if ($totalMonthlyCents !== null && $totalMonthlyCents !== ($current['price_cents'] ?? 0))
|
|
{{-- The plan alone is not the bill once modules are booked,
|
|
and every part of it is frozen at what was agreed. --}}
|
|
<p class="mt-1 text-sm font-medium text-body">
|
|
{{ __('billing.total_with_addons', ['total' => $eur($totalMonthlyCents)]) }}
|
|
</p>
|
|
@endif
|
|
</div>
|
|
</div>
|
|
<div class="grid grid-cols-2 gap-px bg-line sm:grid-cols-4">
|
|
@foreach ([
|
|
['billing.storage', ($current['quota_gb'] ?? 0).' 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">
|
|
<p class="text-xs text-muted">{{ __($label) }}</p>
|
|
<p class="mt-0.5 font-semibold text-ink">{{ $value }}</p>
|
|
</div>
|
|
@endforeach
|
|
</div>
|
|
</div>
|
|
|
|
{{-- Cart: "5 purchases pending" told nobody what they had ordered, and
|
|
offered no way to change their mind. --}}
|
|
@if ($pending->isNotEmpty())
|
|
<div class="overflow-hidden rounded-lg border border-info-border bg-surface shadow-xs animate-rise">
|
|
<div class="flex items-center gap-2 border-b border-line bg-info-bg px-5 py-3">
|
|
<x-ui.icon name="receipt" class="size-4 shrink-0 text-info" />
|
|
<h2 class="text-sm font-semibold text-ink">{{ __('billing.cart.title') }}</h2>
|
|
<span class="ml-auto text-xs text-muted">{{ __('billing.cart.subtitle') }}</span>
|
|
</div>
|
|
|
|
<ul class="divide-y divide-line">
|
|
@foreach ($pending as $order)
|
|
<li wire:key="order-{{ $order->uuid }}" class="flex flex-wrap items-center gap-3 px-5 py-3">
|
|
<div class="min-w-0 flex-1">
|
|
<p class="text-sm font-medium text-ink">{{ $order->label() }}</p>
|
|
<p class="mt-0.5 text-xs text-muted">
|
|
{{ __('billing.cart.added', ['when' => $order->created_at->isoFormat('LL')]) }}
|
|
</p>
|
|
</div>
|
|
<div class="text-right">
|
|
<p class="font-mono text-sm text-body">{{ $eur($order->amount_cents) }}</p>
|
|
<p class="text-xs text-muted">
|
|
{{ __('billing.cart.net') }} ·
|
|
{{ $order->isRecurring() ? __('billing.cart.per_month') : __('billing.cart.once') }}
|
|
</p>
|
|
</div>
|
|
<button type="button" aria-label="{{ __('billing.cart.remove') }}" title="{{ __('billing.cart.remove') }}"
|
|
x-on:click="$dispatch('openModal', { component: 'confirm-remove-order', arguments: { uuid: '{{ $order->uuid }}' } })"
|
|
class="grid size-8 place-items-center rounded-md border border-line text-muted transition hover:border-danger hover:text-danger">
|
|
<x-ui.icon name="trash-2" class="size-4" />
|
|
</button>
|
|
</li>
|
|
@endforeach
|
|
</ul>
|
|
|
|
@php
|
|
$net = $pending->sum('amount_cents');
|
|
$gross = $pending->sum(fn ($o) => $o->grossCents());
|
|
// Summed from the same per-order rounding the total uses: rounding
|
|
// after aggregating would let the two figures disagree by a cent.
|
|
$recurringGross = $pending->filter->isRecurring()->sum(fn ($o) => $o->grossCents());
|
|
@endphp
|
|
<div class="space-y-1.5 border-t border-line bg-surface-2 px-5 py-3.5 text-sm">
|
|
<div class="flex items-center justify-between text-muted">
|
|
<span>{{ __('billing.cart.subtotal_net') }}</span>
|
|
<span class="font-mono">{{ $eur($net) }}</span>
|
|
</div>
|
|
<div class="flex items-center justify-between text-muted">
|
|
<span>
|
|
{{ $tax->reverseCharge
|
|
? __('billing.cart.reverse_charge')
|
|
: __('billing.cart.vat', ['percent' => $tax->percentLabel()]) }}
|
|
</span>
|
|
<span class="font-mono">{{ $eur($gross - $net) }}</span>
|
|
</div>
|
|
<div class="flex items-center justify-between border-t border-line pt-1.5 font-semibold text-ink">
|
|
<span>{{ __('billing.cart.total_gross') }}</span>
|
|
<span class="font-mono">{{ $eur($gross) }}</span>
|
|
</div>
|
|
@if ($recurringGross > 0)
|
|
{{-- The number that actually matters every month, kept apart
|
|
from a one-off traffic top-up in the same cart. --}}
|
|
<p class="pt-1 text-xs text-muted">
|
|
{{ __('billing.cart.recurring_note', ['amount' => $eur($recurringGross)]) }}
|
|
</p>
|
|
@endif
|
|
</div>
|
|
</div>
|
|
@endif
|
|
|
|
{{-- Upgrades --}}
|
|
@if (count($upgrades) > 0)
|
|
<div class="animate-rise [animation-delay:120ms]">
|
|
<h2 class="mb-3 font-semibold text-ink">{{ __('billing.upgrade_title') }}</h2>
|
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
@foreach ($upgrades as $key)
|
|
@php $p = $plans[$key]; @endphp
|
|
<div wire:key="upgrade-{{ $key }}" class="flex flex-col rounded-lg border border-line bg-surface p-5 shadow-xs transition hover:border-accent-border">
|
|
<div class="flex items-baseline justify-between">
|
|
<p class="font-semibold text-ink">{{ __('billing.plan.'.$key) }}</p>
|
|
<span class="rounded-pill bg-accent-subtle px-2 py-0.5 text-xs font-semibold text-accent-text">{{ __('billing.perf.'.($p['performance'] ?? 'standard')) }}</span>
|
|
</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>
|
|
<p class="text-xs text-muted">
|
|
{{ $tax->reverseCharge
|
|
? __('billing.net_reverse_charge')
|
|
: __('billing.net_hint', ['percent' => $tax->percentLabel()]) }}
|
|
</p>
|
|
<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>
|
|
@endforeach
|
|
</div>
|
|
</div>
|
|
@endif
|
|
|
|
{{-- ── Downgrade ──────────────────────────────────────────────────────
|
|
Smaller plans are listed even when they cannot be taken, with the
|
|
reason in the customer's own numbers. A plan that quietly disappears
|
|
reads as "no longer offered"; a greyed-out button that does not say
|
|
what is in the way is the thing people ring about. --}}
|
|
@if ($downgrades !== [])
|
|
<section class="animate-rise">
|
|
<h2 class="text-md font-semibold text-ink">{{ __('billing.downgrade') }}</h2>
|
|
<p class="mt-1 text-sm text-muted">{{ __('billing.downgrade_hint') }}</p>
|
|
|
|
<div class="mt-4 grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
|
@foreach ($downgrades as $key => $plan)
|
|
<article class="flex flex-col rounded-lg border border-line bg-surface p-5 shadow-xs">
|
|
<div class="flex items-start justify-between gap-3">
|
|
<div class="min-w-0">
|
|
<h3 class="text-md font-semibold text-ink">{{ $plan['name'] ?? $key }}</h3>
|
|
<p class="mt-1 text-xs text-muted">
|
|
{{ $plan['quota_gb'] ?? 0 }} GB · {{ __('billing.seats_count', ['count' => $plan['seats'] ?? 0]) }}
|
|
</p>
|
|
</div>
|
|
<p class="shrink-0 text-right font-mono text-lg font-semibold text-ink">
|
|
{{ $eur($plan['price_cents'] ?? 0) }}
|
|
<span class="mt-0.5 block text-xs font-normal text-muted">{{ __('billing.per_month') }}</span>
|
|
</p>
|
|
</div>
|
|
|
|
@if ($plan['check']->allowed)
|
|
<x-ui.button variant="secondary" class="mt-5 w-full"
|
|
wire:click="purchase('downgrade', '{{ $key }}')"
|
|
wire:loading.attr="disabled" wire:target="purchase('downgrade', '{{ $key }}')">
|
|
{{ __('billing.downgrade_to', ['plan' => $plan['name'] ?? $key]) }}
|
|
</x-ui.button>
|
|
@else
|
|
<div class="mt-5 rounded border border-warning-border bg-warning-bg p-3">
|
|
<p class="text-xs font-semibold text-warning">{{ __('billing.downgrade_blocked') }}</p>
|
|
<ul class="mt-2 space-y-1.5">
|
|
@foreach ($plan['check']->blockers as $blocker)
|
|
<li class="text-xs leading-relaxed text-ink">
|
|
{{ __('billing.downgrade_blocker.'.$blocker['key'], [
|
|
'current' => $blocker['current'],
|
|
'limit' => $blocker['limit'],
|
|
]) }}
|
|
</li>
|
|
@endforeach
|
|
</ul>
|
|
</div>
|
|
@endif
|
|
</article>
|
|
@endforeach
|
|
</div>
|
|
</section>
|
|
@endif
|
|
|
|
{{-- Extra storage + Add-ons --}}
|
|
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3 xl:grid-cols-4 animate-rise [animation-delay:180ms]">
|
|
{{-- Storage --}}
|
|
<div class="flex flex-col rounded-lg border border-accent-border bg-accent-subtle p-5">
|
|
<div class="flex items-center gap-2">
|
|
<x-ui.icon name="database" class="size-5 text-accent-active" />
|
|
<p class="font-semibold text-ink">{{ __('billing.storage_title') }}</p>
|
|
</div>
|
|
<p class="mt-2 text-sm text-body">{{ __('billing.storage_body', ['gb' => $storage['gb'], 'price' => $eur($storage['price_cents'])]) }}</p>
|
|
<p class="mt-1 text-xs text-muted">{{ __('billing.net_per_month') }}</p>
|
|
<x-ui.button variant="primary" size="sm" class="mt-4 w-full" wire:click="purchase('storage')" wire:target="purchase('storage')" wire:loading.attr="disabled">
|
|
{{ __('billing.storage_cta', ['gb' => $storage['gb']]) }}
|
|
</x-ui.button>
|
|
</div>
|
|
|
|
{{-- Traffic --}}
|
|
<div class="flex flex-col rounded-lg border {{ $trafficMeter && $trafficMeter->state() !== 'ok' ? 'border-warning-border bg-warning-bg' : 'border-line bg-surface shadow-xs' }} p-5">
|
|
<div class="flex items-center gap-2">
|
|
<x-ui.icon name="activity" class="size-5 {{ $trafficMeter && $trafficMeter->state() !== 'ok' ? 'text-warning' : 'text-muted' }}" />
|
|
<p class="font-semibold text-ink">{{ __('billing.traffic_title') }}</p>
|
|
</div>
|
|
<p class="mt-2 text-sm text-body">{{ __('billing.traffic_body', ['gb' => $trafficAddon['gb'], 'price' => $eur($trafficAddon['price_cents'])]) }}</p>
|
|
<p class="mt-1 text-xs text-muted">{{ __('billing.net_once') }}</p>
|
|
@if ($trafficMeter)
|
|
<p class="mt-2 font-mono text-xs text-muted">
|
|
{{ __('billing.traffic_used', [
|
|
'used' => \App\Support\Bytes::human($trafficMeter->usedBytes),
|
|
'quota' => \App\Support\Bytes::human($trafficMeter->quotaBytes),
|
|
]) }}
|
|
</p>
|
|
@endif
|
|
<x-ui.button variant="{{ $trafficMeter && $trafficMeter->state() !== 'ok' ? 'primary' : 'secondary' }}" size="sm" class="mt-4 w-full"
|
|
wire:click="purchase('traffic')" wire:target="purchase('traffic')" wire:loading.attr="disabled">
|
|
{{ __('billing.traffic_cta', ['gb' => $trafficAddon['gb']]) }}
|
|
</x-ui.button>
|
|
</div>
|
|
|
|
{{-- Add-ons --}}
|
|
@foreach ($addons as $key => $addon)
|
|
<div wire:key="addon-{{ $key }}" class="flex flex-col rounded-lg border border-line bg-surface p-5 shadow-xs">
|
|
<div class="flex items-center gap-2">
|
|
<x-ui.icon name="box" class="size-5 text-muted" />
|
|
<p class="font-semibold text-ink">{{ __('billing.addon.'.$key.'.name') }}</p>
|
|
</div>
|
|
<p class="mt-2 flex-1 text-sm text-muted">{{ __('billing.addon.'.$key.'.desc') }}</p>
|
|
{{-- What they actually pay for it once booked (packs and all),
|
|
and today's price while it is still a sale to be made. --}}
|
|
<p class="mt-3 text-lg font-semibold text-ink">
|
|
{{ $eur($addon['booked'] ? $addon['monthly_cents'] : (int) $addon['price_cents']) }}<span class="text-sm font-normal text-muted"> / {{ __('billing.month_short') }}</span>
|
|
@if ($addon['booked'] && $addon['quantity'] > 1)
|
|
<span class="text-sm font-normal text-muted">· {{ __('billing.addon_packs', ['count' => $addon['quantity']]) }}</span>
|
|
@endif
|
|
</p>
|
|
<p class="text-xs text-muted">
|
|
{{ $tax->reverseCharge
|
|
? __('billing.net_reverse_charge')
|
|
: __('billing.net_hint', ['percent' => $tax->percentLabel()]) }}
|
|
</p>
|
|
@if ($addon['booked'])
|
|
{{-- Already theirs, at the price they booked it for — which
|
|
is why this card does not show today's. --}}
|
|
<p class="mt-3 inline-flex items-center gap-1.5 rounded-pill border border-success-border bg-success-bg px-2.5 py-0.5 text-xs font-medium text-success">
|
|
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>
|
|
{{ __('billing.addon_booked') }}
|
|
</p>
|
|
@else
|
|
<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>
|
|
@endif
|
|
</div>
|
|
@endforeach
|
|
</div>
|
|
|
|
<p class="text-xs text-muted">{{ __('billing.mock_note') }}</p>
|
|
</div>
|