Compare commits
2 Commits
de3ecacc13
...
d4b70b7477
| Author | SHA1 | Date |
|---|---|---|
|
|
d4b70b7477 | |
|
|
ff92466b47 |
|
|
@ -0,0 +1,153 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Models\Customer;
|
||||
use App\Models\Subscription;
|
||||
use App\Services\Billing\PlanCatalogue;
|
||||
use App\Services\Billing\SetupFee;
|
||||
use App\Services\Billing\TaxTreatment;
|
||||
use App\Services\Provisioning\HostCapacity;
|
||||
use App\Support\CompanyProfile;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Url;
|
||||
use Livewire\Component;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* The order, before it is placed.
|
||||
*
|
||||
* There was no such page. The order page posted straight to Stripe, so the last
|
||||
* thing a customer saw in this product's own design was a grid of packages, and
|
||||
* the first thing they saw after pressing was somebody else's payment form
|
||||
* asking for a card. What they were buying, what it costs in total, what happens
|
||||
* afterwards and what they were agreeing to were never stated in one place.
|
||||
*
|
||||
* This is that place: one package, one term, every figure that will be charged,
|
||||
* and the terms acceptance directly above the button that spends money — which
|
||||
* is also where it belongs and where the owner asked for it.
|
||||
*
|
||||
* ## It decides nothing
|
||||
*
|
||||
* Every figure here comes from the same three sources the checkout itself uses —
|
||||
* the catalogue, TaxTreatment, SetupFee — and none of them is recomputed. A
|
||||
* summary that works out its own total is a second answer waiting to disagree
|
||||
* with the first, and the one that matters is the one Stripe is handed.
|
||||
*/
|
||||
#[Layout('layouts.portal-app')]
|
||||
class Checkout extends Component
|
||||
{
|
||||
/** Which package. In the address, so this page can be reloaded and shared. */
|
||||
#[Url]
|
||||
public string $plan = '';
|
||||
|
||||
/** Monthly or yearly — carried from the choice on the order page. */
|
||||
#[Url]
|
||||
public string $term = Subscription::TERM_MONTHLY;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
if (! in_array($this->term, [Subscription::TERM_MONTHLY, Subscription::TERM_YEARLY], true)) {
|
||||
$this->term = Subscription::TERM_MONTHLY;
|
||||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$user = Auth::user();
|
||||
$customer = $user === null ? null : Customer::query()->where('email', $user->email)->first();
|
||||
|
||||
// Someone who already has a contract does not buy a second one by
|
||||
// accident: changing package is a plan change, which prorates and keeps
|
||||
// their data. Same rule as the order page, checked again here because a
|
||||
// link into this page skips that one.
|
||||
$contract = $customer === null ? null : Subscription::query()
|
||||
->where('customer_id', $customer->id)
|
||||
->whereIn('status', ['active', 'past_due'])
|
||||
->first();
|
||||
|
||||
$tax = TaxTreatment::for($customer);
|
||||
// NOT called `plan` in the view: the public property of that name is a
|
||||
// key, a string, and Livewire merges its properties into the view data —
|
||||
// so a package array under the same name is shadowed by it and every
|
||||
// `$plan['name']` in the template reads an offset off a string.
|
||||
$package = $this->plan === '' ? null : $this->planOf($this->plan);
|
||||
|
||||
return view('livewire.checkout', [
|
||||
'package' => $package,
|
||||
'contract' => $contract,
|
||||
'tax' => $tax,
|
||||
'lines' => $package === null ? [] : $this->lines($package, $tax),
|
||||
'vat' => rtrim(rtrim(number_format(CompanyProfile::taxRate(), 1, ',', '.'), '0'), ','),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The chosen package as the shop describes it, or null when the key names
|
||||
* nothing on sale — a URL is a string somebody can retype.
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function planOf(string $key): ?array
|
||||
{
|
||||
try {
|
||||
$sellable = app(PlanCatalogue::class)->sellable();
|
||||
} catch (Throwable) {
|
||||
// The catalogue fails loudly by design; a customer's page is not
|
||||
// the place to argue about it. No package, one sentence.
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! array_key_exists($key, $sellable)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$plan = $sellable[$key];
|
||||
|
||||
return $plan + [
|
||||
'key' => $key,
|
||||
'delivery' => app(HostCapacity::class)->deliveryFor((int) ($plan['disk_gb'] ?? 0)),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* What will be charged, line by line, in the order an invoice states it.
|
||||
*
|
||||
* Net, tax and gross come from TaxTreatment rather than from a
|
||||
* multiplication here: a business with a valid EU VAT number outside Austria
|
||||
* pays no Austrian tax at all, and a summary that "adds 20 %" would quote
|
||||
* that customer a total they will never be charged.
|
||||
*
|
||||
* @param array<string, mixed> $plan
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function lines(array $plan, TaxTreatment $tax): array
|
||||
{
|
||||
$yearly = $this->term === Subscription::TERM_YEARLY;
|
||||
|
||||
$recurringNet = (int) ($yearly ? $plan['yearly_price_cents'] : $plan['price_cents']);
|
||||
$recurringGross = $tax->chargeCents($recurringNet);
|
||||
$setupGross = SetupFee::chargedCents($tax);
|
||||
$setupNet = SetupFee::netCents();
|
||||
|
||||
return [
|
||||
'currency' => (string) $plan['currency'],
|
||||
'recurring_net' => $recurringNet,
|
||||
'recurring_gross' => $recurringGross,
|
||||
'setup_net' => $setupNet,
|
||||
'setup_gross' => $setupGross,
|
||||
// What leaves the account on the first charge: the term plus the
|
||||
// one-off. Every figure afterwards is the term alone.
|
||||
'today_net' => $recurringNet + $setupNet,
|
||||
'today_gross' => $recurringGross + $setupGross,
|
||||
'today_tax' => ($recurringGross + $setupGross) - ($recurringNet + $setupNet),
|
||||
'yearly' => $yearly,
|
||||
'free_months' => (int) ($plan['free_months'] ?? 0),
|
||||
// Only on the yearly view, and only when there is something to
|
||||
// compare against: twelve monthly charges at the monthly price.
|
||||
'twelve_months_gross' => $tax->chargeCents((int) $plan['price_cents']) * 12,
|
||||
'per_month_gross' => $yearly ? intdiv($recurringGross, 12) : $recurringGross,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -27,4 +27,28 @@ return [
|
|||
// ein") und versprach damit einen Weg, den es nicht gibt — eine Cloud wird
|
||||
// nach der Zahlung eingerichtet und nicht vierzehn Tage später.
|
||||
'terms_required' => 'Ohne Annahme der AGB kommt die Bestellung nicht zustande. Bitte setzen Sie das Häkchen — darin ist auch Ihr 14-tägiges Widerrufsrecht geregelt.',
|
||||
|
||||
// The order page before Stripe — App\Livewire\Checkout.
|
||||
'eyebrow' => 'Bestellung',
|
||||
'title' => 'Bestellung prüfen',
|
||||
'back_to_plans' => 'Andere Pakete',
|
||||
'unknown_plan' => 'Dieses Paket steht nicht (mehr) zum Verkauf.',
|
||||
'performance' => 'Leistung',
|
||||
'summary_title' => 'Zusammenfassung',
|
||||
'setup_line' => 'Einmalige Einrichtung',
|
||||
'tax_line' => 'Umsatzsteuer :rate %',
|
||||
'total_today' => 'Heute zu zahlen',
|
||||
'then_monthly' => 'Danach :amount pro Monat',
|
||||
'then_yearly' => 'Danach :amount pro Jahr',
|
||||
'pay_cta' => 'Zahlungspflichtig bestellen',
|
||||
'stripe_note' => 'Weiter zu Stripe. Kartendaten erreichen unsere Server nie.',
|
||||
'next_title' => 'Was danach passiert',
|
||||
'step_pay_title' => 'Zahlung bei Stripe',
|
||||
'step_pay_body' => 'Sie geben Ihre Zahlungsdaten bei Stripe ein und kommen anschließend hierher zurück.',
|
||||
'step_build_title' => 'Ihre Cloud wird aufgesetzt',
|
||||
'step_build_body' => 'Das beginnt, sobald die Zahlung eingegangen ist, und läuft ohne Ihr Zutun.',
|
||||
'step_credentials_title' => 'Zugangsdaten per E-Mail',
|
||||
'step_credentials_body' => 'Sobald die Cloud bereitsteht, bekommen Sie Adresse und Zugangsdaten an Ihre bestätigte Adresse.',
|
||||
'note_withdrawal_title' => '14 Tage Widerrufsrecht',
|
||||
'note_withdrawal_body' => 'Als Verbraucher treten Sie binnen 14 Tagen ohne Angabe von Gründen zurück und erhalten den gesamten Betrag zurück.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -17,6 +17,18 @@ return [
|
|||
'free_months_hint' => '{1} 1 Monat gratis bei Jahreszahlung|[2,*] :count Monate gratis bei Jahreszahlung',
|
||||
'yearly_total' => ':total jährlich',
|
||||
'instead_of' => 'statt :amount',
|
||||
'eyebrow' => 'Pakete',
|
||||
'choose' => 'Weiter',
|
||||
'per_year' => '/Jahr',
|
||||
'per_month_label' => 'monatlich',
|
||||
'per_year_label' => 'jährlich',
|
||||
'per_month_equivalent' => 'entspricht :amount pro Monat',
|
||||
'note_cancel_title' => 'Monatlich kündbar',
|
||||
'note_cancel_body' => 'Keine Mindestlaufzeit. Sie kündigen im Kundenbereich zum Ende der laufenden Periode und erhalten davor einen vollständigen Datenexport.',
|
||||
'note_payment_title' => 'Zahlung über Stripe',
|
||||
'note_payment_body' => 'Kartendaten erreichen unsere Server nie. Die Zahlung läuft über Stripe; wir sehen nur, dass sie erfolgt ist.',
|
||||
'note_migration_title' => 'Datenübernahme',
|
||||
'note_migration_body' => 'Nicht Teil des Pakets — jede Ausgangslage ist anders. Schreiben Sie uns, wir sehen sie uns an und machen ein Angebot.',
|
||||
'recommended' => 'Empfohlen',
|
||||
'up_to' => 'bis :count',
|
||||
'per_month' => '/Monat',
|
||||
|
|
|
|||
|
|
@ -21,4 +21,28 @@ return [
|
|||
// summarised beside the box. Nothing turns on the record either way; it stays
|
||||
// as proof that the customer asked us to start at once.
|
||||
'terms_required' => 'Without accepting the terms the order cannot go through. Please tick the box — your 14-day right of withdrawal is regulated in there too.',
|
||||
|
||||
// The order page before Stripe — App\Livewire\Checkout.
|
||||
'eyebrow' => 'Order',
|
||||
'title' => 'Review your order',
|
||||
'back_to_plans' => 'Other packages',
|
||||
'unknown_plan' => 'This package is not on sale (any more).',
|
||||
'performance' => 'Performance',
|
||||
'summary_title' => 'Summary',
|
||||
'setup_line' => 'One-off setup',
|
||||
'tax_line' => 'VAT :rate %',
|
||||
'total_today' => 'Due today',
|
||||
'then_monthly' => 'Then :amount per month',
|
||||
'then_yearly' => 'Then :amount per year',
|
||||
'pay_cta' => 'Place binding order',
|
||||
'stripe_note' => 'On to Stripe. Card details never reach our servers.',
|
||||
'next_title' => 'What happens next',
|
||||
'step_pay_title' => 'Payment at Stripe',
|
||||
'step_pay_body' => 'You enter your payment details at Stripe and come back here afterwards.',
|
||||
'step_build_title' => 'Your cloud is built',
|
||||
'step_build_body' => 'That starts as soon as the payment lands and runs without you.',
|
||||
'step_credentials_title' => 'Credentials by mail',
|
||||
'step_credentials_body' => 'As soon as the cloud is ready you get the address and credentials at your confirmed address.',
|
||||
'note_withdrawal_title' => '14-day right of withdrawal',
|
||||
'note_withdrawal_body' => 'As a consumer you may withdraw within 14 days without giving a reason and get the full amount back.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -17,6 +17,18 @@ return [
|
|||
'free_months_hint' => '{1} 1 month free when paying yearly|[2,*] :count months free when paying yearly',
|
||||
'yearly_total' => ':total per year',
|
||||
'instead_of' => 'instead of :amount',
|
||||
'eyebrow' => 'Packages',
|
||||
'choose' => 'Continue',
|
||||
'per_year' => '/year',
|
||||
'per_month_label' => 'monthly',
|
||||
'per_year_label' => 'yearly',
|
||||
'per_month_equivalent' => 'that is :amount per month',
|
||||
'note_cancel_title' => 'Cancel monthly',
|
||||
'note_cancel_body' => 'No minimum term. Cancel in the portal to the end of the current period; you get a full data export before it ends.',
|
||||
'note_payment_title' => 'Payment via Stripe',
|
||||
'note_payment_body' => 'Card details never reach our servers. Stripe handles the payment; we only learn that it happened.',
|
||||
'note_migration_title' => 'Data migration',
|
||||
'note_migration_body' => 'Not part of the package — every starting point differs. Write to us, we will look at yours and quote for it.',
|
||||
'recommended' => 'Recommended',
|
||||
'up_to' => 'up to :count',
|
||||
'per_month' => '/month',
|
||||
|
|
|
|||
|
|
@ -39,12 +39,26 @@
|
|||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;">
|
||||
<tr>
|
||||
<td style="padding-right:10px;">
|
||||
{{-- The mark, not just its orange tile. It shipped as an empty
|
||||
square: the ascent inside it lives in an SVG the site loads, and
|
||||
a mail cannot — Gmail strips inline SVG, Outlook renders with
|
||||
Word, and this layout carries no image by design (half of all
|
||||
clients block remote content).
|
||||
|
||||
So the shape is drawn with a character every mail font has.
|
||||
▲ is the mark's own silhouette — the autopilot ascent — in white
|
||||
on the accent tile, which is the whole of what a reader
|
||||
recognises at 26 pixels. --}}
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;">
|
||||
<tr><td width="26" height="26" style="width:26px;height:26px;background-color:#f97316;border-radius:7px;font-size:0;line-height:0;"> </td></tr>
|
||||
<tr><td width="26" height="26" align="center" valign="middle" style="width:26px;height:26px;background-color:#f97316;border-radius:7px;text-align:center;">
|
||||
<span style="display:inline-block;font-family:Helvetica,Arial,sans-serif;font-size:13px;line-height:26px;color:#ffffff;">▲</span>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td>
|
||||
<td style="font-size:17px;line-height:26px;color:#17171c;letter-spacing:-0.2px;">
|
||||
<span style="font-weight:700;color:#17171c;">CluPilot</span>
|
||||
{{-- The whole name, as the wordmark writes it: CluPilot in ink,
|
||||
Cloud in the muted tone beside it. --}}
|
||||
<span style="font-weight:700;color:#17171c;">CluPilot</span> <span style="font-weight:600;color:#6e6e7a;">Cloud</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
|
|
|||
|
|
@ -521,11 +521,18 @@
|
|||
own gives nobody a reason to look. --}}
|
||||
<div x-data="{ term: 'monthly' }" class="mt-12">
|
||||
<div class="flex flex-wrap items-center justify-center gap-3">
|
||||
<div class="inline-flex rounded-lg border border-line bg-surface p-1 shadow-xs">
|
||||
{{-- text-bg on the active option, not a colour that does not
|
||||
exist: `text-on-ink` is not in the palette (the ink pairing
|
||||
is bg-ink/text-bg, as x-ui.button's ink variant has it), so
|
||||
the label kept its inherited grey and sat unreadable on the
|
||||
dark pill. And one radius scale nested inside another: the
|
||||
shell rounded-lg, the option rounded — the shell's radius
|
||||
less its own padding. --}}
|
||||
<div class="inline-flex rounded-lg border border-line bg-surface p-1 shadow-xs" role="group">
|
||||
@foreach (['monthly' => 'Monatlich', 'yearly' => 'Jährlich'] as $option => $label)
|
||||
<button type="button" x-on:click="term = '{{ $option }}'"
|
||||
class="rounded-md px-5 py-1.5 text-sm font-semibold transition-colors"
|
||||
x-bind:class="term === '{{ $option }}' ? 'bg-ink text-on-ink' : 'text-muted hover:text-ink'">
|
||||
class="rounded px-5 py-2 text-sm font-semibold transition-colors"
|
||||
x-bind:class="term === '{{ $option }}' ? 'bg-ink text-bg shadow-xs' : 'text-muted hover:text-ink'">
|
||||
{{ $label }}
|
||||
</button>
|
||||
@endforeach
|
||||
|
|
@ -580,13 +587,18 @@
|
|||
</p>
|
||||
</div>
|
||||
|
||||
{{-- The yearly PRICE, not the monthly equivalent of it. Shown
|
||||
per month, the figure did not move at all when the switch
|
||||
was thrown on a package with no free months, and the
|
||||
sheet looked like it had ignored the click. What it works
|
||||
out to per month is worth knowing and is said under. --}}
|
||||
<div x-show="term === 'yearly'" x-cloak>
|
||||
<p class="mt-6 flex items-baseline gap-1.5">
|
||||
<span class="text-[2.4rem] font-bold leading-none tabular-nums tracking-[-0.04em] text-ink">{{ $plan['price_yearly_monthly'] }}</span>
|
||||
<span class="text-sm text-muted">/Monat</span>
|
||||
<span class="text-[2.4rem] font-bold leading-none tabular-nums tracking-[-0.04em] text-ink">{{ $plan['price_yearly'] }}</span>
|
||||
<span class="text-sm text-muted">/Jahr</span>
|
||||
</p>
|
||||
<p class="mt-1.5 text-xs text-muted">
|
||||
{{ $plan['price_yearly'] }} jährlich · inkl. {{ $vat['label'] }} % MwSt. · netto {{ $plan['price_yearly_net'] }}
|
||||
entspricht {{ $plan['price_yearly_monthly'] }} pro Monat · inkl. {{ $vat['label'] }} % MwSt. · netto {{ $plan['price_yearly_net'] }}
|
||||
</p>
|
||||
@if ($plan['free_months'] > 0)
|
||||
<p class="mt-1 text-xs font-semibold text-success-text">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,222 @@
|
|||
{{--
|
||||
The order, before it is placed.
|
||||
|
||||
Two columns, and which is which matters: the LEFT one is what you are buying
|
||||
and what will happen, the RIGHT one is what it costs and the button that
|
||||
spends the money. The summary sticks, so on a long left column the total and
|
||||
the button stay in view — the shape every enterprise checkout has, for the
|
||||
reason that the figure you are agreeing to should never be scrolled away from
|
||||
the agreement.
|
||||
|
||||
The terms acceptance sits at the BOTTOM of the right column, immediately
|
||||
above that button. It used to be at the top of the package list, as a
|
||||
condition of looking at prices; it is a condition of buying, and this is the
|
||||
moment of buying.
|
||||
--}}
|
||||
@php
|
||||
use Illuminate\Support\Number;
|
||||
|
||||
$eur = fn (int $cents) => Number::currency($cents / 100, in: $lines['currency'] ?? 'EUR', locale: app()->getLocale());
|
||||
@endphp
|
||||
|
||||
<div class="space-y-6">
|
||||
|
||||
<header class="flex flex-wrap items-end gap-x-6 gap-y-4 border-b border-line pb-5 animate-rise">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="lbl">{{ __('checkout.eyebrow') }}</p>
|
||||
<h1 class="mt-[7px] text-[23px] font-bold leading-[1.12] tracking-[-0.03em] text-ink min-[901px]:text-[30px]">
|
||||
{{ __('checkout.title') }}
|
||||
</h1>
|
||||
</div>
|
||||
<a href="{{ route('order') }}" wire:navigate
|
||||
class="inline-flex items-center gap-2 text-sm font-semibold text-muted transition-colors hover:text-ink">
|
||||
<x-ui.icon name="arrow-left" class="size-4" />{{ __('checkout.back_to_plans') }}
|
||||
</a>
|
||||
</header>
|
||||
|
||||
@if ($contract)
|
||||
<x-ui.alert variant="info">
|
||||
{{ __('order.already_customer') }}
|
||||
<a href="{{ route('billing') }}" class="font-semibold underline">{{ __('order.to_billing') }}</a>
|
||||
</x-ui.alert>
|
||||
@elseif ($package === null)
|
||||
{{-- A package key nobody knows. Reached from a retyped address or from a
|
||||
package taken off sale between the two pages. --}}
|
||||
<x-ui.alert variant="warning">
|
||||
{{ __('checkout.unknown_plan') }}
|
||||
<a href="{{ route('order') }}" class="font-semibold underline">{{ __('checkout.back_to_plans') }}</a>
|
||||
</x-ui.alert>
|
||||
@else
|
||||
@error('plan')<x-ui.alert variant="error">{{ $message }}</x-ui.alert>@enderror
|
||||
@error('term')<x-ui.alert variant="error">{{ $message }}</x-ui.alert>@enderror
|
||||
@error('terms_accepted')<x-ui.alert variant="error">{{ $message }}</x-ui.alert>@enderror
|
||||
@error('checkout')<x-ui.alert variant="error">{{ $message }}</x-ui.alert>@enderror
|
||||
|
||||
<div class="grid gap-5 lg:grid-cols-[minmax(0,1fr)_400px] lg:items-start">
|
||||
|
||||
{{-- ── What you are buying ──────────────────────────────────────── --}}
|
||||
<div class="space-y-5">
|
||||
<section class="overflow-hidden rounded-lg border border-line bg-surface shadow-xs animate-rise">
|
||||
<div class="flex flex-wrap items-baseline gap-x-3 gap-y-1 border-b border-line px-6 py-4">
|
||||
<h2 class="text-md font-bold tracking-[-0.01em] text-ink">{{ $package['name'] }}</h2>
|
||||
<span class="rounded-pill border border-line bg-surface-2 px-2.5 py-0.5 text-xs font-semibold text-body">
|
||||
{{ __('order.term_'.$term) }}
|
||||
</span>
|
||||
@if ($package['audience'] ?? '')
|
||||
<span class="text-sm text-muted">{{ $package['audience'] }}</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<dl class="grid grid-cols-2 gap-px bg-line sm:grid-cols-4">
|
||||
@foreach ([
|
||||
__('order.storage') => $package['quota_gb'] >= 1000 ? ($package['quota_gb'] / 1000).' TB' : $package['quota_gb'].' GB',
|
||||
__('order.traffic') => $package['traffic_gb'] >= 1000 ? ($package['traffic_gb'] / 1000).' TB' : $package['traffic_gb'].' GB',
|
||||
__('order.seats') => __('order.up_to', ['count' => $package['seats']]),
|
||||
__('checkout.performance') => __('billing.perf.'.($package['performance'] ?? 'standard')),
|
||||
] as $label => $value)
|
||||
<div class="bg-surface px-6 py-4">
|
||||
<dt class="text-xs text-muted">{{ $label }}</dt>
|
||||
<dd class="mt-1 font-mono text-sm tabular-nums text-ink">{{ $value }}</dd>
|
||||
</div>
|
||||
@endforeach
|
||||
</dl>
|
||||
|
||||
<div class="border-t border-line px-6 py-4">
|
||||
<x-ui.delivery :state="$package['delivery']" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{-- What happens after the button. Three steps, because "you are
|
||||
redirected to a payment provider" is the point at which a
|
||||
first-time buyer wants to know what they are getting into. --}}
|
||||
<section class="rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:60ms]">
|
||||
<h2 class="text-sm font-bold text-ink">{{ __('checkout.next_title') }}</h2>
|
||||
<ol class="mt-4 space-y-4">
|
||||
@foreach (['pay', 'build', 'credentials'] as $i => $step)
|
||||
<li class="flex gap-3.5">
|
||||
<span class="mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-pill bg-ink text-xs font-bold text-bg">{{ $i + 1 }}</span>
|
||||
<span class="min-w-0">
|
||||
<b class="block text-sm font-semibold text-ink">{{ __('checkout.step_'.$step.'_title') }}</b>
|
||||
<span class="mt-0.5 block text-xs leading-relaxed text-muted">{{ __('checkout.step_'.$step.'_body') }}</span>
|
||||
</span>
|
||||
</li>
|
||||
@endforeach
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
{{-- The three standing facts about the sale. Same band as the
|
||||
order page, so the two pages read as one flow. --}}
|
||||
<div class="grid gap-px overflow-hidden rounded-lg border border-line bg-line shadow-xs sm:grid-cols-3 animate-rise [animation-delay:120ms]">
|
||||
@foreach ([
|
||||
['icon' => 'refresh', 'title' => __('order.note_cancel_title'), 'body' => __('order.note_cancel_body')],
|
||||
['icon' => 'lock', 'title' => __('order.note_payment_title'), 'body' => __('order.note_payment_body')],
|
||||
['icon' => 'shield', 'title' => __('checkout.note_withdrawal_title'), 'body' => __('checkout.note_withdrawal_body')],
|
||||
] as $note)
|
||||
<div class="bg-surface p-5">
|
||||
<p class="flex items-center gap-2 text-sm font-semibold text-ink">
|
||||
<x-ui.icon :name="$note['icon']" class="size-4 text-accent-text" />{{ $note['title'] }}
|
||||
</p>
|
||||
<p class="mt-2 text-xs leading-relaxed text-muted">{{ $note['body'] }}</p>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ── What it costs, and the button ────────────────────────────── --}}
|
||||
<form method="POST" action="{{ route('checkout.start') }}"
|
||||
x-data="{ terms: false }"
|
||||
class="rounded-lg border border-line bg-surface shadow-md animate-rise [animation-delay:40ms] lg:sticky lg:top-6">
|
||||
@csrf
|
||||
<input type="hidden" name="plan" value="{{ $package['key'] }}" />
|
||||
<input type="hidden" name="term" value="{{ $term }}" />
|
||||
<input type="hidden" name="terms_accepted" x-bind:value="terms ? '1' : ''" />
|
||||
|
||||
<div class="border-b border-line px-6 py-4">
|
||||
<h2 class="text-sm font-bold text-ink">{{ __('checkout.summary_title') }}</h2>
|
||||
</div>
|
||||
|
||||
<dl class="space-y-3 px-6 py-5 text-sm">
|
||||
<div class="flex items-baseline justify-between gap-4">
|
||||
<dt class="text-muted">
|
||||
{{ $package['name'] }} ·
|
||||
{{ $lines['yearly'] ? __('order.per_year_label') : __('order.per_month_label') }}
|
||||
</dt>
|
||||
<dd class="font-mono tabular-nums text-ink">{{ $eur($lines['recurring_net']) }}</dd>
|
||||
</div>
|
||||
|
||||
@if ($lines['yearly'] && $lines['free_months'] > 0)
|
||||
<div class="flex items-baseline justify-between gap-4 text-success">
|
||||
<dt>{{ trans_choice('order.free_months_hint', $lines['free_months'], ['count' => $lines['free_months']]) }}</dt>
|
||||
<dd class="font-mono tabular-nums">− {{ $eur($lines['twelve_months_gross'] - $lines['recurring_gross']) }}</dd>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($lines['setup_net'] > 0)
|
||||
<div class="flex items-baseline justify-between gap-4">
|
||||
<dt class="text-muted">{{ __('checkout.setup_line') }}</dt>
|
||||
<dd class="font-mono tabular-nums text-ink">{{ $eur($lines['setup_net']) }}</dd>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="flex items-baseline justify-between gap-4 border-t border-line pt-3">
|
||||
<dt class="text-muted">{{ __('checkout.tax_line', ['rate' => $vat]) }}</dt>
|
||||
<dd class="font-mono tabular-nums text-ink">{{ $eur($lines['today_tax']) }}</dd>
|
||||
</div>
|
||||
|
||||
{{-- The one figure that will actually be charged, and it is
|
||||
the largest thing in the panel. --}}
|
||||
<div class="flex items-baseline justify-between gap-4 border-t border-line pt-3">
|
||||
<dt class="text-sm font-semibold text-ink">{{ __('checkout.total_today') }}</dt>
|
||||
<dd class="font-mono text-xl font-bold tabular-nums tracking-[-0.02em] text-ink">{{ $eur($lines['today_gross']) }}</dd>
|
||||
</div>
|
||||
|
||||
{{-- And what comes after it, because the total above includes
|
||||
a one-off and the next charge will not. --}}
|
||||
<p class="text-xs leading-relaxed text-muted">
|
||||
{{ __($lines['yearly'] ? 'checkout.then_yearly' : 'checkout.then_monthly', [
|
||||
'amount' => $eur($lines['recurring_gross']),
|
||||
]) }}
|
||||
@if ($lines['yearly'])
|
||||
· {{ __('order.per_month_equivalent', ['amount' => $eur($lines['per_month_gross'])]) }}
|
||||
@endif
|
||||
</p>
|
||||
</dl>
|
||||
|
||||
{{-- ── The acceptance, directly above the button ─────────────
|
||||
Not at the top of the previous page as a condition of
|
||||
looking at prices: it is a condition of buying, and this is
|
||||
the button that buys. 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. --}}
|
||||
<div class="border-t border-line bg-surface-2 px-6 py-5">
|
||||
<label class="flex cursor-pointer items-start gap-3">
|
||||
<input type="checkbox" x-model="terms"
|
||||
class="mt-0.5 size-4 shrink-0 rounded-sm border-line-strong text-accent-active focus:ring-accent-active" />
|
||||
<span class="min-w-0 text-sm leading-relaxed text-body">
|
||||
{{-- A new tab: following the link must not lose the
|
||||
page with the order 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>
|
||||
</label>
|
||||
<p class="mt-2 pl-7 text-xs leading-relaxed text-muted">{{ __('order.terms_why') }}</p>
|
||||
|
||||
<button type="submit"
|
||||
class="mt-5 inline-flex w-full items-center justify-center gap-2 rounded bg-ink px-4 py-3 text-sm font-semibold text-bg transition-colors hover:bg-accent-text"
|
||||
x-bind:disabled="!terms"
|
||||
x-bind:class="terms ? '' : 'cursor-not-allowed opacity-50'">
|
||||
<x-ui.icon name="lock" class="size-4" />{{ __('checkout.pay_cta') }}
|
||||
</button>
|
||||
<p class="mt-2 text-center text-[11px] leading-snug text-muted" x-show="!terms" x-cloak>
|
||||
{{ __('order.terms_first') }}
|
||||
</p>
|
||||
<p class="mt-3 text-center text-[11px] leading-snug text-muted">{{ __('checkout.stripe_note') }}</p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
|
@ -1,22 +1,69 @@
|
|||
{{--
|
||||
Buying a package.
|
||||
Choosing a package. Choosing only — nothing is agreed to on this page.
|
||||
|
||||
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.
|
||||
## What was wrong with the last one
|
||||
|
||||
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.
|
||||
Everything but the cards was pinned to the left half of the window: a
|
||||
68-character title, an 80-character footnote, and a switch under them, on a
|
||||
1240px shell. It read as a column that had lost its second half.
|
||||
|
||||
And the terms sat at the TOP as a condition of looking at prices, which is
|
||||
the wrong order twice over — it is a condition of BUYING, and the buttons
|
||||
below it were dead until it was ticked, so the page arrived looking broken.
|
||||
The acceptance now lives one step later, on the checkout page, directly above
|
||||
the button that spends money. This page has no form on it at all.
|
||||
|
||||
## The shape
|
||||
|
||||
A header band that uses the width — name and purpose left, the term switch
|
||||
right, where a control belongs. Then the cards. Then a closing band of three
|
||||
columns rather than a paragraph trailing off to the left.
|
||||
--}}
|
||||
<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>
|
||||
@php
|
||||
use Illuminate\Support\Number;
|
||||
|
||||
$eur = fn (int $cents, string $currency) => Number::currency($cents / 100, in: $currency, locale: app()->getLocale());
|
||||
$bestFreeMonths = (int) collect($plans)->max('free_months');
|
||||
@endphp
|
||||
|
||||
<div class="space-y-6" x-data="{ term: 'monthly' }">
|
||||
|
||||
{{-- ── Header band ──────────────────────────────────────────────────────
|
||||
Title left, control right, one rule under both. The switch is a page
|
||||
control and not a card decoration, so it sits with the heading. --}}
|
||||
<header class="flex flex-wrap items-end gap-x-6 gap-y-4 border-b border-line pb-5 animate-rise">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="lbl">{{ __('order.eyebrow') }}</p>
|
||||
<h1 class="mt-[7px] text-[23px] font-bold leading-[1.12] tracking-[-0.03em] text-ink min-[901px]:text-[30px]">
|
||||
{{ __('order.title') }}
|
||||
</h1>
|
||||
<p class="mt-2 max-w-[76ch] text-sm leading-relaxed text-muted">{{ __('order.subtitle') }}</p>
|
||||
</div>
|
||||
|
||||
@if ($plans !== [] && ! $contract)
|
||||
<div class="flex flex-col items-start gap-2 sm:items-end">
|
||||
{{-- One radius scale, nested: the shell is rounded-lg (16px) and
|
||||
the option inside it rounded (11px), which is the shell's
|
||||
radius less its own padding. A rounded-md option — Tailwind's
|
||||
untokenised 6px — read as a different component sitting
|
||||
inside this one. --}}
|
||||
<div class="inline-flex rounded-lg border border-line bg-surface p-1 shadow-xs" role="group">
|
||||
@foreach (['monthly', 'yearly'] as $option)
|
||||
<button type="button" x-on:click="term = '{{ $option }}'"
|
||||
class="rounded px-5 py-2 text-sm font-semibold transition-colors"
|
||||
x-bind:class="term === '{{ $option }}' ? 'bg-ink text-bg shadow-xs' : 'text-muted hover:text-ink'">
|
||||
{{ __('order.term_'.$option) }}
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
@if ($bestFreeMonths > 0)
|
||||
<span class="text-xs font-semibold text-success" x-show="term !== 'yearly'">
|
||||
{{ trans_choice('order.free_months_hint', $bestFreeMonths, ['count' => $bestFreeMonths]) }}
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</header>
|
||||
|
||||
@if ($contract)
|
||||
{{-- Not an error and not a dead end: they have a contract, and the way to
|
||||
|
|
@ -32,212 +79,121 @@
|
|||
@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>
|
||||
{{-- ── 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-lg 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">
|
||||
<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
|
||||
|
||||
{{-- 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>
|
||||
@if ($plan['audience'])
|
||||
<p class="mt-1 text-xs leading-relaxed text-muted">{{ $plan['audience'] }}</p>
|
||||
@endif
|
||||
|
||||
{{-- The price of the term that is selected — the whole price,
|
||||
in the unit it is charged in. The yearly view used to
|
||||
show the monthly EQUIVALENT in the headline, so on a
|
||||
package with no free months the figure did not move at
|
||||
all when the switch was thrown, and the page looked like
|
||||
it had ignored the click. What a year works out to per
|
||||
month is worth knowing and is said underneath. --}}
|
||||
<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'], $plan['currency']) }}</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'], $plan['currency']) }}
|
||||
</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_gross'], $plan['currency']) }}</span>
|
||||
<span class="text-xs text-muted">{{ __('order.per_year') }}</span>
|
||||
</p>
|
||||
<p class="mt-1.5 text-xs text-muted">
|
||||
{{ __('order.per_month_equivalent', ['amount' => $eur($plan['yearly_per_month'], $plan['currency'])]) }} ·
|
||||
{{ __('order.incl_vat', ['rate' => $vat]) }} · {{ __('order.net') }} {{ $eur($plan['yearly_net'], $plan['currency']) }}
|
||||
</p>
|
||||
@if ($plan['free_months'] > 0)
|
||||
<p class="mt-1 text-xs font-semibold text-success">
|
||||
{{ trans_choice('order.free_months_hint', $plan['free_months'], ['count' => $plan['free_months']]) }}
|
||||
· {{ __('order.instead_of', ['amount' => $eur($plan['twelve_months_gross'], $plan['currency'])]) }}
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if ($setup > 0)
|
||||
<p class="mt-1 text-xs text-muted">
|
||||
{{ __('order.setup', ['amount' => $eur($setup, $plan['currency'])]) }}
|
||||
</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 link, not a form: choosing a package is a navigation,
|
||||
and what follows is a page where the order is confirmed.
|
||||
The term travels in the address, so the checkout shows
|
||||
what was being looked at — and a reload of that page
|
||||
still shows it. --}}
|
||||
<a href="{{ route('checkout', ['plan' => $plan['key'], 'term' => 'monthly']) }}"
|
||||
x-bind:href="'{{ route('checkout', ['plan' => $plan['key']]) }}?term=' + term"
|
||||
@class([
|
||||
'mt-6 inline-flex w-full items-center justify-center gap-2 rounded px-4 py-2.5 text-sm font-semibold transition-colors',
|
||||
'bg-ink text-bg hover:bg-accent-text' => $plan['recommended'],
|
||||
'border border-line-strong bg-surface text-ink hover:bg-surface-hover' => ! $plan['recommended'],
|
||||
])>
|
||||
{{ __('order.choose') }}
|
||||
<x-ui.icon name="chevron-down" class="size-4 -rotate-90" />
|
||||
</a>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<p class="max-w-[80ch] text-xs leading-relaxed text-muted">{{ __('order.terms') }}</p>
|
||||
{{-- ── The closing band ──────────────────────────────────────────────
|
||||
Three columns across the same width as the cards. It was one
|
||||
paragraph wrapped at 80 characters, which left two thirds of the
|
||||
page empty under a grid that used all of it. --}}
|
||||
<div class="grid gap-px overflow-hidden rounded-lg border border-line bg-line shadow-xs sm:grid-cols-3 animate-rise [animation-delay:240ms]">
|
||||
@foreach ([
|
||||
['icon' => 'refresh', 'title' => __('order.note_cancel_title'), 'body' => __('order.note_cancel_body')],
|
||||
['icon' => 'lock', 'title' => __('order.note_payment_title'), 'body' => __('order.note_payment_body')],
|
||||
['icon' => 'box', 'title' => __('order.note_migration_title'), 'body' => __('order.note_migration_body')],
|
||||
] as $note)
|
||||
<div class="bg-surface p-6">
|
||||
<p class="flex items-center gap-2 text-sm font-semibold text-ink">
|
||||
<x-ui.icon :name="$note['icon']" class="size-4 text-accent-text" />{{ $note['title'] }}
|
||||
</p>
|
||||
<p class="mt-2 text-xs leading-relaxed text-muted">{{ $note['body'] }}</p>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -317,6 +317,12 @@ $portal = function () {
|
|||
// account that may not belong to the person holding it — and the
|
||||
// credentials for the finished cloud are sent to that address.
|
||||
Route::get('/order', \App\Livewire\Order::class)->name('order');
|
||||
// The order, before it is placed. There was no such page: the order page
|
||||
// posted straight to Stripe, so the last thing a customer saw in this
|
||||
// product's design was a grid of packages and the next thing was
|
||||
// somebody else's card form. GET, with the package and the term in the
|
||||
// address, so it survives a reload.
|
||||
Route::get('/checkout', \App\Livewire\Checkout::class)->name('checkout');
|
||||
Route::post('/checkout', [\App\Http\Controllers\CheckoutController::class, 'start'])
|
||||
// Throttled: each attempt opens a session on somebody else's API.
|
||||
->middleware('throttle:10,1')
|
||||
|
|
|
|||
|
|
@ -74,11 +74,34 @@ it('freezes the free months when the version is published', function () {
|
|||
it('lets a customer pick the term on the order page', function () {
|
||||
$page = $this->actingAs(termBuyer())->get(route('order'))->assertOk()->getContent();
|
||||
|
||||
// Both figures rendered, one shown: switching costs no round trip.
|
||||
expect($page)->toContain("term = 'yearly'")
|
||||
// Both figures rendered, one shown: switching costs no round trip, and
|
||||
// the form posts the word the customer was looking at.
|
||||
->and($page)->toContain('name="term"')
|
||||
->and($page)->toContain(__('order.term_yearly'));
|
||||
->and($page)->toContain(__('order.term_yearly'))
|
||||
// And the choice travels to the checkout page in the address, so a
|
||||
// reload there still shows what was chosen.
|
||||
->and($page)->toContain("?term=' + term");
|
||||
});
|
||||
|
||||
it('carries the chosen term into the checkout page and its form', function () {
|
||||
$page = $this->actingAs(termBuyer())
|
||||
->get(route('checkout', ['plan' => 'team', 'term' => 'yearly']))
|
||||
->assertOk()
|
||||
->getContent();
|
||||
|
||||
expect($page)->toContain('name="term" value="yearly"')
|
||||
// And the yearly figure is the one on the summary: 179,00 × 12 gross.
|
||||
->and($page)->toContain('2.577,60');
|
||||
});
|
||||
|
||||
it('refuses a term nobody sells even in the address bar', function () {
|
||||
// A URL is a string somebody can retype. An unknown term falls back to the
|
||||
// month rather than showing a page priced from nothing.
|
||||
$page = $this->actingAs(termBuyer())
|
||||
->get(route('checkout', ['plan' => 'team', 'term' => 'weekly']))
|
||||
->assertOk()
|
||||
->getContent();
|
||||
|
||||
expect($page)->toContain('name="term" value="monthly"');
|
||||
});
|
||||
|
||||
it('opens the checkout on the yearly Stripe Price when yearly was chosen', function () {
|
||||
|
|
@ -123,7 +146,11 @@ it('shows the yearly price on the public sheet as well', function () {
|
|||
$page = $this->get('/')->assertOk()->getContent();
|
||||
|
||||
expect($page)->toContain("term = 'yearly'")
|
||||
->and($page)->toContain('jährlich');
|
||||
// The yearly PRICE as the headline, in the unit it is charged in — the
|
||||
// monthly equivalent as the headline did not move at all on a package
|
||||
// with no free months, so the sheet looked like it ignored the click.
|
||||
->and($page)->toContain('/Jahr')
|
||||
->and($page)->toContain('entspricht');
|
||||
});
|
||||
|
||||
it('states the term on the billing card, not just a monthly figure', function () {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,140 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Customer;
|
||||
use App\Models\Subscription;
|
||||
use App\Models\User;
|
||||
use App\Services\Billing\SetupFee;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
/**
|
||||
* The order, before it is placed.
|
||||
*
|
||||
* There was no such page. The order page posted straight to Stripe: the last
|
||||
* thing a customer saw in this product's design was a grid of packages, and the
|
||||
* next was somebody else's card form. What they were buying, what it costs in
|
||||
* total, what happens afterwards and what they were agreeing to were never
|
||||
* stated in one place — and the terms box sat at the TOP of the package list,
|
||||
* as a condition of looking at prices.
|
||||
*/
|
||||
function checkoutUser(): User
|
||||
{
|
||||
return User::factory()->create(['email_verified_at' => now()]);
|
||||
}
|
||||
|
||||
it('states the package, the term and every figure that will be charged', function () {
|
||||
$page = $this->actingAs(checkoutUser())
|
||||
->get(route('checkout', ['plan' => 'team', 'term' => 'monthly']))
|
||||
->assertOk();
|
||||
|
||||
$page->assertSee('Team')
|
||||
->assertSee(__('order.term_monthly'))
|
||||
// Net, tax and gross: 179,00 net, 20 % VAT, 214,80 to pay.
|
||||
->assertSee('179,00')
|
||||
->assertSee('214,80')
|
||||
->assertSee(__('checkout.total_today'))
|
||||
// And what comes after the first charge, which is not the same figure
|
||||
// whenever a setup fee is on the first one. Asserted in two pieces: the
|
||||
// formatter puts a non-breaking space before the sign, and building the
|
||||
// expected string by hand is a test that checks its own typing.
|
||||
->assertSee(mb_substr(__('checkout.then_monthly', ['amount' => '']), 0, 6));
|
||||
});
|
||||
|
||||
it('adds the setup fee to what is due today and to nothing after it', function () {
|
||||
Settings::set('company.setup_fee_cents', 9900);
|
||||
|
||||
$page = $this->actingAs(checkoutUser())
|
||||
->get(route('checkout', ['plan' => 'team', 'term' => 'monthly']))
|
||||
->assertOk();
|
||||
|
||||
// 214,80 for the month plus 118,80 for the setup — both gross.
|
||||
$page->assertSee(__('checkout.setup_line'))
|
||||
// 214,80 for the month plus 118,80 for the setup.
|
||||
->assertSee('333,60')
|
||||
// The recurring figure alone is what follows the first charge.
|
||||
->assertSee('214,80');
|
||||
|
||||
expect(SetupFee::netCents())->toBe(9900);
|
||||
});
|
||||
|
||||
it('refuses to place the order without the terms, whatever the browser did', function () {
|
||||
// The button is disabled until the box is ticked, and disabling a button in
|
||||
// a browser refuses nothing. This is the rule.
|
||||
$this->actingAs(checkoutUser())
|
||||
->post(route('checkout.start'), ['plan' => 'team', 'term' => 'monthly'])
|
||||
->assertSessionHasErrors('terms_accepted');
|
||||
});
|
||||
|
||||
it('puts the acceptance under the total and over the button', function () {
|
||||
// The order it is read in: what you get, what it costs, what you agree to,
|
||||
// then the button. It used to be first, above the prices.
|
||||
$page = File::get(resource_path('views/livewire/checkout.blade.php'));
|
||||
|
||||
expect(strpos($page, "__('checkout.total_today')"))
|
||||
->toBeLessThan(strpos($page, "__('order.terms_label'"))
|
||||
->and(strpos($page, "__('order.terms_label'"))
|
||||
->toBeLessThan(strpos($page, "__('checkout.pay_cta')"));
|
||||
});
|
||||
|
||||
it('says what happens after the button, because the next screen is a stranger', function () {
|
||||
// Being handed to a payment provider is the moment a first-time buyer wants
|
||||
// to know what they are getting into.
|
||||
$this->actingAs(checkoutUser())
|
||||
->get(route('checkout', ['plan' => 'start']))
|
||||
->assertOk()
|
||||
->assertSee(__('checkout.step_pay_title'))
|
||||
->assertSee(__('checkout.step_build_title'))
|
||||
->assertSee(__('checkout.step_credentials_title'))
|
||||
->assertSee(__('checkout.stripe_note'));
|
||||
});
|
||||
|
||||
it('answers a package nobody sells with a sentence, not a stack trace', function () {
|
||||
// Reached from a retyped address, or from a package taken off sale between
|
||||
// the two pages.
|
||||
$this->actingAs(checkoutUser())
|
||||
->get(route('checkout', ['plan' => 'gibt-es-nicht']))
|
||||
->assertOk()
|
||||
->assertSee(__('checkout.unknown_plan'));
|
||||
});
|
||||
|
||||
it('sends a customer who already has a contract to their billing page', function () {
|
||||
// A second checkout would build a second, empty cloud and bill for both.
|
||||
// The order page says so; a link straight into this page skips that.
|
||||
$user = checkoutUser();
|
||||
$customer = Customer::factory()->create(['email' => $user->email, 'user_id' => $user->id]);
|
||||
Subscription::factory()->create(['customer_id' => $customer->id, 'status' => 'active']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('checkout', ['plan' => 'team']))
|
||||
->assertOk()
|
||||
->assertSee(__('order.already_customer'))
|
||||
->assertDontSee(__('checkout.pay_cta'));
|
||||
});
|
||||
|
||||
it('is behind the same door as everything else', function () {
|
||||
// An unconfirmed address is not a billing detail to be caught at checkout:
|
||||
// the finished cloud's credentials are sent to it.
|
||||
$this->get(route('checkout', ['plan' => 'team']))->assertRedirect();
|
||||
|
||||
$this->actingAs(User::factory()->unverified()->create())
|
||||
->get(route('checkout', ['plan' => 'team']))
|
||||
->assertRedirect(route('verification.notice'));
|
||||
});
|
||||
|
||||
// ---- The mark in the mails ----
|
||||
|
||||
it('draws the mark in the mail header, not an empty orange tile', function () {
|
||||
// It shipped as a plain square: the ascent inside the mark lives in an SVG
|
||||
// the site loads, and a mail cannot — Gmail strips inline SVG, Outlook
|
||||
// renders with Word, and this layout carries no image by design. So the
|
||||
// shape is a character every mail font has.
|
||||
$html = (string) app(App\Services\Mail\MailPreviews::class)->make('verify-email')?->render();
|
||||
|
||||
// As the entity it is written with: a mail is markup, and ▲ is what
|
||||
// survives every client's encoding guesswork.
|
||||
expect($html)->toContain('▲')
|
||||
->and($html)->toContain('#f97316')
|
||||
// And the whole name beside it, as the wordmark writes it.
|
||||
->and($html)->toContain('CluPilot')
|
||||
->and($html)->toContain('Cloud');
|
||||
});
|
||||
|
|
@ -156,7 +156,8 @@ it('shows the packages with the same gross prices the public sheet quotes', func
|
|||
->get(route('order'))
|
||||
->assertOk()
|
||||
->assertSee('58,80')
|
||||
->assertSee(__('order.buy'));
|
||||
// "Weiter", not "buy": the order page chooses, the checkout page buys.
|
||||
->assertSee(__('order.choose'));
|
||||
});
|
||||
|
||||
it('promises delivery on the booking page from the estate, like everywhere else', function () {
|
||||
|
|
@ -230,8 +231,11 @@ it('names what is being accepted, and links to it', function () {
|
|||
// links to the document, and summarises beside it what a reader most needs to
|
||||
// know before ticking — that delivery starts at once and the withdrawal right
|
||||
// survives it.
|
||||
//
|
||||
// On the CHECKOUT page. It used to sit at the top of the package list as a
|
||||
// condition of looking at prices; it is a condition of buying.
|
||||
$this->actingAs(buyer())
|
||||
->get(route('order'))
|
||||
->get(route('checkout', ['plan' => 'start']))
|
||||
->assertOk()
|
||||
->assertSee(__('order.terms_link'))
|
||||
->assertSee(__('order.terms_why'))
|
||||
|
|
@ -239,6 +243,16 @@ it('names what is being accepted, and links to it', function () {
|
|||
->assertSee('terms_accepted', false);
|
||||
});
|
||||
|
||||
it('keeps the package list free of forms and consents', function () {
|
||||
// Choosing is not agreeing. The page arrived with every button dead until a
|
||||
// box at the top was ticked, which reads as broken before anybody has done
|
||||
// anything wrong.
|
||||
$page = $this->actingAs(buyer())->get(route('order'))->assertOk()->getContent();
|
||||
|
||||
expect($page)->not->toContain('name="terms_accepted"')
|
||||
->and($page)->not->toContain('action="'.route('checkout.start').'"');
|
||||
});
|
||||
|
||||
it('serves the terms it asks a customer to accept', function () {
|
||||
// A link to a page that says "Dieser Inhalt wird noch ergänzt" is not a
|
||||
// contract, and accepting it would be accepting nothing.
|
||||
|
|
@ -300,13 +314,13 @@ it('records no consent for a session that never carried one', function () {
|
|||
->toBeNull();
|
||||
});
|
||||
|
||||
it('asks for the terms before the packages, not after them', function () {
|
||||
// It used to sit at the bottom, so the page arrived with every button
|
||||
// unusable before anybody had done anything wrong. Read in the order it
|
||||
// applies: the condition first, the choice second.
|
||||
$page = Illuminate\Support\Facades\File::get(resource_path('views/livewire/order.blade.php'));
|
||||
it('asks for the terms where the money is spent, not where prices are read', function () {
|
||||
// On the checkout page, at the bottom of the summary, directly above the
|
||||
// button that pays — after the total, not before the prices.
|
||||
$page = Illuminate\Support\Facades\File::get(resource_path('views/livewire/checkout.blade.php'));
|
||||
|
||||
expect(strpos($page, "__('order.terms_title')"))->toBeLessThan(strpos($page, 'lg:grid-cols-3'))
|
||||
expect(strpos($page, "__('checkout.total_today')"))->toBeLessThan(strpos($page, "__('order.terms_label'"))
|
||||
->and(strpos($page, "__('order.terms_label'"))->toBeLessThan(strpos($page, "__('checkout.pay_cta')"))
|
||||
// No order without the terms — and the button says why rather than
|
||||
// leaving somebody to guess at a control that does nothing.
|
||||
->and($page)->toContain('x-bind:disabled="!terms"')
|
||||
|
|
|
|||
Loading…
Reference in New Issue