From 2a97d76b2dad344b22cbdef5c0c3541e3d7629a2 Mon Sep 17 00:00:00 2001 From: nexxo Date: Thu, 30 Jul 2026 17:38:57 +0200 Subject: [PATCH] Rebuild the settings page out of panels and rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **The withdrawal was invisible in two different ways.** It rendered only while the fourteen days were still running, so the moment they expired the whole block vanished — indistinguishable from a feature nobody built. And the account it was looked for on has no contract at all: no package, no subscription, nothing to withdraw from, so there was never anything for it to be about. It is now a row for every CONSUMER who has a contract, open or not: while it runs, the deadline and the button; afterwards, the sentence saying why it is over. A business never sees it — WithdrawalRight answers that, and Settings::withdraw() refuses them again on the server. And an account with no package says so and offers the packages, instead of a dead sentence in a box. **The page is built out of two pieces now.** x-ui.panel is a group: a card with a header that names it. x-ui.row is a setting inside it: what it is on the left, the control on the right, dividers between rows. That is the shape every settings page worth copying uses, and it is the shape that uses the width — a 280px label column with the control beside it fills the line, where a stack of full-width inputs leaves two thirds of it empty and a grid of cards of different heights walks down the screen as a staircase. All four tabs are rebuilt on it, so the page reads as one designed thing rather than four pages that happen to share a tab bar. Below `sm` the rows stack, because on a telephone a label belongs above its field. Co-Authored-By: Claude Opus 5 --- lang/de/settings.php | 3 + lang/en/settings.php | 3 + resources/views/components/ui/panel.blade.php | 40 ++ resources/views/components/ui/row.blade.php | 27 + resources/views/livewire/settings.blade.php | 679 +++++++++--------- tests/Feature/SettingsTest.php | 68 ++ 6 files changed, 480 insertions(+), 340 deletions(-) create mode 100644 resources/views/components/ui/panel.blade.php create mode 100644 resources/views/components/ui/row.blade.php diff --git a/lang/de/settings.php b/lang/de/settings.php index fc58f40..098d932 100644 --- a/lang/de/settings.php +++ b/lang/de/settings.php @@ -49,6 +49,9 @@ return [ 'brand_using_default' => 'Es werden aktuell die CluPilot-Standardwerte verwendet.', 'branding_saved' => 'Branding gespeichert.', + 'contract_title' => 'Vertrag und Konto', + 'contract_sub' => 'Ihr Paket, Ihre Rechte daraus und die Unterlagen dazu.', + 'to_packages' => 'Pakete ansehen', 'package_title' => 'Paket', 'package_active' => 'Aktives Paket: :plan.', 'no_package' => 'Kein aktives Paket.', diff --git a/lang/en/settings.php b/lang/en/settings.php index 158611a..e72e94f 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -49,6 +49,9 @@ return [ 'brand_using_default' => 'Currently using the CluPilot defaults.', 'branding_saved' => 'Branding saved.', + 'contract_title' => 'Contract and account', + 'contract_sub' => 'Your package, the rights that come with it, and the documents.', + 'to_packages' => 'See the packages', 'package_title' => 'Package', 'package_active' => 'Active package: :plan.', 'no_package' => 'No active package.', diff --git a/resources/views/components/ui/panel.blade.php b/resources/views/components/ui/panel.blade.php new file mode 100644 index 0000000..3b2740b --- /dev/null +++ b/resources/views/components/ui/panel.blade.php @@ -0,0 +1,40 @@ +@props([ + 'title' => null, + 'subtitle' => null, +]) + +{{-- A settings panel: one card, a header that names it, a body of rows, and an + optional footer for whatever finishes the group. + + The page was a heap of cards of different heights, each holding one thing, + which produced a staircase down the screen and left half the width empty + beside it. A panel with divided rows is what every settings page worth + copying does — the group is the card, the settings are its rows, and the + rhythm comes from the dividers rather than from the gaps between boxes. --}} +
merge(['class' => 'overflow-hidden rounded-lg border border-line bg-surface shadow-xs']) }}> + @if ($title || isset($header)) +
+ @isset($header) + {{ $header }} + @else +
+

