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

457 lines
29 KiB
PHP

{{--
The customer's own settings.
## What was wrong, three times over
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">
<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. 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 }}')"
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="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>
{{-- 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.row>
{{-- 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>
<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 ═══════════════════════════════════════════════════════ --}}
@if ($tab === 'security')
<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">
<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-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. --}}
<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>
</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">
<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.row>
</form>
@elseif ($twoFactorOn)
<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>
<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.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. --}}
<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>
</x-ui.row>
@endif
</x-ui.panel>
{{-- 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 ═════════════════════════════════════════════════ --}}
@if ($tab === 'branding')
<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.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>
@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>
@error('brandAccent')<p class="mt-1.5 text-xs text-danger">{{ $message }}</p>@enderror
</x-ui.row>
<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>
<x-slot:footer>
@if ($branding['is_default'] ?? false)
<p class="mr-auto text-xs text-muted">{{ __('settings.brand_using_default') }}</p>
@endif
<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 ══════════════════════════════════════════════════════════ --}}
@if ($tab === 'contract')
<div class="space-y-5 animate-rise [animation-delay:60ms]">
<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_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>
@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>
</x-ui.row>
{{-- ── Das Widerrufsrecht ────────────────────────────────────
Shown to every CONSUMER with a contract, open or not not
only while it is open. It vanished entirely once the
fourteen days ran out, which is indistinguishable from a
feature that was never built: the owner went looking for it
and reported it missing from an account that has no contract
at all, so there was nothing for it to be about.
A business never sees it, and the server refuses them again
in Settings::withdraw() a card that is not rendered has
never stopped anybody who can post to /livewire/update.
Not a variant of the cancellation above: that one ends a
contract validly concluded and keeps the term already paid
for; this one unwinds the contract, ends the service the same
day and sends the whole amount back. --}}
@if ($withdrawal->applies)
<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>
@if ($withdrawal->open)
<x-ui.button variant="secondary" size="sm"
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>
@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
{{-- 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">
<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>
</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>
</x-ui.panel>
</div>
@endif
</div>