Fix the checkout link, and stop the switch moving under the cursor

**Every package button led to "Dieses Paket steht nicht (mehr) zum
Verkauf".** The link was built as route() plus "?term=" + term — but both
are QUERY parameters on this route, so route() had already opened the
query string and the result was `?plan=team?term=monthly`. The package
arrived as the whole string "team?term=monthly", matched nothing in the
catalogue, and the page said so politely while giving no hint why. The
address now comes from route() in one piece with a placeholder swapped
into it, and a test asserts every checkout link on the page contains
exactly one question mark.

**The saving moved the buttons it belonged to.** It hung under the switch
and was hidden on the yearly view, so choosing a term shifted the very
control that chooses it. It sits above now and never disappears: one line,
two wordings — an offer while it is an offer, a statement once it is
taken. Same on the public sheet, where its vanishing re-centred the row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feature/betriebsmodus^2
nexxo 2026-07-30 16:05:56 +02:00
parent d4b70b7477
commit 8479fc30f8
5 changed files with 58 additions and 15 deletions

View File

@ -14,6 +14,7 @@ return [
'terms_first' => 'Bitte oben die AGB annehmen.',
'term_monthly' => 'Monatlich',
'term_yearly' => 'Jährlich',
'free_months_taken' => '{1} 1 Monat gratis enthalten|[2,*] :count Monate gratis enthalten',
'free_months_hint' => '{1} 1 Monat gratis bei Jahreszahlung|[2,*] :count Monate gratis bei Jahreszahlung',
'yearly_total' => ':total jährlich',
'instead_of' => 'statt :amount',

View File

@ -14,6 +14,7 @@ return [
'terms_first' => 'Please accept the terms above.',
'term_monthly' => 'Monthly',
'term_yearly' => 'Yearly',
'free_months_taken' => '{1} 1 month free included|[2,*] :count months free included',
'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',

View File

@ -520,6 +520,15 @@
charge. The saving is named next to the switch: "jährlich" on its
own gives nobody a reason to look. --}}
<div x-data="{ term: 'monthly' }" class="mt-12">
{{-- The saving above the switch, and always present: hidden on the
yearly view it re-centred the row every time somebody changed
their mind. One line, two wordings. --}}
@if ($bestFreeMonths > 0)
<p class="mb-3 text-center text-sm font-semibold text-success">
<span x-show="term !== 'yearly'">{{ $bestFreeMonths === 1 ? '1 Monat gratis' : $bestFreeMonths.' Monate gratis' }} bei Jahreszahlung</span>
<span x-show="term === 'yearly'" x-cloak>{{ $bestFreeMonths === 1 ? '1 Monat gratis' : $bestFreeMonths.' Monate gratis' }} in jedem Jahrespreis enthalten</span>
</p>
@endif
<div class="flex flex-wrap items-center justify-center gap-3">
{{-- text-bg on the active option, not a colour that does not
exist: `text-on-ink` is not in the palette (the ink pairing
@ -537,11 +546,6 @@
</button>
@endforeach
</div>
@if ($bestFreeMonths > 0)
<span class="text-sm font-semibold text-success-text" x-show="term !== 'yearly'">
{{ $bestFreeMonths === 1 ? '1 Monat gratis' : $bestFreeMonths.' Monate gratis' }} bei Jahreszahlung
</span>
@endif
</div>
{{-- Stretched, not items-start: the cards carry different numbers of

View File

@ -47,6 +47,16 @@
radius less its own padding. A rounded-md option Tailwind's
untokenised 6px read as a different component sitting
inside this one. --}}
{{-- Above the switch, and ALWAYS present. It hung underneath
and was hidden on the yearly view, so choosing a term moved
the buttons that choose it. One line, two wordings: an offer
while it is an offer, a statement once it is taken. --}}
@if ($bestFreeMonths > 0)
<span class="text-xs font-semibold text-success">
<span x-show="term !== 'yearly'">{{ trans_choice('order.free_months_hint', $bestFreeMonths, ['count' => $bestFreeMonths]) }}</span>
<span x-show="term === 'yearly'" x-cloak>{{ trans_choice('order.free_months_taken', $bestFreeMonths, ['count' => $bestFreeMonths]) }}</span>
</span>
@endif
<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 }}'"
@ -56,11 +66,6 @@
</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>
@ -163,8 +168,14 @@
The term travels in the address, so the checkout shows
what was being looked at and a reload of that page
still shows it. --}}
{{-- The whole address from route(), with the term swapped
into it not route() plus "?term=". Both parameters are
query parameters here, so appending a second "?" built
`?plan=team?term=monthly`, and the package arrived as
the string "team?term=monthly": every click landed on
"this package is not on sale". --}}
<a href="{{ route('checkout', ['plan' => $plan['key'], 'term' => 'monthly']) }}"
x-bind:href="'{{ route('checkout', ['plan' => $plan['key']]) }}?term=' + term"
x-bind:href="'{{ route('checkout', ['plan' => $plan['key'], 'term' => '__TERM__']) }}'.replace('__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'],

View File

@ -76,10 +76,36 @@ it('lets a customer pick the term on the order page', function () {
// Both figures rendered, one shown: switching costs no round trip.
expect($page)->toContain("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");
->and($page)->toContain(__('order.term_yearly'));
});
it('links to the checkout with one query string, not two question marks', function () {
// Both parameters are query parameters, so route() + '?term=' built
// `?plan=team?term=monthly` and the package arrived as the whole string
// "team?term=monthly". Every click landed on "this package is not on sale",
// and nothing in the page or the log said why.
$page = $this->actingAs(termBuyer())->get(route('order'))->assertOk()->getContent();
preg_match_all('/href="([^"]*checkout[^"]*)"/', $page, $plain);
preg_match_all('/x-bind:href="\x27([^\x27]*)\x27/', $page, $bound);
expect($plain[1])->not->toBeEmpty()
->and($bound[1])->not->toBeEmpty();
foreach (array_merge($plain[1], $bound[1]) as $href) {
expect(substr_count($href, '?'))->toBe(1, $href);
}
});
it('keeps the saving above the switch, where it cannot move the buttons', function () {
// It hung underneath and was hidden on the yearly view, so choosing a term
// moved the very buttons that choose it.
$page = Illuminate\Support\Facades\File::get(resource_path('views/livewire/order.blade.php'));
expect(strpos($page, "order.free_months_hint"))->toBeLessThan(strpos($page, "order.term_'.\$option"))
// And it is always there: one line, two wordings, no appearing and
// disappearing.
->and($page)->toContain('order.free_months_taken');
});
it('carries the chosen term into the checkout page and its form', function () {