diff --git a/app/Livewire/Checkout.php b/app/Livewire/Checkout.php new file mode 100644 index 0000000..5d053ad --- /dev/null +++ b/app/Livewire/Checkout.php @@ -0,0 +1,153 @@ +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|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 $plan + * @return array + */ + 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, + ]; + } +} diff --git a/lang/de/checkout.php b/lang/de/checkout.php index 5f3ce60..a015533 100644 --- a/lang/de/checkout.php +++ b/lang/de/checkout.php @@ -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.', ]; diff --git a/lang/de/order.php b/lang/de/order.php index 10723ed..82bf350 100644 --- a/lang/de/order.php +++ b/lang/de/order.php @@ -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', diff --git a/lang/en/checkout.php b/lang/en/checkout.php index ddb3e0b..01d3e08 100644 --- a/lang/en/checkout.php +++ b/lang/en/checkout.php @@ -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.', ]; diff --git a/lang/en/order.php b/lang/en/order.php index 46be820..160b20d 100644 --- a/lang/en/order.php +++ b/lang/en/order.php @@ -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', diff --git a/resources/views/components/mail/layout.blade.php b/resources/views/components/mail/layout.blade.php index 5597d80..6952df1 100644 --- a/resources/views/components/mail/layout.blade.php +++ b/resources/views/components/mail/layout.blade.php @@ -39,12 +39,26 @@
+ {{-- 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. --}} - +
 
+ +
- CluPilot + {{-- The whole name, as the wordmark writes it: CluPilot in ink, + Cloud in the muted tone beside it. --}} + CluPilot Cloud
diff --git a/resources/views/landing.blade.php b/resources/views/landing.blade.php index f49024c..768169e 100644 --- a/resources/views/landing.blade.php +++ b/resources/views/landing.blade.php @@ -521,11 +521,18 @@ own gives nobody a reason to look. --}}
-
+ {{-- 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. --}} +
@foreach (['monthly' => 'Monatlich', 'yearly' => 'Jährlich'] as $option => $label) @endforeach @@ -580,13 +587,18 @@

+ {{-- 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. --}}

- {{ $plan['price_yearly_monthly'] }} - /Monat + {{ $plan['price_yearly'] }} + /Jahr

- {{ $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'] }}

@if ($plan['free_months'] > 0)

diff --git a/resources/views/livewire/checkout.blade.php b/resources/views/livewire/checkout.blade.php new file mode 100644 index 0000000..ea2ae43 --- /dev/null +++ b/resources/views/livewire/checkout.blade.php @@ -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 + +

+ +
+
+

{{ __('checkout.eyebrow') }}

+

+ {{ __('checkout.title') }} +

+
+ + {{ __('checkout.back_to_plans') }} + +
+ + @if ($contract) + + {{ __('order.already_customer') }} + {{ __('order.to_billing') }} + + @elseif ($package === null) + {{-- A package key nobody knows. Reached from a retyped address or from a + package taken off sale between the two pages. --}} + + {{ __('checkout.unknown_plan') }} + {{ __('checkout.back_to_plans') }} + + @else + @error('plan'){{ $message }}@enderror + @error('term'){{ $message }}@enderror + @error('terms_accepted'){{ $message }}@enderror + @error('checkout'){{ $message }}@enderror + +
+ + {{-- ── What you are buying ──────────────────────────────────────── --}} +
+
+
+

{{ $package['name'] }}

+ + {{ __('order.term_'.$term) }} + + @if ($package['audience'] ?? '') + {{ $package['audience'] }} + @endif +
+ +
+ @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) +
+
{{ $label }}
+
{{ $value }}
+
+ @endforeach +
+ +
+ +
+
+ + {{-- 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. --}} +
+

{{ __('checkout.next_title') }}

+
    + @foreach (['pay', 'build', 'credentials'] as $i => $step) +
  1. + {{ $i + 1 }} + + {{ __('checkout.step_'.$step.'_title') }} + {{ __('checkout.step_'.$step.'_body') }} + +
  2. + @endforeach +
+
+ + {{-- The three standing facts about the sale. Same band as the + order page, so the two pages read as one flow. --}} +
+ @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) +
+

