From 5883989552e6ef2db6c1503069a909ee8ca05ab4 Mon Sep 17 00:00:00 2001 From: nexxo Date: Thu, 30 Jul 2026 17:59:16 +0200 Subject: [PATCH] Give settings the shape a settings page actually has MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four attempts, and this is what was wrong with the last one: full-width panels stacked down the page, each carrying its own title INSIDE it. Every group had two frames — the title bar and the border — so the page read as a column of long boxes with nothing saying where one topic ended and the next began, and a phone number got a field a thousand pixels wide. The shape now: - **A section list on the left**, sticky, so the sections stay reachable however far a form runs. Below `lg` it becomes a scrolling strip of chips, because a vertical list on a telephone is four rows of nothing. Under it, who is signed in — on a shared office machine that is a real question, and a settings page is where it gets asked. - **A content column of 820px**, not the whole 1240 shell. - **The heading OUTSIDE the card it belongs to.** A section is a heading, a sentence, and then a card of rows. That single move is what stops them reading as boxes: the border now frames the fields, not the topic. - **What cannot be undone is last, in a card whose border says so** — the deletion deadlines and the close button together, at the foot of the contract section, in danger colours. The rows are unchanged (label left, control right) and so is every binding; what changed is the frame around them. Co-Authored-By: Claude Opus 5 --- lang/de/dpa.php | 2 + lang/de/settings.php | 1 + lang/en/dpa.php | 2 + lang/en/settings.php | 1 + resources/views/livewire/settings.blade.php | 913 +++++++++++--------- 5 files changed, 507 insertions(+), 412 deletions(-) diff --git a/lang/de/dpa.php b/lang/de/dpa.php index 6f79b85..6d69a3d 100644 --- a/lang/de/dpa.php +++ b/lang/de/dpa.php @@ -15,4 +15,6 @@ return [ 'in_force_note' => 'Dieser Vertrag ist mit Ihrer Bestellung Bestandteil unserer Vereinbarung; eine gesonderte Unterschrift ist nicht nötig. Wenn Sie für Ihre Unterlagen eine ausdrückliche Bestätigung möchten, halten wir Fassung, Zeitpunkt und IP-Adresse fest.', 'download_agreement' => 'AV-Vertrag laden', 'download_measures' => 'TOM laden', + 'open' => 'Öffnen', + 'state_label' => 'Status', ]; diff --git a/lang/de/settings.php b/lang/de/settings.php index 098d932..c1136a4 100644 --- a/lang/de/settings.php +++ b/lang/de/settings.php @@ -11,6 +11,7 @@ return [ 'branding' => 'Erscheinungsbild', 'contract' => 'Vertrag', ], + 'signed_in_as' => 'Angemeldet als', 'title' => 'Einstellungen', 'subtitle' => 'Alles, was zu Ihrem Konto gehört — nach Themen getrennt.', 'save' => 'Speichern', diff --git a/lang/en/dpa.php b/lang/en/dpa.php index 4c0b3a5..1a95a8f 100644 --- a/lang/en/dpa.php +++ b/lang/en/dpa.php @@ -15,4 +15,6 @@ return [ 'in_force_note' => 'This agreement became part of our contract with your order; a separate signature is not required. If you would like an explicit confirmation for your records, we note the version, the moment and the IP address.', 'download_agreement' => 'Download agreement', 'download_measures' => 'Download measures', + 'open' => 'Open', + 'state_label' => 'State', ]; diff --git a/lang/en/settings.php b/lang/en/settings.php index e72e94f..25f2292 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -11,6 +11,7 @@ return [ 'branding' => 'Appearance', 'contract' => 'Contract', ], + 'signed_in_as' => 'Signed in as', 'title' => 'Settings', 'subtitle' => 'Everything that belongs to your account, grouped by subject.', 'save' => 'Save', diff --git a/resources/views/livewire/settings.blade.php b/resources/views/livewire/settings.blade.php index 2298e20..32fcf95 100644 --- a/resources/views/livewire/settings.blade.php +++ b/resources/views/livewire/settings.blade.php @@ -1,27 +1,46 @@ {{-- The customer's own settings. - ## What was wrong, three times over + ## Four attempts, and what was wrong with each - 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. + 1. One column 768px wide in a 1240px shell, everything in it, two thousand + pixels deep. + 2. Four tabs — better — but cards of different heights in a grid, which on + the contract tab read as a staircase. + 3. Panels and rows: right idea, wrong proportions. Full-width slabs stacked + down the page, each with its title INSIDE it, so the page was a column of + long boxes and nothing said where one topic ended. ## 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. + The shape serious settings pages actually use: a section list on the left, + a measured content column on the right, and the heading OUTSIDE the card it + belongs to. - Below `sm` the rows stack, because on a telephone a label belongs above its - field. + - The nav is the structure. It is sticky, so the sections stay reachable + however far down a form goes, and it says which one you are in without + making you read a row of tabs. + - 820px is the content column. Nobody types a phone number into a field + 1100px wide; a settings page that lets them looks like a spreadsheet. + - A section is a heading, a sentence, and then a card of rows. Putting the + heading inside the card gave every group two frames — the title bar and + the border — and that is what made them read as boxes rather than as + topics. + - What cannot be undone lives at the end, in a card that says so with its + border. + + Below `lg` the nav becomes a scrolling strip of chips above the content, and + the rows stack, because on a telephone a label belongs above its field. --}} +@php + $sections = [ + 'profile' => 'users', + 'security' => 'shield-check', + 'branding' => 'pen', + 'contract' => 'receipt', + ]; +@endphp +
@@ -32,425 +51,495 @@

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

- {{-- The same tab bar the console uses, so one vocabulary holds on both sides - of the login. The choice lives in the query string — see the #[Url] - attribute on the component. --}} -
- @foreach ($tabs as $name) - - @endforeach -
+
- {{-- ══ Ihre Daten ═══════════════════════════════════════════════════════ --}} - @if ($tab === 'profile') -
- - - - - - - - - - - - - + {{-- ── The section list ──────────────────────────────────────────── + Sticky on a wide screen: a long form must not scroll its own + navigation away. A strip of chips below `lg`, because a vertical + list on a telephone is four rows of nothing. --}} + - {{-- A FACT for a record that has one. It decides the fourteen-day - right of withdrawal: a business able to set itself to - "Privatperson" here is a business able to withdraw from a - contract it may not withdraw from. Registration asks; this - page reports. The lock is in Settings::saveProfile, not here - — a form that only hides a control has never stopped anybody - who can post to /livewire/update. --}} - - @if ($customer?->customer_type) -

- - {{ __('settings.customer_type_'.$customer->customer_type) }} + {{-- ── The content column ─────────────────────────────────────────── + 820px, not the whole shell. Nobody types a postcode into a field a + thousand pixels wide. --}} +

+ + {{-- ══ Ihre Daten ═══════════════════════════════════════════════ --}} + @if ($tab === 'profile') + +
+

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

+

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

+ + + + + + + + + + + + + + + + {{-- Field by field, not a paragraph: what landed in + the textarea was whatever somebody typed — a + postcode on the street line, a town with no + postcode, no country at all — and an invoice has + to name its recipient precisely enough to be a + document. --}} + +
+ +
+ + +
+ +
+
+ + + + {{ __('settings.save') }} + + +
+
+ + {{-- A FACT for a record that has one. It decides the + fourteen-day right of withdrawal: a business able to set + itself to "Privatperson" here is a business able to + withdraw from a contract it may not withdraw from. + Registration asks; this page reports. The lock is in + Settings::saveProfile, not here — a form that only hides + a control has never stopped anybody who can post to + /livewire/update. --}} +
+

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

+

+ {{ $customer?->customer_type ? __('settings.customer_type_locked') : __('settings.customer_type_once') }}

- @else -
- @foreach ($customerTypes as $type) - - @endforeach -
- @error('customerType')

{{ $message }}

@enderror - @endif - - - - {{ __('settings.save') }} - - - - - @endif + +
+ @if ($customer?->customer_type) +

+ + {{ __('settings.customer_type_'.$customer->customer_type) }} +

+ @else +
+ @foreach ($customerTypes as $type) + + @endforeach +
+ @error('customerType')

{{ $message }}

@enderror + @endif +
+
+
+ + @endif - {{-- ══ Sicherheit ═══════════════════════════════════════════════════════ --}} - @if ($tab === 'security') -
+ {{-- ══ Sicherheit ═══════════════════════════════════════════════ --}} + @if ($tab === 'security') +
- {{-- Own password. There was no way to change one at all — Fortify's - updatePasswords feature was switched off. --}} -
- - - - - -
- - -
-
+ {{-- Own password. There was no way to change one at all — + Fortify's updatePasswords feature was switched off. --}} +
+

{{ __('admin_settings.password_title') }}

+

{{ __('admin_settings.password_sub') }}

- - - {{ __('admin_settings.password_save') }} - - - - +
+ + + + + +
+ + +
+
- {{-- Two-factor. Every action is re-checked server-side against a - recent password confirmation — hiding the buttons stops nobody - who can post to /livewire/update. --}} - - -
+ + + {{ __('admin_settings.password_save') }} + + + + +
+ + {{-- Two-factor. Every action is re-checked server-side + against a recent password confirmation — hiding the + buttons stops nobody who can post to /livewire/update. --}} +
-

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

+

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

{{ $twoFactorOn ? __('settings.twofa_state_on') : __('settings.twofa_state_off') }}
-

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

-
- +

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

- @if (! $passwordConfirmed) - {{-- The gate. A signed-in session is not enough to change how - the account is protected — the risk is an unlocked - machine. --}} -
- -
-
- -
- - {{ __('settings.twofa_confirm_button') }} - -
-
-
- @elseif ($twoFactorOn) - -
- - {{ __('settings.twofa_new_codes') }} - - - {{ __('settings.twofa_disable') }} - -
-
- @elseif ($twoFactorPending) - -
-
{!! $twoFactorQr !!}
-
-
- -
- - {{ __('settings.twofa_activate') }} - -
-
-
- @else - - - {{ __('settings.twofa_enable') }} - - - @endif - - @if ($recoveryCodes) - {{-- Shown once, on purpose: nobody writes down what they were - never shown, and these are the way back in when the phone - is gone. --}} - -
    - @foreach ($recoveryCodes as $code)
  • {{ $code }}
  • @endforeach -
-
- @endif - - - {{-- The devices. It answers the same question two-factor does — who - can get into this account — and it is a table of its own. --}} - @livewire('sessions') -
- @endif - - {{-- ══ Erscheinungsbild ═════════════════════════════════════════════════ --}} - @if ($tab === 'branding') -
- - - - - - -
- - -
- @error('brandPrimary')

{{ $message }}

@enderror -
- - -
- - -
- @error('brandAccent')

{{ $message }}

@enderror -
- - -
-
- @if ($logo) - - @elseif ($logoUrl) - + + @if (! $passwordConfirmed) + {{-- The gate. A signed-in session is not enough + to change how the account is protected — the + risk is an unlocked machine. --}} + + +
+
+ +
+ + {{ __('settings.twofa_confirm_button') }} + +
+
+ + @elseif ($twoFactorOn) + +
+ + {{ __('settings.twofa_new_codes') }} + + + {{ __('settings.twofa_disable') }} + +
+
+ @elseif ($twoFactorPending) + +
+
{!! $twoFactorQr !!}
+
+
+ +
+ + {{ __('settings.twofa_activate') }} + +
+
+
@else - {{ __('settings.brand_default') }} - @endif -
-
- - @if ($logoUrl) - - @endif - @error('logo')

{{ $message }}

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

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

- @endif - - {{ __('settings.save') }} - -
-
- - @endif - - {{-- ══ Vertrag ══════════════════════════════════════════════════════════ --}} - @if ($tab === 'contract') -
- - - - {{-- The package, and the way out of it. --}} - -
-

- @if ($cancellationScheduled) - {{ __('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') }} - - @elseif (! $hasActivePackage && ! $cancellationScheduled) - - {{ __('settings.to_packages') }} - - @endif -
-
- - {{-- ── 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. - - 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 -

- @if ($withdrawal->open) - - {{ __('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') }} + + + {{ __('settings.twofa_enable') }} - - {{ __('dpa.download') }} - - @endif - {{ __('dpa.version', ['version' => $dpa->version]) }} -
- - @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 - {{-- Closing the account. Last row of the panel, where the - irreversible things belong. --}} - -
- - {{ __('settings.close_cta') }} - -
-
-
+ @if ($recoveryCodes) + {{-- Shown once, on purpose: nobody writes down + what they were never shown, and these are the + way back in when the phone is gone. --}} + +
    + @foreach ($recoveryCodes as $code)
  • {{ $code }}
  • @endforeach +
+
+ @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. --}} - -
-
    -
  • {{ __('settings.lifecycle_unverified', ['days' => App\Console\Commands\PruneUnverifiedAccounts::AFTER_DAYS]) }}
  • -
  • {{ __('settings.lifecycle_dormant', ['warn' => App\Console\Commands\PruneDormantAccounts::WARN_DAYS_BEFORE]) }}
  • -
  • {{ __('settings.lifecycle_kept') }}
  • -
- - {{ __('settings.lifecycle_terms') }} - + {{-- The devices. It answers the same question two-factor does + — who can get into this account. --}} +
+ @livewire('sessions') +
-
+ @endif + + {{-- ══ Erscheinungsbild ═════════════════════════════════════════ --}} + @if ($tab === 'branding') +
+
+

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

+

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

+ + + + + + + +
+ + +
+ @error('brandPrimary')

{{ $message }}

@enderror +
+ + +
+ + +
+ @error('brandAccent')

{{ $message }}

@enderror +
+ + +
+
+ @if ($logo) + + @elseif ($logoUrl) + + @else + {{ __('settings.brand_default') }} + @endif +
+
+ + @if ($logoUrl) + + @endif + @error('logo')

{{ $message }}

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

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

+ @endif + + {{ __('settings.save') }} + +
+
+
+
+ @endif + + {{-- ══ Vertrag ══════════════════════════════════════════════════ --}} + @if ($tab === 'contract') +
+ +
+

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

+

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

+ + + +
+

+ @if ($cancellationScheduled) + {{ __('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') }} + + @elseif (! $hasActivePackage && ! $cancellationScheduled) + + {{ __('settings.to_packages') }} + + @endif +
+
+ + {{-- ── Das Widerrufsrecht ──────────────────────── + Shown to every CONSUMER with a contract, open or + not. It used to render only while the window was + open, so once the fourteen days expired the block + vanished — indistinguishable from a feature + nobody built. + + A business never sees it, and Settings::withdraw() + refuses them again on the server: a card that is + not rendered has never stopped anybody who can + post to /livewire/update. + + Not a variant of the cancellation above it: 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 +

+ @if ($withdrawal->open) + + {{ __('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.title') }}

+ {{ __('dpa.version', ['version' => $dpa->version]) }} +
+

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

+ + + +
+ + {{ __('dpa.open') }} + + + {{ __('dpa.download') }} + +
+
+ + @if ($dpa->measures_path) + +
+ + {{ __('dpa.open') }} + + + {{ __('dpa.download') }} + +
+
+ @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') }}

+ + {{ __('dpa.accept_cta') }} + +
+ @endif +
+
+
+ @endif + + {{-- ── What ends an account ────────────────────────────── + The deadlines and the button that closes it, together and + last, in a card whose border says what kind of thing this + is. Everything irreversible on this page lives here. + + The deadlines come from the commands that enforce them, + so the page cannot drift from what actually happens. --}} +
+

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

+

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

+ + + +
    +
  • {{ __('settings.lifecycle_unverified', ['days' => App\Console\Commands\PruneUnverifiedAccounts::AFTER_DAYS]) }}
  • +
  • {{ __('settings.lifecycle_dormant', ['warn' => App\Console\Commands\PruneDormantAccounts::WARN_DAYS_BEFORE]) }}
  • +
  • {{ __('settings.lifecycle_kept') }}
  • +
+ + {{ __('settings.lifecycle_terms') }} + +
+ + +
+ + {{ __('settings.close_cta') }} + +
+
+
+
+
+ @endif
- @endif +