Give the order a checkout page, and stop hiding what a year costs
**There was no checkout page.** The package list posted straight to Stripe, so the last thing a customer saw in this product's design was a grid of cards and the next was somebody else's form asking for a card number. What they were buying, what it costs in total, what happens afterwards and what they were agreeing to were never stated in one place. App\Livewire\Checkout is that place: the package and its figures on the left, a sticky summary on the right — net, the saving where there is one, the setup fee, the tax, what is due today, and what will be charged after it. Every figure comes from the catalogue, TaxTreatment and SetupFee, the same three the checkout itself reads; nothing is recomputed, because a summary that works out its own total is a second answer waiting to disagree with the one Stripe is handed. **The terms acceptance moved to where the money is spent.** It sat at the top of the package list as a condition of LOOKING at prices, with every button dead beneath it. It is a condition of buying, so it is now at the foot of the summary, directly above the button — after the total, which is the order those three things are read in. The package list has no form on it at all now; its buttons are links. **The page used the left half of a 1240px shell.** A 68-character title, a switch under it and an 80-character footnote, beside a grid that used the whole width. Now: a header band with the name left and the term switch right, where a control belongs, and a closing band of three columns instead of a paragraph trailing off. **The yearly view showed the monthly equivalent as its headline**, so on a package with no free months the big figure did not move at all when the switch was thrown — the page looked like it had ignored the click. The headline is the price of the term in the unit it is charged in; what a year works out to per month is said underneath. Same on the public sheet. **The switch was unreadable.** `text-on-ink` is not in this palette — the ink pairing is bg-ink/text-bg, as x-ui.button has always had it — so the label kept its inherited grey on a near-black pill. And its shell was rounded-lg with rounded-md options inside: two radius scales, one of them Tailwind's untokenised 6px. One scale now, nested. **The mails drew an empty orange tile.** The mark's ascent 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. The shape is drawn with a character every mail font has, white on the accent tile, with the whole wordmark beside it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>feature/betriebsmodus^2
parent
de3ecacc13
commit
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