Rebuild the settings page out of panels and rows

**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 <noreply@anthropic.com>
feature/betriebsmodus^2
nexxo 2026-07-30 17:38:57 +02:00
parent f25a0030f0
commit 2a97d76b2d
6 changed files with 480 additions and 340 deletions

View File

@ -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.',

View File

@ -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.',

View File

@ -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. --}}
<section {{ $attributes->merge(['class' => 'overflow-hidden rounded-lg border border-line bg-surface shadow-xs']) }}>
@if ($title || isset($header))
<div class="flex flex-wrap items-start justify-between gap-3 border-b border-line px-6 py-4">
@isset($header)
{{ $header }}
@else
<div class="min-w-0">
<h2 class="text-sm font-bold text-ink">{{ $title }}</h2>
@if ($subtitle)
<p class="mt-0.5 max-w-[76ch] text-sm leading-relaxed text-muted">{{ $subtitle }}</p>
@endif
</div>
@isset($action)
<div class="shrink-0">{{ $action }}</div>
@endisset
@endisset
</div>
@endif
<div class="divide-y divide-line">{{ $slot }}</div>
@isset($footer)
<div class="flex flex-wrap items-center justify-end gap-3 border-t border-line bg-surface-2 px-6 py-4">
{{ $footer }}
</div>
@endisset
</section>

View File