{{ $title }}

+ @if ($subtitle) +

{{ $subtitle }}

+ @endif +
+ @isset($action) +
{{ $action }}
+ @endisset + @endisset +
+ @endif + +
{{ $slot }}
+ + @isset($footer) +
+ {{ $footer }} +
+ @endisset +
diff --git a/resources/views/components/ui/row.blade.php b/resources/views/components/ui/row.blade.php new file mode 100644 index 0000000..f0e6609 --- /dev/null +++ b/resources/views/components/ui/row.blade.php @@ -0,0 +1,27 @@ +@props([ + 'label' => null, + 'hint' => null, + 'for' => null, +]) + +{{-- One row of a settings panel: what it is on the left, the control on the + right. + + The two-column shape is what uses the width. A single column of full-width + inputs on a 1240px shell leaves two thirds of the line empty and makes a + four-field form look like a questionnaire; a label column of 280px with the + control beside it reads as a settings page and fits the screen it is on. + + Stacks below `sm`, because on a telephone the label belongs above its + field. --}} +
merge(['class' => 'grid gap-x-6 gap-y-2 px-6 py-4 sm:grid-cols-[minmax(0,280px)_minmax(0,1fr)] sm:items-start']) }}> + @if ($label) +
+ + @if ($hint) +

{{ $hint }}

