Rebuild the customer's settings page as four tabs
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, their password, two-factor, the devices they are signed in on, the branding of their cloud, 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. Four tabs, by what somebody came to do: their details, security, the appearance of their cloud, the contract. The same tab bar the console uses, so one vocabulary holds on both sides of the login, and the choice lives in the query string — `#[Url(history: true)]`, no `except`, so the parameter is there from the first render and a link to this page can name the part it means. An unknown tab out of the address falls back to the first rather than rendering a settings page with no section on it. The width is used now. Inside a tab the short cards sit side by side instead of each taking a full row to hold three lines: the customer type beside the company form (it is one question, and beside the VAT field it was mistaken for it), the password beside two-factor, the logo beside the colours, the contract beside the closing of the account. Also: the subtitle listed the old sections by name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>feature/betriebsmodus^2
parent
7da2939c55
commit
1d4e98eb06
|
|
@ -19,16 +19,52 @@ use Laravel\Fortify\Actions\EnableTwoFactorAuthentication;
|
||||||
use Laravel\Fortify\Actions\GenerateNewRecoveryCodes;
|
use Laravel\Fortify\Actions\GenerateNewRecoveryCodes;
|
||||||
use Livewire\Attributes\Layout;
|
use Livewire\Attributes\Layout;
|
||||||
use Livewire\Attributes\On;
|
use Livewire\Attributes\On;
|
||||||
|
use Livewire\Attributes\Url;
|
||||||
use Livewire\Attributes\Validate;
|
use Livewire\Attributes\Validate;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use Livewire\WithFileUploads;
|
use Livewire\WithFileUploads;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The customer's own settings.
|
||||||
|
*
|
||||||
|
* It was one column, 768 pixels wide inside a 1240-pixel shell — a third of the
|
||||||
|
* window left empty beside it — and everything a customer might ever change was
|
||||||
|
* stacked in it: the company address, their password, two-factor, the devices
|
||||||
|
* they are signed in on, the branding of their cloud, the contract, and the
|
||||||
|
* button that closes the account. Two thousand pixels of scroll, and the way to
|
||||||
|
* a particular thing was to know how far down it lived.
|
||||||
|
*
|
||||||
|
* Four tabs now, by what somebody came to do: their data, how the account is
|
||||||
|
* protected, how the cloud looks, and the contract. The choice lives in the
|
||||||
|
* query string, so a reload, a bookmark and the back button all land where they
|
||||||
|
* were — and a link to this page can name the part it means.
|
||||||
|
*/
|
||||||
class Settings extends Component
|
class Settings extends Component
|
||||||
{
|
{
|
||||||
use ChangesOwnPassword;
|
use ChangesOwnPassword;
|
||||||
use ConfirmsPassword;
|
use ConfirmsPassword;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tabs, in the order they are shown.
|
||||||
|
*
|
||||||
|
* The list IS the schema: it validates the query string, builds the tab bar
|
||||||
|
* and decides which section renders. A fourth place to add a tab is a fourth
|
||||||
|
* place to forget one.
|
||||||
|
*/
|
||||||
|
public const TABS = ['profile', 'security', 'branding', 'contract'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which one is open, taken from and written back to the address.
|
||||||
|
*
|
||||||
|
* `history: true`, so each tab is a step the back button can take, and no
|
||||||
|
* `except`, so the parameter is there from the first render — hiding it on
|
||||||
|
* the default reads as "the tab is not in the URL", which is exactly how it
|
||||||
|
* was reported on the console's settings page.
|
||||||
|
*/
|
||||||
|
#[Url(history: true)]
|
||||||
|
public string $tab = 'profile';
|
||||||
|
|
||||||
/** Shown once, right after setting two-factor up. */
|
/** Shown once, right after setting two-factor up. */
|
||||||
public ?array $recoveryCodes = null;
|
public ?array $recoveryCodes = null;
|
||||||
|
|
||||||
|
|
@ -158,6 +194,13 @@ class Settings extends Component
|
||||||
|
|
||||||
public function mount(): void
|
public function mount(): void
|
||||||
{
|
{
|
||||||
|
// A tab name out of the URL is a string a stranger typed. Anything
|
||||||
|
// unknown falls back to the first rather than rendering a settings page
|
||||||
|
// with no section on it at all.
|
||||||
|
if (! in_array($this->tab, self::TABS, true)) {
|
||||||
|
$this->tab = self::TABS[0];
|
||||||
|
}
|
||||||
|
|
||||||
$c = $this->customer();
|
$c = $this->customer();
|
||||||
if ($c === null) {
|
if ($c === null) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -358,6 +401,7 @@ class Settings extends Component
|
||||||
$withdrawal = WithdrawalRight::for($contract);
|
$withdrawal = WithdrawalRight::for($contract);
|
||||||
|
|
||||||
return view('livewire.settings', [
|
return view('livewire.settings', [
|
||||||
|
'tabs' => self::TABS,
|
||||||
// Never the secret itself — only whether it exists, and the SVG
|
// Never the secret itself — only whether it exists, and the SVG
|
||||||
// Fortify renders from it. The secret in a Livewire property would
|
// Fortify renders from it. The secret in a Livewire property would
|
||||||
// travel to the browser and back in the component snapshot.
|
// travel to the browser and back in the component snapshot.
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,18 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
'eyebrow' => 'Konto',
|
||||||
|
'company_sub' => 'Diese Angaben stehen auf Ihren Rechnungen.',
|
||||||
|
// The tab bar. The list in App\Livewire\Settings::TABS decides which
|
||||||
|
// exist; these only name them.
|
||||||
|
'tab' => [
|
||||||
|
'profile' => 'Ihre Daten',
|
||||||
|
'security' => 'Sicherheit',
|
||||||
|
'branding' => 'Erscheinungsbild',
|
||||||
|
'contract' => 'Vertrag',
|
||||||
|
],
|
||||||
'title' => 'Einstellungen',
|
'title' => 'Einstellungen',
|
||||||
'subtitle' => 'Firmendaten, Branding und Ihr Paket verwalten.',
|
'subtitle' => 'Alles, was zu Ihrem Konto gehört — nach Themen getrennt.',
|
||||||
'save' => 'Speichern',
|
'save' => 'Speichern',
|
||||||
|
|
||||||
'company_title' => 'Firmendaten',
|
'company_title' => 'Firmendaten',
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,18 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
'eyebrow' => 'Account',
|
||||||
|
'company_sub' => 'These details appear on your invoices.',
|
||||||
|
// The tab bar. The list in App\Livewire\Settings::TABS decides which
|
||||||
|
// exist; these only name them.
|
||||||
|
'tab' => [
|
||||||
|
'profile' => 'Your details',
|
||||||
|
'security' => 'Security',
|
||||||
|
'branding' => 'Appearance',
|
||||||
|
'contract' => 'Contract',
|
||||||
|
],
|
||||||
'title' => 'Settings',
|
'title' => 'Settings',
|
||||||
'subtitle' => 'Manage your company details, branding and package.',
|
'subtitle' => 'Everything that belongs to your account, grouped by subject.',
|
||||||
'save' => 'Save',
|
'save' => 'Save',
|
||||||
|
|
||||||
'company_title' => 'Company details',
|
'company_title' => 'Company details',
|
||||||
|
|
|
||||||
|
|
@ -1,299 +1,350 @@
|
||||||
<div class="mx-auto max-w-3xl space-y-6">
|
{{--
|
||||||
<div class="animate-rise">
|
The customer's own settings.
|
||||||
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('settings.title') }}</h1>
|
|
||||||
<p class="mt-1 text-sm text-muted">{{ __('settings.subtitle') }}</p>
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
--}}
|
||||||
|
<div class="space-y-6">
|
||||||
|
|
||||||
|
<header class="animate-rise">
|
||||||
|
<p class="lbl">{{ __('settings.eyebrow') }}</p>
|
||||||
|
<h1 class="mt-[7px] text-[23px] font-bold leading-[1.12] tracking-[-0.03em] text-ink min-[901px]:text-[30px]">
|
||||||
|
{{ __('settings.title') }}
|
||||||
|
</h1>
|
||||||
|
<p class="mt-2 max-w-[76ch] text-sm leading-relaxed text-muted">{{ __('settings.subtitle') }}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{{-- The same tab bar the console uses, so one vocabulary holds on both sides
|
||||||
|
of the login. --}}
|
||||||
|
<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 }}')"
|
||||||
|
aria-selected="{{ $tab === $name ? 'true' : 'false' }}"
|
||||||
|
@class([
|
||||||
|
'flex items-center gap-2 whitespace-nowrap border-b-2 px-4 py-2.5 text-sm font-medium transition-colors -mb-px',
|
||||||
|
'border-accent-active text-ink' => $tab === $name,
|
||||||
|
'border-transparent text-muted hover:text-ink' => $tab !== $name,
|
||||||
|
])>
|
||||||
|
<x-ui.icon :name="['profile' => 'users', 'security' => 'shield-check', 'branding' => 'pen', 'contract' => 'receipt'][$name] ?? 'settings'"
|
||||||
|
class="size-4" />{{ __('settings.tab.'.$name) }}
|
||||||
|
</button>
|
||||||
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- Company / billing profile --}}
|
{{-- ── Ihre Daten ───────────────────────────────────────────────────── --}}
|
||||||
<form wire:submit="saveProfile" class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:60ms]">
|
@if ($tab === 'profile')
|
||||||
<h2 class="font-semibold text-ink">{{ __('settings.company_title') }}</h2>
|
<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="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<div class="space-y-5 rounded-lg border border-line bg-surface p-6 shadow-xs">
|
||||||
<x-ui.input name="companyName" wire:model="companyName" :label="__('settings.company_name')" />
|
<div>
|
||||||
<x-ui.input name="contactName" wire:model="contactName" :label="__('settings.contact_name')" />
|
<h2 class="text-sm font-bold text-ink">{{ __('settings.company_title') }}</h2>
|
||||||
<x-ui.input name="phone" wire:model="phone" :label="__('settings.phone')" />
|
<p class="mt-1 text-sm text-muted">{{ __('settings.company_sub') }}</p>
|
||||||
<x-ui.input name="vatId" wire:model="vatId" :label="__('settings.vat_id')" />
|
|
||||||
</div>
|
|
||||||
{{-- Beside the VAT number, deliberately, because that is the field
|
|
||||||
people mistake it for. A business without a number is an ordinary
|
|
||||||
small business and a consumer with one does not exist, so the two
|
|
||||||
answer different questions: this one decides the fourteen-day right
|
|
||||||
of withdrawal and whether reverse charge can ever apply. Nothing is
|
|
||||||
preselected for a customer nobody has asked yet. --}}
|
|
||||||
<fieldset class="space-y-1.5">
|
|
||||||
<legend class="text-sm font-medium text-body">{{ __('settings.customer_type') }}</legend>
|
|
||||||
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
|
||||||
@foreach ($customerTypes as $type)
|
|
||||||
<label class="flex cursor-pointer items-center gap-2 rounded-md 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 text-muted">{{ __('settings.customer_type_hint') }}</p>
|
|
||||||
@error('customerType')<p class="text-xs text-danger">{{ $message }}</p>@enderror
|
|
||||||
</fieldset>
|
|
||||||
<div>
|
|
||||||
<label class="text-sm font-medium text-body" for="billingAddress">{{ __('settings.billing_address') }}</label>
|
|
||||||
<textarea id="billingAddress" wire:model="billingAddress" rows="3"
|
|
||||||
class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-ink"></textarea>
|
|
||||||
@error('billingAddress')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-end">
|
|
||||||
<x-ui.button variant="primary" type="submit" wire:loading.attr="disabled" wire:target="saveProfile">{{ __('settings.save') }}</x-ui.button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{{-- Branding --}}
|
|
||||||
|
|
||||||
{{-- 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 animate-rise [animation-delay:90ms]">
|
|
||||||
<div>
|
|
||||||
<h2 class="font-semibold text-ink">{{ __('admin_settings.password_title') }}</h2>
|
|
||||||
<p class="mt-1 text-sm text-muted">{{ __('admin_settings.password_sub') }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-4 sm:grid-cols-3">
|
|
||||||
<x-ui.input name="currentPassword" type="password" autocomplete="current-password"
|
|
||||||
:label="__('admin_settings.password_current')" wire:model="currentPassword" />
|
|
||||||
<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">
|
|
||||||
<x-ui.button type="submit" variant="primary" wire:loading.attr="disabled" wire:target="updateOwnPassword">{{ __('admin_settings.password_save') }}</x-ui.button>
|
|
||||||
</div>
|
|
||||||
</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 animate-rise [animation-delay:150ms]">
|
|
||||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
|
||||||
<h2 class="font-semibold 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>
|
</div>
|
||||||
<p class="mt-1.5 max-w-xl text-sm text-muted">{{ __('settings.twofa_sub') }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (! $passwordConfirmed)
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
{{-- The gate. A signed-in session is not enough to change how the
|
<x-ui.input name="companyName" wire:model="companyName" :label="__('settings.company_name')" />
|
||||||
account is protected — the risk is an unlocked machine. --}}
|
<x-ui.input name="contactName" wire:model="contactName" :label="__('settings.contact_name')" />
|
||||||
<form wire:submit="confirmPassword" class="rounded-lg border border-line bg-surface-2 p-4">
|
<x-ui.input name="phone" wire:model="phone" :label="__('settings.phone')" />
|
||||||
<p class="text-sm text-body">{{ __('settings.twofa_confirm_first') }}</p>
|
<x-ui.input name="vatId" wire:model="vatId" :label="__('settings.vat_id')" />
|
||||||
<div class="mt-3 flex flex-wrap items-start gap-2">
|
</div>
|
||||||
<div class="min-w-56 flex-1">
|
|
||||||
<x-ui.input name="confirmablePassword" type="password" autocomplete="current-password"
|
<div>
|
||||||
:label="__('admin_settings.password_current')" wire:model="confirmablePassword" />
|
<label class="text-sm font-medium text-body" for="billingAddress">{{ __('settings.billing_address') }}</label>
|
||||||
</div>
|
<textarea id="billingAddress" wire:model="billingAddress" rows="3"
|
||||||
<x-ui.button type="submit" variant="secondary" class="mt-7" wire:loading.attr="disabled" wire:target="confirmPassword">
|
class="mt-1.5 w-full rounded border border-line-strong bg-surface px-3 py-2 text-sm text-ink"></textarea>
|
||||||
{{ __('settings.twofa_confirm_button') }}
|
@error('billingAddress')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
|
||||||
</x-ui.button>
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{{-- Beside the form rather than inside it: it is one question, it
|
||||||
|
decides the fourteen-day right of withdrawal and whether reverse
|
||||||
|
charge can ever apply, and people mistake it for the VAT field
|
||||||
|
when the two sit in the same row. Nothing is preselected for a
|
||||||
|
customer nobody has asked yet. --}}
|
||||||
|
<fieldset class="space-y-3 rounded-lg border border-line bg-surface p-6 shadow-xs">
|
||||||
|
<legend class="text-sm font-bold text-ink">{{ __('settings.customer_type') }}</legend>
|
||||||
|
<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_hint') }}</p>
|
||||||
|
@error('customerType')<p class="text-xs text-danger">{{ $message }}</p>@enderror
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- ── Sicherheit ───────────────────────────────────────────────────── --}}
|
||||||
|
@if ($tab === 'security')
|
||||||
|
<div class="grid gap-5 lg:grid-cols-2 lg:items-start 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>
|
||||||
|
|
||||||
|
<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>
|
</div>
|
||||||
</form>
|
</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-lg 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.button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
@else
|
|
||||||
<x-ui.button variant="primary" wire:click="enableTwoFactor" wire:loading.attr="disabled" wire:target="enableTwoFactor">
|
|
||||||
{{ __('settings.twofa_enable') }}
|
|
||||||
</x-ui.button>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if ($recoveryCodes)
|
{{-- Two-factor. Every action is re-checked server-side against a
|
||||||
{{-- Shown once, on purpose: nobody writes down what they were never
|
recent password confirmation — hiding the buttons stops nobody
|
||||||
shown, and these are the way back in when the phone is gone. --}}
|
who can post to /livewire/update. --}}
|
||||||
<div class="rounded-lg border border-warning-border bg-warning-bg p-4">
|
<div class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs">
|
||||||
<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 sm:grid-cols-4">
|
|
||||||
@foreach ($recoveryCodes as $code)<li class="select-all">{{ $code }}</li>@endforeach
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{{-- Directly under two-factor, because the two answer the same question:
|
|
||||||
who can get into this account, and what can I do about it. --}}
|
|
||||||
<div class="animate-rise [animation-delay:165ms]">
|
|
||||||
@livewire('sessions')
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form wire:submit="saveBranding" class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:120ms]">
|
|
||||||
<div>
|
|
||||||
<h2 class="font-semibold text-ink">{{ __('settings.branding_title') }}</h2>
|
|
||||||
<p class="mt-1 text-sm text-muted">{{ __('settings.branding_sub') }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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-md 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
|
|
||||||
</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-md 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
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="text-sm font-medium text-body">{{ __('settings.brand_logo') }}</label>
|
|
||||||
<div class="mt-1.5 flex flex-wrap items-center gap-4">
|
|
||||||
<div class="grid size-16 place-items-center overflow-hidden rounded-lg border border-line bg-surface-2">
|
|
||||||
@if ($logo)
|
|
||||||
<img src="{{ $logo->temporaryUrl() }}" alt="" class="max-h-16 max-w-16 object-contain" />
|
|
||||||
@elseif ($logoUrl)
|
|
||||||
<img src="{{ $logoUrl }}" alt="" class="max-h-16 max-w-16 object-contain" />
|
|
||||||
@else
|
|
||||||
<span class="text-xs text-muted">{{ __('settings.brand_default') }}</span>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col gap-1.5">
|
|
||||||
<input type="file" wire:model="logo" accept="image/png,image/webp" class="text-sm text-body file:mr-3 file:rounded-md 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 text-muted">{{ __('settings.brand_logo_hint') }}</p>
|
|
||||||
@if ($logoUrl)
|
|
||||||
<button type="button" wire:click="removeLogo" class="self-start text-xs font-semibold text-danger hover:underline">{{ __('settings.brand_logo_remove') }}</button>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@error('logo')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if ($branding['is_default'] ?? false)
|
|
||||||
<p class="text-xs text-muted">{{ __('settings.brand_using_default') }}</p>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
<div class="flex justify-end">
|
|
||||||
<x-ui.button variant="primary" type="submit" wire:loading.attr="disabled" wire:target="saveBranding,logo">{{ __('settings.save') }}</x-ui.button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{{-- Package & account lifecycle --}}
|
|
||||||
<div class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:180ms]">
|
|
||||||
<h2 class="font-semibold text-ink">{{ __('settings.package_title') }}</h2>
|
|
||||||
|
|
||||||
@if ($cancellationScheduled)
|
|
||||||
<div class="flex items-start gap-3 rounded-lg border border-warning-border bg-warning-bg p-4">
|
|
||||||
<x-ui.icon name="alert-triangle" class="size-5 shrink-0 text-warning" />
|
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-semibold text-ink">{{ __('settings.cancel_scheduled_title') }}</p>
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<p class="mt-0.5 text-sm text-body">{{ __('settings.cancel_scheduled_body', ['date' => $instance?->service_ends_at?->local()->isoFormat('LL')]) }}</p>
|
<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-1.5 text-sm text-muted">{{ __('settings.twofa_sub') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@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" />
|
||||||
|
</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>
|
||||||
|
</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.button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<x-ui.button variant="primary" wire:click="enableTwoFactor" wire:loading.attr="disabled" wire:target="enableTwoFactor">
|
||||||
|
{{ __('settings.twofa_enable') }}
|
||||||
|
</x-ui.button>
|
||||||
|
@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">
|
||||||
|
@foreach ($recoveryCodes as $code)<li class="select-all">{{ $code }}</li>@endforeach
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- 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>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- ── 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>
|
||||||
|
|
||||||
|
<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
|
||||||
|
</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
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
@elseif ($hasActivePackage)
|
|
||||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
<div class="space-y-3 rounded-lg border border-line bg-surface p-6 shadow-xs">
|
||||||
<p class="text-sm text-muted">{{ __('settings.package_active', ['plan' => $instance ? __('billing.plan.'.$instance->plan) : '—']) }}</p>
|
<label class="text-sm font-bold text-ink">{{ __('settings.brand_logo') }}</label>
|
||||||
<x-ui.button variant="secondary" size="sm"
|
<div class="grid size-20 place-items-center overflow-hidden rounded border border-line bg-surface-2">
|
||||||
x-on:click="$dispatch('openModal', { component: 'confirm-cancel-package' })">
|
@if ($logo)
|
||||||
{{ __('settings.cancel_cta') }}
|
<img src="{{ $logo->temporaryUrl() }}" alt="" class="max-h-20 max-w-20 object-contain" />
|
||||||
</x-ui.button>
|
@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>
|
||||||
|
<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>
|
</div>
|
||||||
@else
|
</form>
|
||||||
<p class="text-sm text-muted">{{ __('settings.no_package') }}</p>
|
@endif
|
||||||
@endif
|
|
||||||
|
|
||||||
{{-- The fourteen-day right of withdrawal. Shown only where it exists:
|
{{-- ── Vertrag ──────────────────────────────────────────────────────── --}}
|
||||||
a business customer has none and never sees this block — and the
|
@if ($tab === 'contract')
|
||||||
server refuses them again in Settings::withdraw(), because a card
|
<div class="grid gap-5 lg:grid-cols-2 lg:items-start animate-rise [animation-delay:60ms]">
|
||||||
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.
|
<div class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs">
|
||||||
A cancellation ends a contract that was validly concluded and keeps
|
<h2 class="text-sm font-bold text-ink">{{ __('settings.package_title') }}</h2>
|
||||||
the term the customer paid for; a withdrawal unwinds the contract
|
|
||||||
itself, ends the service the same day and sends the money back. --}}
|
@if ($cancellationScheduled)
|
||||||
@if ($withdrawal->applies && $withdrawal->open)
|
<div class="flex items-start gap-3 rounded border border-warning-border bg-warning-bg p-4">
|
||||||
<div class="border-t border-line pt-4">
|
<x-ui.icon name="alert-triangle" class="size-5 shrink-0 text-warning" />
|
||||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
<div>
|
||||||
<div>
|
<p class="text-sm font-semibold text-ink">{{ __('settings.cancel_scheduled_title') }}</p>
|
||||||
|
<p class="mt-0.5 text-sm text-body">{{ __('settings.cancel_scheduled_body', ['date' => $instance?->service_ends_at?->local()->isoFormat('LL')]) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@elseif ($hasActivePackage)
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<p class="text-sm text-muted">{{ __('settings.package_active', ['plan' => $instance ? __('billing.plan.'.$instance->plan) : '—']) }}</p>
|
||||||
|
<x-ui.button variant="secondary" size="sm"
|
||||||
|
x-on:click="$dispatch('openModal', { component: 'confirm-cancel-package' })">
|
||||||
|
{{ __('settings.cancel_cta') }}
|
||||||
|
</x-ui.button>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<p class="text-sm text-muted">{{ __('settings.no_package') }}</p>
|
||||||
|
@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
|
||||||
|
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="border-t border-line pt-4">
|
||||||
<p class="text-sm font-semibold text-ink">{{ __('withdrawal.card_title') }}</p>
|
<p class="text-sm font-semibold text-ink">{{ __('withdrawal.card_title') }}</p>
|
||||||
<p class="mt-0.5 max-w-xl text-sm text-muted">
|
<p class="mt-0.5 text-sm text-muted">
|
||||||
{{ __('withdrawal.card_sub', [
|
{{ __('withdrawal.card_sub', [
|
||||||
'date' => $withdrawal->endsAt->local()->isoFormat('LL'),
|
'date' => $withdrawal->endsAt->local()->isoFormat('LL'),
|
||||||
'days' => $withdrawal->daysLeft(),
|
'days' => $withdrawal->daysLeft(),
|
||||||
]) }}
|
]) }}
|
||||||
</p>
|
</p>
|
||||||
|
<x-ui.button variant="secondary" size="sm" class="mt-3"
|
||||||
|
x-on:click="$dispatch('openModal', { component: 'confirm-withdraw' })">
|
||||||
|
{{ __('withdrawal.cta') }}
|
||||||
|
</x-ui.button>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs">
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<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>
|
</div>
|
||||||
<x-ui.button variant="secondary" size="sm"
|
<x-ui.button variant="secondary" size="sm"
|
||||||
x-on:click="$dispatch('openModal', { component: 'confirm-withdraw' })">
|
x-on:click="$dispatch('openModal', { component: 'confirm-close-account' })">
|
||||||
{{ __('withdrawal.cta') }}
|
{{ __('settings.close_cta') }}
|
||||||
</x-ui.button>
|
</x-ui.button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
<div class="border-t border-line pt-4">
|
{{-- The deadlines, said where somebody asks the question.
|
||||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
"Nach fünf Tagen gelöscht" on the verification page reads as
|
||||||
<div>
|
if it applied to every account; it applies to a registration
|
||||||
<p class="text-sm font-semibold text-ink">{{ __('settings.close_account_title') }}</p>
|
nobody confirmed. The other rule — a year without a package —
|
||||||
<p class="mt-0.5 text-sm text-muted">{{ __('settings.close_account_sub') }}</p>
|
was nowhere at all. Both come from the commands that enforce
|
||||||
|
them, so the page cannot drift from what actually happens. --}}
|
||||||
|
<div class="rounded border border-line bg-surface-2 p-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">
|
||||||
|
<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>
|
||||||
|
</ul>
|
||||||
|
<a href="{{ route('legal.agb') }}" target="_blank" rel="noopener"
|
||||||
|
class="mt-3 inline-block text-sm font-medium text-accent-text underline-offset-4 hover:underline">
|
||||||
|
{{ __('settings.lifecycle_terms') }}
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<x-ui.button variant="secondary" size="sm"
|
|
||||||
x-on:click="$dispatch('openModal', { component: 'confirm-close-account' })">
|
|
||||||
{{ __('settings.close_cta') }}
|
|
||||||
</x-ui.button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{{-- 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. --}}
|
|
||||||
<div class="mt-4 rounded-md border border-line bg-surface-2 p-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">
|
|
||||||
<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>
|
|
||||||
</ul>
|
|
||||||
<a href="{{ route('legal.agb') }}" target="_blank" rel="noopener"
|
|
||||||
class="mt-3 inline-block text-sm font-medium text-accent-text underline-offset-4 hover:underline">
|
|
||||||
{{ __('settings.lifecycle_terms') }}
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -539,9 +539,13 @@ it('shows the card to a consumer and never to a business', function () {
|
||||||
$user = User::factory()->create(['email' => $contract->customer->email]);
|
$user = User::factory()->create(['email' => $contract->customer->email]);
|
||||||
$contract->customer->update(['user_id' => $user->id]);
|
$contract->customer->update(['user_id' => $user->id]);
|
||||||
|
|
||||||
Livewire::actingAs($user)->test(Settings::class)->assertSee(__('withdrawal.cta'));
|
// On the contract tab: the settings page is four tabs now, and a withdrawal
|
||||||
|
// belongs with the contract rather than with the company address.
|
||||||
|
Livewire::actingAs($user)->test(Settings::class, ['tab' => 'contract'])
|
||||||
|
->assertSee(__('withdrawal.cta'));
|
||||||
|
|
||||||
$contract->customer->update(['customer_type' => Customer::TYPE_BUSINESS]);
|
$contract->customer->update(['customer_type' => Customer::TYPE_BUSINESS]);
|
||||||
|
|
||||||
Livewire::actingAs($user)->test(Settings::class)->assertDontSee(__('withdrawal.cta'));
|
Livewire::actingAs($user)->test(Settings::class, ['tab' => 'contract'])
|
||||||
|
->assertDontSee(__('withdrawal.cta'));
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -169,3 +169,71 @@ it('closes the account when no package is active', function () {
|
||||||
|
|
||||||
expect($customer->fresh()->closed_at)->not->toBeNull();
|
expect($customer->fresh()->closed_at)->not->toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- Four tabs, and the choice in the address ----
|
||||||
|
|
||||||
|
it('opens on the customer own details and says so in the address', function () {
|
||||||
|
// The page was one column 768px wide inside a 1240px shell, holding the
|
||||||
|
// company address, the password, two-factor, the devices, the branding and
|
||||||
|
// the contract — two thousand pixels of scroll, and finding a thing meant
|
||||||
|
// knowing how far down it lived.
|
||||||
|
$user = App\Models\User::factory()->create(['email_verified_at' => now()]);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)->test(App\Livewire\Settings::class)
|
||||||
|
->assertSet('tab', 'profile')
|
||||||
|
->assertSee(__('settings.company_title'))
|
||||||
|
// Not the other tabs' content.
|
||||||
|
->assertDontSee(__('settings.branding_title'))
|
||||||
|
->assertDontSee(__('settings.package_title'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lands on the tab the address names', function () {
|
||||||
|
$user = App\Models\User::factory()->create(['email_verified_at' => now()]);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)->test(App\Livewire\Settings::class, ['tab' => 'branding'])
|
||||||
|
->assertSet('tab', 'branding')
|
||||||
|
->assertSee(__('settings.branding_title'))
|
||||||
|
->assertDontSee(__('settings.company_title'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the first tab when the address names one nobody has', function () {
|
||||||
|
// A tab name out of the URL is a string a stranger typed; the alternative
|
||||||
|
// is a settings page with no section on it at all.
|
||||||
|
$user = App\Models\User::factory()->create(['email_verified_at' => now()]);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)->test(App\Livewire\Settings::class, ['tab' => 'gibt-es-nicht'])
|
||||||
|
->assertSet('tab', 'profile')
|
||||||
|
->assertSee(__('settings.company_title'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes the tab into the query string, so a link can name one', function () {
|
||||||
|
// history: true and no `except` — the parameter is there from the first
|
||||||
|
// render. Hidden on the default, a link to this page could not say which
|
||||||
|
// tab it meant unless the tab happened not to be the first.
|
||||||
|
$component = Illuminate\Support\Facades\File::get(app_path('Livewire/Settings.php'));
|
||||||
|
|
||||||
|
expect($component)->toContain('#[Url(history: true)]')
|
||||||
|
// The property and its default, matched without spelling a PHP
|
||||||
|
// variable inside a quoted string.
|
||||||
|
->and($component)->toContain('public string $tab')
|
||||||
|
->and($component)->toContain("= 'profile';")
|
||||||
|
->and($component)->not->toContain('except:');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps every tab reachable from the bar', function () {
|
||||||
|
// The list IS the schema: it validates the query string, builds the bar and
|
||||||
|
// decides which section renders.
|
||||||
|
$user = App\Models\User::factory()->create(['email_verified_at' => now()]);
|
||||||
|
$page = Livewire::actingAs($user)->test(App\Livewire\Settings::class);
|
||||||
|
|
||||||
|
foreach (App\Livewire\Settings::TABS as $tab) {
|
||||||
|
$page->assertSee(__('settings.tab.'.$tab));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the width it is given', function () {
|
||||||
|
// 768px in a 1240px shell left a third of the window empty beside a column
|
||||||
|
// nobody could read the end of.
|
||||||
|
expect(Illuminate\Support\Facades\File::get(resource_path('views/livewire/settings.blade.php')))
|
||||||
|
->not->toContain('max-w-3xl');
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue