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

351 lines
23 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>
<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 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 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>
</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 ──────────────────────────────────────────────────────── --}}
@if ($tab === 'contract')
<div class="grid gap-5 lg:grid-cols-2 lg:items-start animate-rise [animation-delay:60ms]">
<div class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs">
<h2 class="text-sm font-bold text-ink">{{ __('settings.package_title') }}</h2>
@if ($cancellationScheduled)
<div class="flex items-start gap-3 rounded border border-warning-border bg-warning-bg p-4">
<x-ui.icon name="alert-triangle" class="size-5 shrink-0 text-warning" />
<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="mt-0.5 text-sm text-muted">
{{ __('withdrawal.card_sub', [
'date' => $withdrawal->endsAt->local()->isoFormat('LL'),
'days' => $withdrawal->daysLeft(),
]) }}
</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>
<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="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>
</div>
@endif
</div>