CluPilotCloud/resources/views/livewire/order.blade.php

244 lines
16 KiB
PHP

{{--
Buying a package.
The first version put five cards in a four-column grid, so the fifth wrapped
and sat alone; the consent box was at the BOTTOM, which left every button
disabled on arrival a page that looks broken before you have done anything
wrong. And the prices ran across the card in one block, so nothing stood out
to compare.
Now: the consent comes first, because it is the condition for everything under
it. Three columns, so five plans read as 3 + 2 rather than 4 + 1. Each card is
one column of the same four rows price, delivery, what you get, the button
so the eye can run down a row and compare like with like.
--}}
<div class="mx-auto max-w-[1120px] space-y-6">
<div class="animate-rise">
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('order.title') }}</h1>
<p class="mt-1 max-w-[68ch] text-sm leading-relaxed text-muted">{{ __('order.subtitle') }}</p>
</div>
@if ($contract)
{{-- Not an error and not a dead end: they have a contract, and the way to
a bigger package is a plan change, which prorates and leaves their
data where it is. A second checkout would build a second, empty cloud
and bill for both. --}}
<x-ui.alert variant="info">
{{ __('order.already_customer') }}
<a href="{{ route('billing') }}" class="font-semibold underline">{{ __('order.to_billing') }}</a>
</x-ui.alert>
@elseif ($plans === [])
<x-ui.alert variant="warning">{{ __('order.no_catalogue') }}</x-ui.alert>
@else
@error('plan')<x-ui.alert variant="error">{{ $message }}</x-ui.alert>@enderror
{{-- `term` decides which of the two prices every card shows and what the
forms post. Both figures are rendered, so switching is instant and
costs no round trip and the checkout is handed the same word the
customer was looking at. --}}
<div x-data="{ terms: false, term: 'monthly' }" class="space-y-6">
{{-- ── The terms, first ──────────────────────────────────────────
One declaration, and the terms behind it regulate the sale:
price, VAT, delivery, cancellation, the fourteen-day withdrawal
right and the express request to begin at once. The box used to
carry that last one as a paragraph of its own, which read like a
choice between "now" and "in fourteen days" and there is no
second option.
The label is not the whole contract in small print: it says what
is being accepted, links to it, and summarises underneath what a
reader most needs to know before ticking.
The server rule is the real gate (CheckoutController validates
`accepted`), so with JavaScript off the field arrives empty and
the checkout is refused with the sentence explaining why. --}}
<label class="flex cursor-pointer items-start gap-3 rounded-lg border p-5 shadow-xs transition-colors animate-rise [animation-delay:40ms]"
x-bind:class="terms ? 'border-success-border bg-success-bg' : 'border-accent-border bg-accent-subtle'">
<input type="checkbox" x-model="terms"
class="mt-0.5 size-4 shrink-0 rounded border-line-strong text-accent-active focus:ring-accent-active" />
<span class="min-w-0">
<span class="block text-sm font-semibold text-ink">{{ __('order.terms_title') }}</span>
<span class="mt-1 block text-sm leading-relaxed text-body">
{{-- A new tab: following the link must not lose the page
with the choice on it. --}}
{!! __('order.terms_label', [
'link' => '<a href="'.route('legal.agb').'" target="_blank" rel="noopener"
class="font-semibold text-accent-text underline underline-offset-4">'
.e(__('order.terms_link')).'</a>',
]) !!}
</span>
<span class="mt-2 block text-xs leading-relaxed text-muted">{{ __('order.terms_why') }}</span>
</span>
</label>
@error('terms_accepted')
<x-ui.alert variant="error">{{ $message }}</x-ui.alert>
@enderror
{{-- ── Monthly or yearly ─────────────────────────────────────────
Two words, not a dropdown: there are exactly two answers and
both fit on the line. The saving is named on the switch itself,
because "jährlich" alone gives nobody a reason to look. --}}
{{-- The block form of the php directive, not the parenthesised
short one: that short form does not survive a cast and a method
call in one expression it compiled to an unterminated PHP open
tag and took the rest of the page with it.
Neither form is written out here, and that is deliberate: Blade
compiles DIRECTIVES inside comments too, so naming the short one
in this very comment produced the same open tag a second time
and the whole block vanished from the page without an error
anywhere. A comment describes a directive; it never spells one. --}}
@php
$bestFreeMonths = (int) collect($plans)->max('free_months');
@endphp
<div class="flex flex-wrap items-center gap-3 animate-rise [animation-delay:50ms]">
<div class="inline-flex rounded-lg border border-line bg-surface p-1 shadow-xs">
@foreach (['monthly', 'yearly'] as $option)
<button type="button" x-on:click="term = '{{ $option }}'"
class="rounded-md px-4 py-1.5 text-sm font-semibold transition-colors"
x-bind:class="term === '{{ $option }}' ? 'bg-ink text-on-ink' : 'text-muted hover:text-ink'">
{{ __('order.term_'.$option) }}
</button>
@endforeach
</div>
@if ($bestFreeMonths > 0)
<span class="text-sm font-semibold text-success-text" x-show="term !== 'yearly'">
{{ trans_choice('order.free_months_hint', $bestFreeMonths, ['count' => $bestFreeMonths]) }}
</span>
@endif
</div>
{{-- ── The packages ──────────────────────────────────────────────
Three columns: five plans read as 3 + 2, and a fifth card alone
on a row of four reads as a mistake. items-start, because the
recommended one is allowed to be taller without dragging its
neighbours' buttons down with it. --}}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 lg:items-start">
@foreach ($plans as $i => $plan)
<div @class([
'flex flex-col rounded-xl border bg-surface p-6 shadow-xs animate-rise',
'border-ink shadow-md ring-1 ring-ink' => $plan['recommended'],
'border-line' => ! $plan['recommended'],
]) style="animation-delay: {{ 60 + $i * 40 }}ms">
{{-- Name, who it is for, and the one badge. --}}
<div class="flex flex-wrap items-center gap-2">
<h2 class="text-md font-bold tracking-[-0.01em] text-ink">{{ $plan['name'] }}</h2>
@if ($plan['recommended'])
<span class="rounded-pill bg-accent-active px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-on-accent">{{ __('order.recommended') }}</span>
@endif
</div>
@if ($plan['audience'])
<p class="mt-1 text-xs leading-relaxed text-muted">{{ $plan['audience'] }}</p>
@endif
{{-- The price, gross and large. A customer quoted 58,80 €
on the website must not meet 49 € on the page with
the button on it; the net figure is underneath for
the books.
Both terms, one shown at a time. The headline figure
stays "per month" in either — a yearly price shown as
a single big number reads as five times dearer at a
glance, and the amount actually taken is named on the
line underneath. --}}
@php
$eur = fn (int $cents) => Number::currency($cents / 100, in: $plan['currency'], locale: app()->getLocale());
@endphp
<div x-show="term === 'monthly'">
<p class="mt-5 flex items-baseline gap-1.5">
<span class="text-[2rem] font-bold leading-none tabular-nums tracking-[-0.03em] text-ink">{{ $eur($plan['gross']) }}</span>
<span class="text-xs text-muted">{{ __('order.per_month') }}</span>
</p>
<p class="mt-1.5 text-xs text-muted">
{{ __('order.incl_vat', ['rate' => $vat]) }} ·
{{ __('order.net') }} {{ $eur($plan['net']) }}
</p>
</div>
<div x-show="term === 'yearly'" x-cloak>
<p class="mt-5 flex items-baseline gap-1.5">
<span class="text-[2rem] font-bold leading-none tabular-nums tracking-[-0.03em] text-ink">{{ $eur($plan['yearly_per_month']) }}</span>
<span class="text-xs text-muted">{{ __('order.per_month') }}</span>
</p>
<p class="mt-1.5 text-xs text-muted">
{{ __('order.yearly_total', ['total' => $eur($plan['yearly_gross'])]) }} ·
{{ __('order.incl_vat', ['rate' => $vat]) }} ·
{{ __('order.net') }} {{ $eur($plan['yearly_net']) }}
</p>
@if ($plan['free_months'] > 0)
{{-- The saving, with the figure it is measured
against: "2 Monate gratis" next to what
twelve monthly payments would have cost. --}}
<p class="mt-1 text-xs font-semibold text-success-text">
{{ trans_choice('order.free_months_hint', $plan['free_months'], ['count' => $plan['free_months']]) }}
· {{ __('order.instead_of', ['amount' => $eur($plan['twelve_months_gross'])]) }}
</p>
@endif
</div>
@if ($setup > 0)
<p class="mt-1 text-xs text-muted">
{{ __('order.setup', ['amount' => Number::currency($setup / 100, in: $plan['currency'], locale: app()->getLocale())]) }}
</p>
@endif
{{-- Read from the estate, not written down: where a host
has room this rolls out on its own. --}}
<x-ui.delivery :state="$plan['delivery']" class="mt-4" />
<dl class="mt-4 flex-1 space-y-2 border-t border-line pt-4 text-sm">
@foreach ([
__('order.storage') => $plan['quota_gb'] >= 1000 ? ($plan['quota_gb'] / 1000).' TB' : $plan['quota_gb'].' GB',
__('order.traffic') => $plan['traffic_gb'] >= 1000 ? ($plan['traffic_gb'] / 1000).' TB' : $plan['traffic_gb'].' GB',
__('order.seats') => __('order.up_to', ['count' => $plan['seats']]),
] as $label => $value)
<div class="flex items-baseline justify-between gap-3">
<dt class="text-muted">{{ $label }}</dt>
<dd class="font-mono tabular-nums text-ink">{{ $value }}</dd>
</div>
@endforeach
</dl>
{{-- A plain form post, not a Livewire action: the response
is a redirect to another domain, and Livewire's
XHR-and-swap cannot leave the site. --}}
<form method="POST" action="{{ route('checkout.start') }}" class="mt-6">
@csrf
<input type="hidden" name="plan" value="{{ $plan['key'] }}" />
{{-- The term the customer was looking at when they
pressed. CheckoutController validates it against
the two we sell and picks that term's Stripe
Price, so nothing here can invent a third. --}}
<input type="hidden" name="term" x-bind:value="term" />
<input type="hidden" name="terms_accepted" x-bind:value="terms ? '1' : ''" />
{{-- No order without the terms — so the button is not
usable until they are accepted, and says why
underneath rather than leaving somebody to guess
at a dead button. The refusal on the server
stands regardless: this is the visible half of
one rule, not the rule itself.
x-bind on a plain attribute, not @disabled on the
component — R24#3: a Blade directive in a
component tag's attribute list lands in the
attribute bag and takes the view apart. --}}
<x-ui.button type="submit" :variant="$plan['recommended'] ? 'primary' : 'secondary'" class="w-full"
x-bind:disabled="!terms"
x-bind:class="terms ? '' : 'cursor-not-allowed opacity-50'">
{{ __('order.buy') }}
</x-ui.button>
<p class="mt-2 text-center text-[11px] leading-snug text-muted" x-show="!terms" x-cloak>
{{ __('order.terms_first') }}
</p>
</form>
</div>
@endforeach
</div>
</div>
<p class="max-w-[80ch] text-xs leading-relaxed text-muted">{{ __('order.terms') }}</p>
@endif
</div>