CluPilotCloud/resources/views/livewire/settings.blade.php

458 lines
30 KiB
PHP

{{--
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.
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>
{{-- ── 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>
<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')" />
</div>
<x-ui.input name="billingCountry" wire:model="billingCountry" :label="__('settings.billing_country')" />
</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, 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>
</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>
</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>
</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 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>
@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>
</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. --}}
@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">
@if ($cancellationScheduled)
{{ __('settings.cancel_scheduled_title') }}
@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>
</div>
@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="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(),
]) }}
</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>
<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>
</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>
{{-- 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="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">
<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>
</section>
</div>
@endif
</div>