+ @endif +
+ @endif +
{{ $slot }}
+
diff --git a/resources/views/livewire/settings.blade.php b/resources/views/livewire/settings.blade.php index 2663fd1..2298e20 100644 --- a/resources/views/livewire/settings.blade.php +++ b/resources/views/livewire/settings.blade.php @@ -1,17 +1,26 @@ {{-- The customer's own settings. - It was one column 768 pixels wide inside a 1240-pixel shell — a third of the - window empty beside it — holding everything a customer might ever change: - the company address, the password, two-factor, the signed-in devices, the - branding, the contract and the button that closes the account. Two thousand - pixels of scroll, and the way to a particular thing was knowing how far down - it lived. + ## What was wrong, three times over - Four tabs, by what somebody came to do. Inside a tab the short cards sit side - by side instead of each taking a full row to hold three lines. The choice is - in the query string (see the #[Url] attribute on the component), so a reload, - a bookmark and the back button all land where they were. + First it was one column 768px wide in a 1240px shell, holding everything a + customer might ever change, two thousand pixels deep. Then it became four + tabs — better — but the cards inside them were still boxes of different + heights in a grid, which on the contract tab produced a short one, a wide one + and a short one again: a staircase with no rhythm and half the width empty + beside it. + + ## What it is now + + Panels and rows (x-ui.panel, x-ui.row). A group is a card with a header that + names it; a setting is a ROW inside that card — what it is on the left, the + control on the right, dividers between. That is the shape every settings page + worth copying uses, and it is the shape that uses the width: a label column + of 280px with the control beside it fills the line, where a stack of + full-width inputs leaves two thirds of it empty. + + Below `sm` the rows stack, because on a telephone a label belongs above its + field. --}}
@@ -24,7 +33,8 @@ {{-- The same tab bar the console uses, so one vocabulary holds on both sides - of the login. --}} + of the login. The choice lives in the query string — see the #[Url] + attribute on the component. --}}
@foreach ($tabs as $name) + @endif + @error('logo')

{{ $message }}

@enderror +
+
+ -
- -
- @if ($logo) - - @elseif ($logoUrl) - - @else - {{ __('settings.brand_default') }} + + @if ($branding['is_default'] ?? false) +

{{ __('settings.brand_using_default') }}

@endif -
- -

{{ __('settings.brand_logo_hint') }}

- @if ($logoUrl) - - @endif - @error('logo')

{{ $message }}

@enderror - @if ($branding['is_default'] ?? false) -

{{ __('settings.brand_using_default') }}

- @endif -
+ + {{ __('settings.save') }} + + + @endif - {{-- ── Vertrag ──────────────────────────────────────────────────────── --}} - {{-- ── Vertrag ────────────────────────────────────────────────────── - One column of full-width sections, not a two-column grid with one card - spanning both: that produced a short box, a wide one, then a short one - again — a staircase with no rhythm to it. Each section here has the same - shape (a header row, then its body), so the eye can run down the page - instead of tracking sideways. --}} + {{-- ══ Vertrag ══════════════════════════════════════════════════════════ --}} @if ($tab === 'contract')
- {{-- Package, cancellation and — while it is open — the withdrawal. --}} -
-
-
-

{{ __('settings.package_title') }}

-

+ + + {{-- The package, and the way out of it. --}} + +

+

@if ($cancellationScheduled) - {{ __('settings.cancel_scheduled_title') }} + {{ __('settings.cancel_scheduled_body', ['date' => $instance?->service_ends_at?->local()->isoFormat('LL')]) }} @elseif ($hasActivePackage) {{ __('settings.package_active', ['plan' => $instance ? __('billing.plan.'.$instance->plan) : '—']) }} @else {{ __('settings.no_package') }} @endif

-
- @if ($hasActivePackage && ! $cancellationScheduled) - - {{ __('settings.cancel_cta') }} - - @endif -
- @if ($cancellationScheduled) -
- -

{{ __('settings.cancel_scheduled_body', ['date' => $instance?->service_ends_at?->local()->isoFormat('LL')]) }}

+ @if ($hasActivePackage && ! $cancellationScheduled) + + {{ __('settings.cancel_cta') }} + + @elseif (! $hasActivePackage && ! $cancellationScheduled) + + {{ __('settings.to_packages') }} + + @endif
- @endif + - {{-- The fourteen-day right of withdrawal. Shown only where it - exists: a business customer has none and never sees this - block — and the server refuses them again in - Settings::withdraw(), because a card that is not rendered has + {{-- ── Das Widerrufsrecht ──────────────────────────────────── + Shown to every CONSUMER with a contract, open or not — not + only while it is open. It vanished entirely once the + fourteen days ran out, which is indistinguishable from a + feature that was never built: the owner went looking for it + and reported it missing from an account that has no contract + at all, so there was nothing for it to be about. + + A business never sees it, and the server refuses them again + in Settings::withdraw() — a card that is not rendered has never stopped anybody who can post to /livewire/update. - Separate from the cancellation above it, and not a variant of - it. A cancellation ends a contract that was validly concluded - and keeps the term the customer paid for; a withdrawal - unwinds the contract itself, ends the service the same day - and sends the money back. --}} - @if ($withdrawal->applies && $withdrawal->open) -
-
-

{{ __('withdrawal.card_title') }}

-

- {{ __('withdrawal.card_sub', [ - 'date' => $withdrawal->endsAt->local()->isoFormat('LL'), - 'days' => $withdrawal->daysLeft(), - ]) }} + Not a variant of the cancellation above: that one ends a + contract validly concluded and keeps the term already paid + for; this one unwinds the contract, ends the service the same + day and sends the whole amount back. --}} + @if ($withdrawal->applies) + +

+

+ @if ($withdrawal->open) + {{ __('withdrawal.card_sub', [ + 'date' => $withdrawal->endsAt->local()->isoFormat('LL'), + 'days' => $withdrawal->daysLeft(), + ]) }} + @else + {{ $withdrawal->refusal }} + @endif

-
- - {{ __('withdrawal.cta') }} - -
- @endif -
- - {{-- ── AV-Vertrag & TOM ────────────────────────────────────────── - Art. 28 DSGVO wants a contract wherever personal data is - processed on somebody else's behalf. It is concluded WITH the - terms at checkout (see the AGB), so this is not a second signing - ceremony: the document has to be available, current, and - retrievable. An extra confirmation is offered because some - customers — a practice, a firm, anybody who gets audited — want - one on file, and it costs a click. - - Nothing renders until an operator has published a version. --}} - @if ($dpa !== null) -
-
-
-

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

-

{{ __('dpa.sub') }}

-
- - {{ __('dpa.version', ['version' => $dpa->version]) }} - -
- -
- - {{ __('dpa.read_agreement') }} - - @if ($dpa->measures_path) - - {{ __('dpa.read_measures') }} - - @endif - {{-- Keep, not just read: the file carries the version in - its name, so which fassung applied is answerable from - a downloads folder months later. --}} - - {{ __('dpa.download_agreement') }} - - @if ($dpa->measures_path) - - {{ __('dpa.download_measures') }} - - @endif -
- -
- @if ($dpaAcceptance) - {{-- R19: stored in UTC, read on the wall clock. --}} -

- - {{ __('dpa.accepted_on', [ - 'version' => $dpa->version, - 'when' => $dpaAcceptance->accepted_at->local()->isoFormat('LL, LT'), - ]) }} -

- @else -
-

{{ __('dpa.in_force_note') }}

+ @if ($withdrawal->open) - {{ __('dpa.accept_cta') }} + x-on:click="$dispatch('openModal', { component: 'confirm-withdraw' })"> + {{ __('withdrawal.cta') }} + @endif +
+ + @endif + + {{-- ── AV-Vertrag & TOM ────────────────────────────────────── + Art. 28 DSGVO wants a contract wherever personal data is + processed on somebody else's behalf. It is concluded WITH the + terms at checkout (see the AGB), so this is not a second + signing ceremony: the document has to be available, current + and retrievable. The extra confirmation is offered because a + practice or a firm that gets audited often wants one on file. + + Nothing renders until an operator has published a version. --}} + @if ($dpa !== null) + +
+
+ + {{ __('dpa.read_agreement') }} + + + {{ __('dpa.download') }} + + @if ($dpa->measures_path) + + + {{ __('dpa.read_measures') }} + + + {{ __('dpa.download') }} + + @endif + {{ __('dpa.version', ['version' => $dpa->version]) }}
- @endif -
-
- @endif - {{-- Closing the account, and the deadlines that close one by itself. --}} -
-
-
-

{{ __('settings.close_account_title') }}

-

{{ __('settings.close_account_sub') }}

-
- - {{ __('settings.close_cta') }} - -
+ @if ($dpaAcceptance) + {{-- R19: stored in UTC, read on the wall clock. --}} +

+ + {{ __('dpa.accepted_on', [ + 'version' => $dpa->version, + 'when' => $dpaAcceptance->accepted_at->local()->isoFormat('LL, LT'), + ]) }} +

+ @else +
+

{{ __('dpa.in_force_note') }}

+ + {{ __('dpa.accept_cta') }} + +
+ @endif +
+ + @endif - {{-- The deadlines, said where somebody asks the question. - "Nach fünf Tagen gelöscht" on the verification page reads as - if it applied to every account; it applies to a registration - nobody confirmed. The other rule — a year without a package — - was nowhere at all. Both come from the commands that enforce - them, so the page cannot drift from what actually happens. --}} + {{-- Closing the account. Last row of the panel, where the + irreversible things belong. --}} + +
+ + {{ __('settings.close_cta') }} + +
+
+ + + {{-- The deadlines, said where somebody asks the question. "Nach fünf + Tagen gelöscht" on the verification page reads as if it applied + to every account; it applies to a registration nobody confirmed. + The other rule — a year without a package — was nowhere at all. + Both come from the commands that enforce them, so the page cannot + drift from what actually happens. --}} +
-

{{ __('settings.lifecycle_title') }}

-
    +
    • {{ __('settings.lifecycle_unverified', ['days' => App\Console\Commands\PruneUnverifiedAccounts::AFTER_DAYS]) }}
    • {{ __('settings.lifecycle_dormant', ['warn' => App\Console\Commands\PruneDormantAccounts::WARN_DAYS_BEFORE]) }}
    • {{ __('settings.lifecycle_kept') }}
    • @@ -451,7 +450,7 @@ {{ __('settings.lifecycle_terms') }}
- +
@endif diff --git a/tests/Feature/SettingsTest.php b/tests/Feature/SettingsTest.php index e8971bb..5d9a557 100644 --- a/tests/Feature/SettingsTest.php +++ b/tests/Feature/SettingsTest.php @@ -377,3 +377,71 @@ it('never offers a business the withdrawal it does not have', function () { expect(Livewire::actingAs($user)->test(ConfirmCancelPackage::class)->html()) ->not->toContain(__('withdrawal.instead_title')); }); + +it('shows a consumer their withdrawal right, expired as well as open', function () { + // It used to render only while the window was open, so once the fourteen + // days ran out it vanished — indistinguishable from a feature that was never + // built. Which is exactly how it was reported missing. + ['user' => $user, 'customer' => $customer] = settingsSetup(); + $customer->update(['customer_type' => App\Models\Customer::TYPE_CONSUMER]); + $instance = $customer->instances()->where('status', 'active')->first(); + + $contract = App\Models\Subscription::factory()->create([ + 'customer_id' => $customer->id, + 'instance_id' => $instance->id, + 'status' => 'active', + 'started_at' => now()->subDay(), + 'withdrawal_ends_at' => now()->addDays(13), + ]); + + Livewire::actingAs($user)->test(Settings::class, ['tab' => 'contract']) + ->assertSee(__('withdrawal.card_title')) + ->assertSee(__('withdrawal.cta')); + + // Expired: still stated, with the reason, and no button. + $contract->update(['withdrawal_ends_at' => now()->subDay()]); + + Livewire::actingAs($user)->test(Settings::class, ['tab' => 'contract']) + ->assertSee(__('withdrawal.card_title')) + ->assertDontSee(__('withdrawal.cta')); +}); + +it('never shows a business a right it does not have', function () { + ['user' => $user, 'customer' => $customer] = settingsSetup(); + $customer->update(['customer_type' => App\Models\Customer::TYPE_BUSINESS]); + $instance = $customer->instances()->where('status', 'active')->first(); + App\Models\Subscription::factory()->create([ + 'customer_id' => $customer->id, + 'instance_id' => $instance->id, + 'status' => 'active', + 'started_at' => now()->subDay(), + 'withdrawal_ends_at' => now()->addDays(13), + ]); + + Livewire::actingAs($user)->test(Settings::class, ['tab' => 'contract']) + ->assertDontSee(__('withdrawal.card_title')); +}); + +it('offers the packages to somebody who has none, instead of a dead sentence', function () { + $user = App\Models\User::factory()->create(['email_verified_at' => now()]); + App\Models\Customer::factory()->create(['email' => $user->email, 'user_id' => $user->id]); + + Livewire::actingAs($user)->test(Settings::class, ['tab' => 'contract']) + ->assertSee(__('settings.no_package')) + ->assertSee(__('settings.to_packages')) + // And nothing about withdrawing, because there is nothing to withdraw + // from — which is what the owner ran into looking for the button. + ->assertDontSee(__('withdrawal.card_title')); +}); + +it('builds every tab out of the same two pieces', function () { + // Panels and rows, not a heap of cards of different heights: the group is + // the card, the setting is a row inside it, and the label column is what + // uses the width. + $page = Illuminate\Support\Facades\File::get(resource_path('views/livewire/settings.blade.php')); + + expect(substr_count($page, 'toBeGreaterThanOrEqual(5) + ->and(substr_count($page, 'toBeGreaterThanOrEqual(12) + // No hand-rolled card markup left behind. + ->and($page)->not->toContain('rounded-lg border border-line bg-surface p-6 shadow-xs'); +});