+ {{ $note['title'] }} +

+

{{ $note['body'] }}

+
+ @endforeach +
+
+ + {{-- ── What it costs, and the button ────────────────────────────── --}} +
+ @csrf + + + + +
+

{{ __('checkout.summary_title') }}

+
+ +
+
+
+ {{ $package['name'] }} · + {{ $lines['yearly'] ? __('order.per_year_label') : __('order.per_month_label') }} +
+
{{ $eur($lines['recurring_net']) }}
+
+ + @if ($lines['yearly'] && $lines['free_months'] > 0) +
+
{{ trans_choice('order.free_months_hint', $lines['free_months'], ['count' => $lines['free_months']]) }}
+
− {{ $eur($lines['twelve_months_gross'] - $lines['recurring_gross']) }}
+
+ @endif + + @if ($lines['setup_net'] > 0) +
+
{{ __('checkout.setup_line') }}
+
{{ $eur($lines['setup_net']) }}
+
+ @endif + +
+
{{ __('checkout.tax_line', ['rate' => $vat]) }}
+
{{ $eur($lines['today_tax']) }}
+
+ + {{-- The one figure that will actually be charged, and it is + the largest thing in the panel. --}} +
+
{{ __('checkout.total_today') }}
+
{{ $eur($lines['today_gross']) }}
+
+ + {{-- And what comes after it, because the total above includes + a one-off and the next charge will not. --}} +

+ {{ __($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 +

+
+ + {{-- ── 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. --}} +
+ +

{{ __('order.terms_why') }}

+ + +

+ {{ __('order.terms_first') }} +

+

{{ __('checkout.stripe_note') }}

+
+
+
+ @endif +
diff --git a/resources/views/livewire/order.blade.php b/resources/views/livewire/order.blade.php index 665ecc7..efc6a2a 100644 --- a/resources/views/livewire/order.blade.php +++ b/resources/views/livewire/order.blade.php @@ -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. --}} -
-
-

{{ __('order.title') }}

-

{{ __('order.subtitle') }}

-
+@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 + +
+ + {{-- ── 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. --}} +
+
+

{{ __('order.eyebrow') }}

+

+ {{ __('order.title') }} +

+

{{ __('order.subtitle') }}

+
+ + @if ($plans !== [] && ! $contract) +
+ {{-- 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. --}} +
+ @foreach (['monthly', 'yearly'] as $option) + + @endforeach +
+ @if ($bestFreeMonths > 0) + + {{ trans_choice('order.free_months_hint', $bestFreeMonths, ['count' => $bestFreeMonths]) }} + + @endif +
+ @endif +
@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'){{ $message }}@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. --}} -
- {{-- ── 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. --}} - - - @error('terms_accepted') - {{ $message }} - @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 -
-
- @foreach (['monthly', 'yearly'] as $option) - - @endforeach -
- @if ($bestFreeMonths > 0) - - {{ trans_choice('order.free_months_hint', $bestFreeMonths, ['count' => $bestFreeMonths]) }} - - @endif -
- - {{-- ── 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. --}} -
- @foreach ($plans as $i => $plan) -
$plan['recommended'], - 'border-line' => ! $plan['recommended'], - ]) style="animation-delay: {{ 60 + $i * 40 }}ms"> - {{-- Name, who it is for, and the one badge. --}} -
-

{{ $plan['name'] }}

- @if ($plan['recommended']) - {{ __('order.recommended') }} - @endif -
- @if ($plan['audience']) -

{{ $plan['audience'] }}

+ {{-- ── 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. --}} +
+ @foreach ($plans as $i => $plan) +
$plan['recommended'], + 'border-line' => ! $plan['recommended'], + ]) style="animation-delay: {{ 60 + $i * 40 }}ms"> +
+

{{ $plan['name'] }}

+ @if ($plan['recommended']) + {{ __('order.recommended') }} @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 - -
-

- {{ $eur($plan['gross']) }} - {{ __('order.per_month') }} -

