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') }}

-
- +
@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'); +});