@ -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. --}}
<div {{ $attributes->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)
<div class="min-w-0">
<label @if ($for) for="{{ $for }}" @endif class="text-sm font-medium text-ink">{{ $label }}</label>
@if ($hint)
<p class="mt-0.5 text-xs leading-relaxed text-muted">{{ $hint }}</p>
@endif
</div>
@endif
<div class="min-w-0">{{ $slot }}</div>
</div>

View File

@ -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.
--}}
<div class="space-y-6">
@ -24,7 +33,8 @@
</header>
{{-- 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. --}}
<div class="flex flex-wrap items-center gap-x-1 gap-y-2 border-b border-line animate-rise [animation-delay:40ms]" role="tablist">
@foreach ($tabs as $name)
<button type="button" role="tab" wire:click="$set('tab', '{{ $name }}')"
@ -40,408 +50,397 @@
@endforeach
</div>
{{-- ── Ihre Daten ───────────────────────────────────────────────────── --}}
{{-- ══ Ihre Daten ═══════════════════════════════════════════════════════ --}}
@if ($tab === 'profile')
<form wire:submit="saveProfile" class="grid gap-5 lg:grid-cols-[minmax(0,1fr)_320px] lg:items-start animate-rise [animation-delay:60ms]">
<div class="space-y-5 rounded-lg border border-line bg-surface p-6 shadow-xs">
<div>
<h2 class="text-sm font-bold text-ink">{{ __('settings.company_title') }}</h2>
<p class="mt-1 text-sm text-muted">{{ __('settings.company_sub') }}</p>
</div>
<form wire:submit="saveProfile" class="space-y-5 animate-rise [animation-delay:60ms]">
<x-ui.panel :title="__('settings.company_title')" :subtitle="__('settings.company_sub')">
<x-ui.row :label="__('settings.company_name')" for="companyName">
<x-ui.input name="companyName" wire:model="companyName" />
</x-ui.row>
<x-ui.row :label="__('settings.contact_name')" for="contactName">
<x-ui.input name="contactName" wire:model="contactName" />
</x-ui.row>
<x-ui.row :label="__('settings.phone')" for="phone">
<x-ui.input name="phone" wire:model="phone" />
</x-ui.row>
<x-ui.row :label="__('settings.vat_id')" for="vatId">
<x-ui.input name="vatId" wire:model="vatId" />
</x-ui.row>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<x-ui.input name="companyName" wire:model="companyName" :label="__('settings.company_name')" />
<x-ui.input name="contactName" wire:model="contactName" :label="__('settings.contact_name')" />
<x-ui.input name="phone" wire:model="phone" :label="__('settings.phone')" />
<x-ui.input name="vatId" wire:model="vatId" :label="__('settings.vat_id')" />
</div>
{{-- Field by field, not a paragraph: what landed in the
textarea was whatever somebody typed a postcode on the
street line, a city with no postcode, no country at all
and an invoice has to name its recipient precisely enough to
be a document. Postcode and town share a row because that is
how they are read and written. --}}
<div class="space-y-4">
<p class="text-sm font-medium text-body">{{ __('settings.billing_address') }}</p>
<x-ui.input name="billingStreet" wire:model="billingStreet" :label="__('settings.billing_street')" />
<div class="grid gap-4 sm:grid-cols-[140px_minmax(0,1fr)]">
<x-ui.input name="billingPostcode" wire:model="billingPostcode" :label="__('settings.billing_postcode')" />
<x-ui.input name="billingCity" wire:model="billingCity" :label="__('settings.billing_city')" />
{{-- 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. --}}
<x-ui.row :label="__('settings.billing_address')" for="billingStreet">
<div class="space-y-3">
<x-ui.input name="billingStreet" wire:model="billingStreet" :placeholder="__('settings.billing_street')" />
<div class="grid gap-3 sm:grid-cols-[140px_minmax(0,1fr)]">
<x-ui.input name="billingPostcode" wire:model="billingPostcode" :placeholder="__('settings.billing_postcode')" />
<x-ui.input name="billingCity" wire:model="billingCity" :placeholder="__('settings.billing_city')" />
</div>
<x-ui.input name="billingCountry" wire:model="billingCountry" :placeholder="__('settings.billing_country')" />
</div>
<x-ui.input name="billingCountry" wire:model="billingCountry" :label="__('settings.billing_country')" />
</div>
</x-ui.row>
<div class="flex justify-end border-t border-line pt-4">
<x-ui.button variant="primary" type="submit" wire:loading.attr="disabled" wire:target="saveProfile">{{ __('settings.save') }}</x-ui.button>
</div>
</div>
{{-- 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. --}}
<x-ui.row :label="__('settings.customer_type')"
:hint="$customer?->customer_type ? __('settings.customer_type_locked') : __('settings.customer_type_once')">
@if ($customer?->customer_type)
<p class="flex items-center gap-2 py-2 text-sm font-semibold text-ink">
<x-ui.icon name="lock" class="size-4 text-muted" />
{{ __('settings.customer_type_'.$customer->customer_type) }}
</p>
@else
<div class="flex flex-wrap gap-2">
@foreach ($customerTypes as $type)
<label class="flex cursor-pointer items-center gap-2 rounded border border-line-strong bg-surface px-3 py-2 text-sm text-ink transition has-[:checked]:border-ink">
<input type="radio" wire:model="customerType" value="{{ $type }}"
class="size-4 shrink-0 border-line text-ink" />
{{ __('settings.customer_type_'.$type) }}
</label>
@endforeach
</div>
@error('customerType')<p class="mt-1.5 text-xs text-danger">{{ $message }}</p>@enderror
@endif
</x-ui.row>
{{-- Beside the form, and for a record that has one it is a FACT
rather than a field. 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 the question; this page reports
the answer. 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.
A record created before the question existed has no answer yet,
and may be given one here, once. --}}
<div class="space-y-3 rounded-lg border border-line bg-surface p-6 shadow-xs">
<p class="text-sm font-bold text-ink">{{ __('settings.customer_type') }}</p>
@if ($customer?->customer_type)
<p class="flex items-center gap-2 text-sm font-semibold text-ink">
<x-ui.icon name="lock" class="size-4 text-muted" />
{{ __('settings.customer_type_'.$customer->customer_type) }}
</p>
<p class="text-xs leading-relaxed text-muted">{{ __('settings.customer_type_locked') }}</p>
@else
<div class="space-y-2">
@foreach ($customerTypes as $type)
<label class="flex cursor-pointer items-center gap-2 rounded border border-line-strong bg-surface px-3 py-2 text-sm text-ink transition has-[:checked]:border-ink">
<input type="radio" wire:model="customerType" value="{{ $type }}"
class="size-4 shrink-0 border-line text-ink" />
{{ __('settings.customer_type_'.$type) }}
</label>
@endforeach
</div>
<p class="text-xs leading-relaxed text-muted">{{ __('settings.customer_type_once') }}</p>
@error('customerType')<p class="text-xs text-danger">{{ $message }}</p>@enderror
@endif
</div>
<x-slot:footer>
<x-ui.button variant="primary" type="submit" wire:loading.attr="disabled" wire:target="saveProfile">
{{ __('settings.save') }}
</x-ui.button>
</x-slot:footer>
</x-ui.panel>
</form>
@endif
{{-- ── Sicherheit ───────────────────────────────────────────────────── --}}
{{-- ══ Sicherheit ═══════════════════════════════════════════════════════ --}}
@if ($tab === 'security')
<div class="grid gap-5 lg:grid-cols-2 lg:items-start animate-rise [animation-delay:60ms]">
<div class="space-y-5 animate-rise [animation-delay:60ms]">
{{-- Own password. There was no way to change one at all Fortify's
updatePasswords feature was switched off. --}}
<form wire:submit="updateOwnPassword" class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs">
<div>
<h2 class="text-sm font-bold text-ink">{{ __('admin_settings.password_title') }}</h2>
<p class="mt-1 text-sm text-muted">{{ __('admin_settings.password_sub') }}</p>
</div>
<form wire:submit="updateOwnPassword">
<x-ui.panel :title="__('admin_settings.password_title')" :subtitle="__('admin_settings.password_sub')">
<x-ui.row :label="__('admin_settings.password_current')" for="currentPassword">
<x-ui.input name="currentPassword" type="password" autocomplete="current-password" wire:model="currentPassword" />
</x-ui.row>
<x-ui.row :label="__('admin_settings.password_new')" for="newPassword">
<div class="grid gap-3 sm:grid-cols-2">
<x-ui.input name="newPassword" type="password" autocomplete="new-password" wire:model="newPassword" />
<x-ui.input name="newPasswordConfirmation" type="password" autocomplete="new-password"
:placeholder="__('admin_settings.password_repeat')" wire:model="newPasswordConfirmation" />
</div>
</x-ui.row>
<x-ui.input name="currentPassword" type="password" autocomplete="current-password"
:label="__('admin_settings.password_current')" wire:model="currentPassword" />
<div class="grid gap-4 sm:grid-cols-2">
<x-ui.input name="newPassword" type="password" autocomplete="new-password"
:label="__('admin_settings.password_new')" wire:model="newPassword" />
<x-ui.input name="newPasswordConfirmation" type="password" autocomplete="new-password"
:label="__('admin_settings.password_repeat')" wire:model="newPasswordConfirmation" />
</div>
<div class="flex justify-end border-t border-line pt-4">
<x-ui.button type="submit" variant="primary" wire:loading.attr="disabled" wire:target="updateOwnPassword">{{ __('admin_settings.password_save') }}</x-ui.button>
</div>
<x-slot:footer>
<x-ui.button type="submit" variant="primary" wire:loading.attr="disabled" wire:target="updateOwnPassword">
{{ __('admin_settings.password_save') }}
</x-ui.button>
</x-slot:footer>
</x-ui.panel>
</form>
{{-- 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. --}}
<div class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs">
<div>
<div class="flex flex-wrap items-center gap-2">
<h2 class="text-sm font-bold text-ink">{{ __('settings.twofa_title') }}</h2>
<span class="inline-flex items-center gap-1.5 rounded-pill border px-2.5 py-0.5 text-xs font-medium
{{ $twoFactorOn ? 'border-success-border bg-success-bg text-success' : 'border-line-strong bg-surface-2 text-muted' }}">
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>
{{ $twoFactorOn ? __('settings.twofa_state_on') : __('settings.twofa_state_off') }}
</span>
<x-ui.panel>
<x-slot:header>
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<h2 class="text-sm font-bold text-ink">{{ __('settings.twofa_title') }}</h2>
<span class="inline-flex items-center gap-1.5 rounded-pill border px-2.5 py-0.5 text-xs font-medium
{{ $twoFactorOn ? 'border-success-border bg-success-bg text-success' : 'border-line-strong bg-surface-2 text-muted' }}">
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>
{{ $twoFactorOn ? __('settings.twofa_state_on') : __('settings.twofa_state_off') }}
</span>
</div>
<p class="mt-0.5 max-w-[76ch] text-sm leading-relaxed text-muted">{{ __('settings.twofa_sub') }}</p>
</div>
<p class="mt-1.5 text-sm text-muted">{{ __('settings.twofa_sub') }}</p>
</div>
</x-slot:header>
@if (! $passwordConfirmed)
{{-- The gate. A signed-in session is not enough to change how
the account is protected the risk is an unlocked
machine. --}}
<form wire:submit="confirmPassword" class="rounded border border-line bg-surface-2 p-4">
<p class="text-sm text-body">{{ __('settings.twofa_confirm_first') }}</p>
<div class="mt-3 flex flex-wrap items-start gap-2">
<div class="min-w-56 flex-1">
<x-ui.input name="confirmablePassword" type="password" autocomplete="current-password"
:label="__('admin_settings.password_current')" wire:model="confirmablePassword" />
<form wire:submit="confirmPassword">
<x-ui.row :label="__('admin_settings.password_current')" :hint="__('settings.twofa_confirm_first')" for="confirmablePassword">
<div class="flex flex-wrap items-start gap-2">
<div class="min-w-56 flex-1">
<x-ui.input name="confirmablePassword" type="password" autocomplete="current-password" wire:model="confirmablePassword" />
</div>
<x-ui.button type="submit" variant="secondary" wire:loading.attr="disabled" wire:target="confirmPassword">
{{ __('settings.twofa_confirm_button') }}
</x-ui.button>
</div>
<x-ui.button type="submit" variant="secondary" class="mt-7" wire:loading.attr="disabled" wire:target="confirmPassword">
{{ __('settings.twofa_confirm_button') }}
</x-ui.button>
</div>
</x-ui.row>
</form>
@elseif ($twoFactorOn)
<div class="flex flex-wrap gap-2">
<x-ui.button variant="secondary" wire:click="regenerateRecoveryCodes" wire:loading.attr="disabled" wire:target="regenerateRecoveryCodes">
{{ __('settings.twofa_new_codes') }}
</x-ui.button>
<x-ui.button variant="danger"
x-on:click="$dispatch('openModal', { component: 'confirm-disable-two-factor' })">
{{ __('settings.twofa_disable') }}
</x-ui.button>
</div>
@elseif ($twoFactorPending)
<div class="grid gap-5 sm:grid-cols-[auto_1fr] sm:items-start">
<div class="rounded border border-line bg-white p-3">{!! $twoFactorQr !!}</div>
<form wire:submit="confirmTwoFactor" class="space-y-3">
<p class="text-sm text-muted">{{ __('settings.twofa_scan') }}</p>
<div class="max-w-48">
<x-ui.input name="twoFactorCode" inputmode="numeric" autocomplete="one-time-code"
:label="__('settings.twofa_code')" wire:model="twoFactorCode" />
</div>
<x-ui.button type="submit" variant="primary" wire:loading.attr="disabled" wire:target="confirmTwoFactor">
{{ __('settings.twofa_activate') }}
<x-ui.row :label="__('settings.twofa_state_on')" :hint="__('settings.twofa_codes_hint')">
<div class="flex flex-wrap gap-2">
<x-ui.button variant="secondary" wire:click="regenerateRecoveryCodes" wire:loading.attr="disabled" wire:target="regenerateRecoveryCodes">
{{ __('settings.twofa_new_codes') }}
</x-ui.button>
</form>
</div>
<x-ui.button variant="danger"
x-on:click="$dispatch('openModal', { component: 'confirm-disable-two-factor' })">
{{ __('settings.twofa_disable') }}
</x-ui.button>
</div>
</x-ui.row>
@elseif ($twoFactorPending)
<x-ui.row :label="__('settings.twofa_scan')">
<div class="flex flex-wrap items-start gap-5">
<div class="rounded border border-line bg-white p-3">{!! $twoFactorQr !!}</div>
<form wire:submit="confirmTwoFactor" class="space-y-3">
<div class="max-w-48">
<x-ui.input name="twoFactorCode" inputmode="numeric" autocomplete="one-time-code"
:label="__('settings.twofa_code')" wire:model="twoFactorCode" />
</div>
<x-ui.button type="submit" variant="primary" wire:loading.attr="disabled" wire:target="confirmTwoFactor">
{{ __('settings.twofa_activate') }}
</x-ui.button>
</form>
</div>
</x-ui.row>
@else
<x-ui.button variant="primary" wire:click="enableTwoFactor" wire:loading.attr="disabled" wire:target="enableTwoFactor">
{{ __('settings.twofa_enable') }}
</x-ui.button>
<x-ui.row :label="__('settings.twofa_state_off')">
<x-ui.button variant="primary" wire:click="enableTwoFactor" wire:loading.attr="disabled" wire:target="enableTwoFactor">
{{ __('settings.twofa_enable') }}
</x-ui.button>
</x-ui.row>
@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. --}}
<div class="rounded border border-warning-border bg-warning-bg p-4">
<p class="text-sm font-medium text-warning">{{ __('settings.twofa_codes_title') }}</p>
<p class="mt-1 text-xs text-warning">{{ __('settings.twofa_codes_hint') }}</p>
<ul class="mt-3 grid grid-cols-2 gap-x-6 gap-y-1 font-mono text-sm text-ink">
<x-ui.row :label="__('settings.twofa_codes_title')" :hint="__('settings.twofa_codes_hint')">
<ul class="grid grid-cols-2 gap-x-6 gap-y-1 rounded border border-warning-border bg-warning-bg p-4 font-mono text-sm text-ink sm:grid-cols-3">
@foreach ($recoveryCodes as $code)<li class="select-all">{{ $code }}</li>@endforeach
</ul>
</div>
</x-ui.row>
@endif
</div>
</x-ui.panel>
{{-- Under both, across the width: it answers the same question they
do who can get into this account and it is a table. --}}
<div class="lg:col-span-2">
@livewire('sessions')
</div>
{{-- 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')
</div>
@endif
{{-- ── Erscheinungsbild ─────────────────────────────────────────────── --}}
{{-- ══ Erscheinungsbild ═════════════════════════════════════════════════ --}}
@if ($tab === 'branding')
<form wire:submit="saveBranding" class="grid gap-5 lg:grid-cols-[minmax(0,1fr)_320px] lg:items-start animate-rise [animation-delay:60ms]">
<div class="space-y-5 rounded-lg border border-line bg-surface p-6 shadow-xs">
<div>
<h2 class="text-sm font-bold text-ink">{{ __('settings.branding_title') }}</h2>
<p class="mt-1 text-sm text-muted">{{ __('settings.branding_sub') }}</p>
</div>
<form wire:submit="saveBranding" class="space-y-5 animate-rise [animation-delay:60ms]">
<x-ui.panel :title="__('settings.branding_title')" :subtitle="__('settings.branding_sub')">
<x-ui.row :label="__('settings.brand_display_name')" for="brandDisplayName">
<x-ui.input name="brandDisplayName" wire:model="brandDisplayName" :placeholder="$branding['display_name'] ?? ''" />
</x-ui.row>
<x-ui.input name="brandDisplayName" wire:model="brandDisplayName" :label="__('settings.brand_display_name')" :placeholder="$branding['display_name'] ?? ''" />
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label class="text-sm font-medium text-body">{{ __('settings.brand_primary') }}</label>
<div class="mt-1.5 flex items-center gap-2">
<input type="color" wire:model="brandPrimary" value="{{ $brandPrimary ?: ($branding['primary_color'] ?? '#f97316') }}" class="h-9 w-12 rounded border border-line-strong bg-surface" aria-label="{{ __('settings.brand_primary') }}" />
<input type="text" wire:model="brandPrimary" placeholder="{{ $branding['primary_color'] ?? '#f97316' }}" class="w-28 rounded border border-line-strong bg-surface px-2.5 py-1.5 font-mono text-sm text-ink" />
</div>
@error('brandPrimary')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
<x-ui.row :label="__('settings.brand_primary')">
<div class="flex items-center gap-2">
<input type="color" wire:model="brandPrimary" value="{{ $brandPrimary ?: ($branding['primary_color'] ?? '#f97316') }}"
class="h-10 w-14 rounded border border-line-strong bg-surface" aria-label="{{ __('settings.brand_primary') }}" />
<input type="text" wire:model="brandPrimary" placeholder="{{ $branding['primary_color'] ?? '#f97316' }}"
class="w-32 rounded border border-line-strong bg-surface px-3 py-2.5 font-mono text-sm text-ink" />
</div>
<div>
<label class="text-sm font-medium text-body">{{ __('settings.brand_accent') }}</label>
<div class="mt-1.5 flex items-center gap-2">
<input type="color" wire:model="brandAccent" value="{{ $brandAccent ?: ($branding['accent_color'] ?? '#c2560a') }}" class="h-9 w-12 rounded border border-line-strong bg-surface" aria-label="{{ __('settings.brand_accent') }}" />
<input type="text" wire:model="brandAccent" placeholder="{{ $branding['accent_color'] ?? '#c2560a' }}" class="w-28 rounded border border-line-strong bg-surface px-2.5 py-1.5 font-mono text-sm text-ink" />
</div>
@error('brandAccent')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
@error('brandPrimary')<p class="mt-1.5 text-xs text-danger">{{ $message }}</p>@enderror
</x-ui.row>
<x-ui.row :label="__('settings.brand_accent')">
<div class="flex items-center gap-2">
<input type="color" wire:model="brandAccent" value="{{ $brandAccent ?: ($branding['accent_color'] ?? '#c2560a') }}"
class="h-10 w-14 rounded border border-line-strong bg-surface" aria-label="{{ __('settings.brand_accent') }}" />
<input type="text" wire:model="brandAccent" placeholder="{{ $branding['accent_color'] ?? '#c2560a' }}"
class="w-32 rounded border border-line-strong bg-surface px-3 py-2.5 font-mono text-sm text-ink" />
</div>
</div>
@error('brandAccent')<p class="mt-1.5 text-xs text-danger">{{ $message }}</p>@enderror
</x-ui.row>
<div class="flex justify-end border-t border-line pt-4">
<x-ui.button variant="primary" type="submit" wire:loading.attr="disabled" wire:target="saveBranding,logo">{{ __('settings.save') }}</x-ui.button>
</div>
</div>
<x-ui.row :label="__('settings.brand_logo')" :hint="__('settings.brand_logo_hint')">
<div class="flex flex-wrap items-center gap-4">
<div class="grid size-20 shrink-0 place-items-center overflow-hidden rounded border border-line bg-surface-2">
@if ($logo)
<img src="{{ $logo->temporaryUrl() }}" alt="" class="max-h-20 max-w-20 object-contain" />
@elseif ($logoUrl)
<img src="{{ $logoUrl }}" alt="" class="max-h-20 max-w-20 object-contain" />
@else
<span class="px-2 text-center text-xs text-muted">{{ __('settings.brand_default') }}</span>
@endif
</div>
<div class="min-w-0 flex-1 space-y-2">
<input type="file" wire:model="logo" accept="image/png,image/webp"
class="w-full text-sm text-body file:mr-3 file:rounded file:border-0 file:bg-surface-2 file:px-3 file:py-1.5 file:text-sm file:font-semibold file:text-body" />
@if ($logoUrl)
<button type="button" wire:click="removeLogo" class="text-xs font-semibold text-danger hover:underline">{{ __('settings.brand_logo_remove') }}</button>
@endif
@error('logo')<p class="text-xs text-danger">{{ $message }}</p>@enderror
</div>
</div>
</x-ui.row>
<div class="space-y-3 rounded-lg border border-line bg-surface p-6 shadow-xs">
<label class="text-sm font-bold text-ink">{{ __('settings.brand_logo') }}</label>
<div class="grid size-20 place-items-center overflow-hidden rounded border border-line bg-surface-2">
@if ($logo)
<img src="{{ $logo->temporaryUrl() }}" alt="" class="max-h-20 max-w-20 object-contain" />
@elseif ($logoUrl)
<img src="{{ $logoUrl }}" alt="" class="max-h-20 max-w-20 object-contain" />
@else
<span class="px-2 text-center text-xs text-muted">{{ __('settings.brand_default') }}</span>
<x-slot:footer>
@if ($branding['is_default'] ?? false)
<p class="mr-auto text-xs text-muted">{{ __('settings.brand_using_default') }}</p>
@endif
</div>
<input type="file" wire:model="logo" accept="image/png,image/webp" class="w-full text-sm text-body file:mr-3 file:rounded file:border-0 file:bg-surface-2 file:px-3 file:py-1.5 file:text-sm file:font-semibold file:text-body" />
<p class="text-xs leading-relaxed text-muted">{{ __('settings.brand_logo_hint') }}</p>
@if ($logoUrl)
<button type="button" wire:click="removeLogo" class="text-xs font-semibold text-danger hover:underline">{{ __('settings.brand_logo_remove') }}</button>
@endif
@error('logo')<p class="text-xs text-danger">{{ $message }}</p>@enderror
@if ($branding['is_default'] ?? false)
<p class="border-t border-line pt-3 text-xs text-muted">{{ __('settings.brand_using_default') }}</p>
@endif
</div>
<x-ui.button variant="primary" type="submit" wire:loading.attr="disabled" wire:target="saveBranding,logo">
{{ __('settings.save') }}
</x-ui.button>
</x-slot:footer>
</x-ui.panel>
</form>
@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')
<div class="space-y-5 animate-rise [animation-delay:60ms]">
{{-- Package, cancellation and while it is open the withdrawal. --}}
<section class="overflow-hidden rounded-lg border border-line bg-surface shadow-xs">
<div class="flex flex-wrap items-center justify-between gap-3 border-b border-line px-6 py-4">
<div class="min-w-0">
<h2 class="text-sm font-bold text-ink">{{ __('settings.package_title') }}</h2>
<p class="mt-0.5 text-sm text-muted">
<x-ui.panel :title="__('settings.contract_title')" :subtitle="__('settings.contract_sub')">
{{-- The package, and the way out of it. --}}
<x-ui.row :label="__('settings.package_title')">
<div class="flex flex-wrap items-center justify-between gap-3">
<p class="min-w-0 text-sm text-body">
@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
</p>
</div>
@if ($hasActivePackage && ! $cancellationScheduled)
<x-ui.button variant="secondary" size="sm"
x-on:click="$dispatch('openModal', { component: 'confirm-cancel-package' })">
{{ __('settings.cancel_cta') }}
</x-ui.button>
@endif
</div>
@if ($cancellationScheduled)
<div class="flex items-start gap-3 border-b border-line bg-warning-bg px-6 py-4">
<x-ui.icon name="alert-triangle" class="mt-0.5 size-5 shrink-0 text-warning" />
<p class="text-sm text-body">{{ __('settings.cancel_scheduled_body', ['date' => $instance?->service_ends_at?->local()->isoFormat('LL')]) }}</p>
@if ($hasActivePackage && ! $cancellationScheduled)
<x-ui.button variant="secondary" size="sm"
x-on:click="$dispatch('openModal', { component: 'confirm-cancel-package' })">
{{ __('settings.cancel_cta') }}
</x-ui.button>
@elseif (! $hasActivePackage && ! $cancellationScheduled)
<x-ui.button :href="route('order')" variant="secondary" size="sm" wire:navigate>
{{ __('settings.to_packages') }}
</x-ui.button>
@endif
</div>
@endif
</x-ui.row>
{{-- 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)
<div class="flex flex-wrap items-center justify-between gap-3 px-6 py-4">
<div class="min-w-0">
<p class="text-sm font-semibold text-ink">{{ __('withdrawal.card_title') }}</p>
<p class="mt-0.5 max-w-[70ch] text-sm text-muted">
{{ __('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)
<x-ui.row :label="__('withdrawal.card_title')">
<div class="flex flex-wrap items-center justify-between gap-3">
<p class="min-w-0 max-w-[70ch] text-sm text-body">
@if ($withdrawal->open)
{{ __('withdrawal.card_sub', [
'date' => $withdrawal->endsAt->local()->isoFormat('LL'),
'days' => $withdrawal->daysLeft(),
]) }}
@else
{{ $withdrawal->refusal }}
@endif
</p>
</div>
<x-ui.button variant="secondary" size="sm"
x-on:click="$dispatch('openModal', { component: 'confirm-withdraw' })">
{{ __('withdrawal.cta') }}
</x-ui.button>
</div>
@endif
</section>
{{-- ── 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)
<section class="overflow-hidden rounded-lg border border-line bg-surface shadow-xs">
<div class="flex flex-wrap items-center justify-between gap-3 border-b border-line px-6 py-4">
<div class="min-w-0">
<h2 class="text-sm font-bold text-ink">{{ __('dpa.title') }}</h2>
<p class="mt-0.5 max-w-[76ch] text-sm leading-relaxed text-muted">{{ __('dpa.sub') }}</p>
</div>
<span class="inline-flex shrink-0 items-center gap-1.5 rounded-pill border border-line bg-surface-2 px-2.5 py-0.5 font-mono text-xs text-body">
{{ __('dpa.version', ['version' => $dpa->version]) }}
</span>
</div>
<div class="flex flex-wrap items-center gap-2 px-6 py-4">
<x-ui.button :href="route('dpa.file', 'agreement')" variant="secondary" size="sm" target="_blank">
<x-ui.icon name="file-text" class="size-4" />{{ __('dpa.read_agreement') }}
</x-ui.button>
@if ($dpa->measures_path)
<x-ui.button :href="route('dpa.file', 'measures')" variant="secondary" size="sm" target="_blank">
<x-ui.icon name="shield" class="size-4" />{{ __('dpa.read_measures') }}
</x-ui.button>
@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. --}}
<x-ui.button :href="route('dpa.file', ['which' => 'agreement', 'download' => 1])" variant="ghost" size="sm" download>
<x-ui.icon name="download" class="size-4" />{{ __('dpa.download_agreement') }}
</x-ui.button>
@if ($dpa->measures_path)
<x-ui.button :href="route('dpa.file', ['which' => 'measures', 'download' => 1])" variant="ghost" size="sm" download>
<x-ui.icon name="download" class="size-4" />{{ __('dpa.download_measures') }}
</x-ui.button>
@endif
</div>
<div class="border-t border-line bg-surface-2 px-6 py-4">
@if ($dpaAcceptance)
{{-- R19: stored in UTC, read on the wall clock. --}}
<p class="flex flex-wrap items-center gap-2 text-sm text-body">
<x-ui.icon name="check" class="size-4 text-success" />
{{ __('dpa.accepted_on', [
'version' => $dpa->version,
'when' => $dpaAcceptance->accepted_at->local()->isoFormat('LL, LT'),
]) }}
</p>
@else
<div class="flex flex-wrap items-center justify-between gap-3">
<p class="max-w-[76ch] text-sm leading-relaxed text-muted">{{ __('dpa.in_force_note') }}</p>
@if ($withdrawal->open)
<x-ui.button variant="secondary" size="sm"
wire:click="acceptProcessingAgreement" wire:loading.attr="disabled" wire:target="acceptProcessingAgreement">
{{ __('dpa.accept_cta') }}
x-on:click="$dispatch('openModal', { component: 'confirm-withdraw' })">
{{ __('withdrawal.cta') }}
</x-ui.button>
@endif
</div>
</x-ui.row>
@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)
<x-ui.row :label="__('dpa.title')" :hint="__('dpa.sub')">
<div class="space-y-3">
<div class="flex flex-wrap items-center gap-2">
<x-ui.button :href="route('dpa.file', 'agreement')" variant="secondary" size="sm" target="_blank">
<x-ui.icon name="file-text" class="size-4" />{{ __('dpa.read_agreement') }}
</x-ui.button>
<x-ui.button :href="route('dpa.file', ['which' => 'agreement', 'download' => 1])" variant="ghost" size="sm" download>
<x-ui.icon name="download" class="size-4" />{{ __('dpa.download') }}
</x-ui.button>
@if ($dpa->measures_path)
<span class="mx-1 h-4 w-px bg-line" aria-hidden="true"></span>
<x-ui.button :href="route('dpa.file', 'measures')" variant="secondary" size="sm" target="_blank">
<x-ui.icon name="shield" class="size-4" />{{ __('dpa.read_measures') }}
</x-ui.button>
<x-ui.button :href="route('dpa.file', ['which' => 'measures', 'download' => 1])" variant="ghost" size="sm" download>
<x-ui.icon name="download" class="size-4" />{{ __('dpa.download') }}
</x-ui.button>
@endif
<span class="ml-auto font-mono text-xs text-muted">{{ __('dpa.version', ['version' => $dpa->version]) }}</span>
</div>
@endif
</div>
</section>
@endif
{{-- Closing the account, and the deadlines that close one by itself. --}}
<section class="overflow-hidden rounded-lg border border-line bg-surface shadow-xs">
<div class="flex flex-wrap items-center justify-between gap-3 border-b border-line px-6 py-4">
<div class="min-w-0">
<h2 class="text-sm font-bold text-ink">{{ __('settings.close_account_title') }}</h2>
<p class="mt-0.5 text-sm text-muted">{{ __('settings.close_account_sub') }}</p>
</div>
<x-ui.button variant="secondary" size="sm"
x-on:click="$dispatch('openModal', { component: 'confirm-close-account' })">
{{ __('settings.close_cta') }}
</x-ui.button>
</div>
@if ($dpaAcceptance)
{{-- R19: stored in UTC, read on the wall clock. --}}
<p class="flex items-start gap-2 text-xs leading-relaxed text-muted">
<x-ui.icon name="check" class="mt-0.5 size-3.5 shrink-0 text-success" />
{{ __('dpa.accepted_on', [
'version' => $dpa->version,
'when' => $dpaAcceptance->accepted_at->local()->isoFormat('LL, LT'),
]) }}
</p>
@else
<div class="flex flex-wrap items-center justify-between gap-3 rounded border border-line bg-surface-2 px-4 py-3">
<p class="min-w-0 max-w-[70ch] text-xs leading-relaxed text-muted">{{ __('dpa.in_force_note') }}</p>
<x-ui.button variant="secondary" size="sm"
wire:click="acceptProcessingAgreement" wire:loading.attr="disabled" wire:target="acceptProcessingAgreement">
{{ __('dpa.accept_cta') }}
</x-ui.button>
</div>
@endif
</div>
</x-ui.row>
@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. --}}
<x-ui.row :label="__('settings.close_account_title')" :hint="__('settings.close_account_sub')">
<div class="flex justify-start sm:justify-end">
<x-ui.button variant="secondary" size="sm"
x-on:click="$dispatch('openModal', { component: 'confirm-close-account' })">
{{ __('settings.close_cta') }}
</x-ui.button>
</div>
</x-ui.row>
</x-ui.panel>
{{-- 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. --}}
<x-ui.panel :title="__('settings.lifecycle_title')">
<div class="px-6 py-4">
<p class="text-sm font-semibold text-ink">{{ __('settings.lifecycle_title') }}</p>
<ul class="mt-2 space-y-1.5 text-sm leading-relaxed text-muted">
<ul class="space-y-1.5 text-sm leading-relaxed text-muted">
<li>{{ __('settings.lifecycle_unverified', ['days' => App\Console\Commands\PruneUnverifiedAccounts::AFTER_DAYS]) }}</li>
<li>{{ __('settings.lifecycle_dormant', ['warn' => App\Console\Commands\PruneDormantAccounts::WARN_DAYS_BEFORE]) }}</li>
<li>{{ __('settings.lifecycle_kept') }}</li>
@ -451,7 +450,7 @@
{{ __('settings.lifecycle_terms') }}
</a>
</div>
</section>
</x-ui.panel>
</div>
@endif
</div>

View File

@ -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, '<x-ui.panel'))->toBeGreaterThanOrEqual(5)
->and(substr_count($page, '<x-ui.row'))->toBeGreaterThanOrEqual(12)
// No hand-rolled card markup left behind.
->and($page)->not->toContain('rounded-lg border border-line bg-surface p-6 shadow-xs');
});