-

- {{ __('order.incl_vat', ['rate' => $vat]) }} · - {{ __('order.net') }} {{ $eur($plan['net']) }} -

-
- -
-

- {{ $eur($plan['yearly_per_month']) }} - {{ __('order.per_month') }} -

-

- {{ __('order.yearly_total', ['total' => $eur($plan['yearly_gross'])]) }} · - {{ __('order.incl_vat', ['rate' => $vat]) }} · - {{ __('order.net') }} {{ $eur($plan['yearly_net']) }} -

- @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. --}} -

- {{ trans_choice('order.free_months_hint', $plan['free_months'], ['count' => $plan['free_months']]) }} - · {{ __('order.instead_of', ['amount' => $eur($plan['twelve_months_gross'])]) }} -

- @endif -
- @if ($setup > 0) -

- {{ __('order.setup', ['amount' => Number::currency($setup / 100, in: $plan['currency'], locale: app()->getLocale())]) }} -

- @endif - - {{-- Read from the estate, not written down: where a host - has room this rolls out on its own. --}} - - -
- @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) -
-
{{ $label }}
-
{{ $value }}
-
- @endforeach -
- - {{-- 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. --}} -
- @csrf - - {{-- 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. --}} - - - - {{-- 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. --}} - - {{ __('order.buy') }} - -

- {{ __('order.terms_first') }} -

-
- @endforeach -
+ @if ($plan['audience']) +

{{ $plan['audience'] }}

+ @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. --}} +
+

+ {{ $eur($plan['gross'], $plan['currency']) }} + {{ __('order.per_month') }} +

+

+ {{ __('order.incl_vat', ['rate' => $vat]) }} · {{ __('order.net') }} {{ $eur($plan['net'], $plan['currency']) }} +

+
+ +
+

+ {{ $eur($plan['yearly_gross'], $plan['currency']) }} + {{ __('order.per_year') }} +

+

+ {{ __('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']) }} +

+ @if ($plan['free_months'] > 0) +

+ {{ trans_choice('order.free_months_hint', $plan['free_months'], ['count' => $plan['free_months']]) }} + · {{ __('order.instead_of', ['amount' => $eur($plan['twelve_months_gross'], $plan['currency'])]) }} +

+ @endif +
+ + @if ($setup > 0) +

+ {{ __('order.setup', ['amount' => $eur($setup, $plan['currency'])]) }} +

+ @endif + + {{-- Read from the estate, not written down: where a host has + room this rolls out on its own. --}} + + +
+ @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) +
+
{{ $label }}
+
{{ $value }}
+
+ @endforeach +
+ + {{-- 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. --}} + $plan['recommended'], + 'border border-line-strong bg-surface text-ink hover:bg-surface-hover' => ! $plan['recommended'], + ])> + {{ __('order.choose') }} + + +
+ @endforeach
-

{{ __('order.terms') }}

+ {{-- ── 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. --}} +
+ @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) +
+

+ {{ $note['title'] }} +

+

{{ $note['body'] }}

+
+ @endforeach +
@endif
diff --git a/routes/web.php b/routes/web.php index ab2db7e..e7e7cfe 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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') diff --git a/tests/Feature/Billing/YearlyTermTest.php b/tests/Feature/Billing/YearlyTermTest.php index 25bce8d..ceb91ff 100644 --- a/tests/Feature/Billing/YearlyTermTest.php +++ b/tests/Feature/Billing/YearlyTermTest.php @@ -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 () { diff --git a/tests/Feature/CheckoutPageTest.php b/tests/Feature/CheckoutPageTest.php new file mode 100644 index 0000000..8bef529 --- /dev/null +++ b/tests/Feature/CheckoutPageTest.php @@ -0,0 +1,140 @@ +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'); +}); diff --git a/tests/Feature/SelfServiceOrderTest.php b/tests/Feature/SelfServiceOrderTest.php index ee05d2c..f27cae2 100644 --- a/tests/Feature/SelfServiceOrderTest.php +++ b/tests/Feature/SelfServiceOrderTest.php @@ -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"')