presseportale/resources/views/livewire/customer/profile.blade.php
2026-06-12 14:36:18 +00:00

520 lines
25 KiB
PHP

<?php
use App\Enums\VatIdCheckStatus;
use App\Models\User;
use App\Services\Billing\VatIdValidationService;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Volt\Component;
new #[Layout('components.layouts.app'), Title('Mein Profil')] class extends Component
{
public string $name = '';
public string $language = 'de';
public string $salutationKey = 'none';
public string $firstName = '';
public string $lastName = '';
public string $title = '';
public string $phone = '';
public string $address = '';
public string $countryCode = 'DE';
public string $backlinkUrl = '';
public bool $showStats = false;
public bool $disableFooterCode = false;
public string $taxIdNumber = '';
public string $billingSalutationKey = 'none';
public string $billingCompany = '';
public string $billingFirstName = '';
public string $billingLastName = '';
public string $billingAddress1 = '';
public string $billingAddress2 = '';
public string $billingPostalCode = '';
public string $billingCity = '';
public string $billingCountryCode = 'DE';
public ?string $vatCheckStatus = null;
public ?string $vatCheckMessage = null;
public function mount(): void
{
$user = auth()->user();
$profile = $user->profile;
$this->name = (string) $user->name;
$this->language = $user->language ?? 'de';
$this->salutationKey = (string) ($profile?->salutation_key ?? 'none');
$this->firstName = (string) ($profile?->first_name ?? '');
$this->lastName = (string) ($profile?->last_name ?? '');
$this->title = (string) ($profile?->title ?? '');
$this->phone = (string) ($profile?->phone ?? '');
$this->address = (string) ($profile?->address ?? '');
$this->countryCode = (string) ($profile?->country_code ?? 'DE');
$this->backlinkUrl = (string) ($profile?->backlink_url ?? '');
$this->showStats = (bool) ($profile?->show_stats ?? false);
$this->disableFooterCode = (bool) ($profile?->disable_footer_code ?? false);
$this->taxIdNumber = (string) ($profile?->tax_id_number ?? '');
$billingAddress = $user->billingAddress;
$this->billingSalutationKey = (string) ($billingAddress?->salutation_key ?? 'none');
$this->billingCompany = (string) ($billingAddress?->company ?? '');
$this->billingFirstName = (string) ($billingAddress?->first_name ?? '');
$this->billingLastName = (string) ($billingAddress?->last_name ?? '');
$this->billingAddress1 = (string) ($billingAddress?->address1 ?? '');
$this->billingAddress2 = (string) ($billingAddress?->address2 ?? '');
$this->billingPostalCode = (string) ($billingAddress?->postal_code ?? '');
$this->billingCity = (string) ($billingAddress?->city ?? '');
$this->billingCountryCode = (string) ($billingAddress?->country_code ?? 'DE');
// Bestandsdaten vor der Feld-Trennung: `name` war eine freie
// Empfängerzeile — einmalig in Vor-/Nachname aufteilen.
if (blank($this->billingFirstName) && blank($this->billingLastName) && filled($billingAddress?->name)) {
$parts = preg_split('/\s+/u', trim((string) $billingAddress->name)) ?: [];
$this->billingLastName = (string) array_pop($parts);
$this->billingFirstName = implode(' ', $parts);
}
if (filled($this->taxIdNumber)) {
$this->refreshVatCheck();
}
}
/**
* Persönliche Daten als Rechnungsempfänger übernehmen — löst die von
* Kevin angemerkte Doppel-Eingabe auf, ohne die Datensätze zu koppeln.
*/
public function copyProfileToBilling(): void
{
$this->billingSalutationKey = $this->salutationKey;
$this->billingFirstName = $this->firstName;
$this->billingLastName = $this->lastName;
if (filled($this->countryCode)) {
$this->billingCountryCode = $this->countryCode;
}
}
public function updatedTaxIdNumber(): void
{
$this->refreshVatCheck();
}
public function updatedBillingCountryCode(): void
{
$this->refreshVatCheck();
}
private function refreshVatCheck(): void
{
if (blank($this->taxIdNumber)) {
$this->vatCheckStatus = null;
$this->vatCheckMessage = null;
return;
}
$result = app(VatIdValidationService::class)->check($this->taxIdNumber, $this->billingCountryCode);
$this->vatCheckStatus = $result['status']->value;
$this->vatCheckMessage = $result['message'];
}
public function saveProfile(): void
{
$rules = [
'name' => ['required', 'string', 'max:120'],
'language' => ['required', Rule::in(['de', 'en'])],
'salutationKey' => ['required', Rule::in(array_keys((array) config('salutations.items', [])))],
'firstName' => ['nullable', 'string', 'max:80'],
'lastName' => ['nullable', 'string', 'max:80'],
'title' => ['nullable', 'string', 'max:80'],
'phone' => ['nullable', 'string', 'max:40'],
'address' => ['nullable', 'string', 'max:1000'],
'countryCode' => ['nullable', 'string', 'size:2', Rule::in(array_keys((array) config('countries.items', [])))],
'backlinkUrl' => ['nullable', 'url', 'max:255'],
'taxIdNumber' => ['nullable', 'string', 'max:255'],
'billingSalutationKey' => ['required', Rule::in(array_keys((array) config('salutations.items', [])))],
'billingCompany' => ['nullable', 'string', 'max:255'],
'billingFirstName' => ['nullable', 'string', 'max:80'],
'billingLastName' => ['nullable', 'string', 'max:80'],
'billingAddress1' => ['nullable', 'string', 'max:255'],
'billingAddress2' => ['nullable', 'string', 'max:255'],
'billingPostalCode' => ['nullable', 'string', 'max:20'],
'billingCity' => ['nullable', 'string', 'max:120'],
'billingCountryCode' => ['nullable', 'string', 'size:2', Rule::in(array_keys((array) config('countries.items', [])))],
];
// Sobald irgendein Rechnungsfeld gefüllt ist, werden die
// Pflichtfelder einzeln eingefordert — die Meldung erscheint genau
// einmal unter dem jeweils fehlenden Feld (vorher: eine generische
// Sammelmeldung zusätzlich zur Feldmeldung).
if ($this->billingHasInput()) {
$rules['billingLastName'] = ['required', 'string', 'max:80'];
$rules['billingAddress1'] = ['required', 'string', 'max:255'];
$rules['billingPostalCode'] = ['required', 'string', 'max:20'];
$rules['billingCity'] = ['required', 'string', 'max:120'];
$rules['billingCountryCode'] = ['required', 'string', 'size:2', Rule::in(array_keys((array) config('countries.items', [])))];
}
$validated = $this->validate($rules, attributes: [
'billingLastName' => __('Nachname (Rechnung)'),
'billingAddress1' => __('Straße und Hausnummer'),
'billingPostalCode' => __('PLZ'),
'billingCity' => __('Ort'),
'billingCountryCode' => __('Land'),
]);
// USt-ID: hartes Format-Gate; die Online-Bestätigung (eVatR) bleibt
// ein Hinweis und blockiert das Speichern nicht.
if (filled($validated['taxIdNumber'])) {
$this->refreshVatCheck();
if ($this->vatCheckStatus === VatIdCheckStatus::FormatInvalid->value) {
$this->addError('taxIdNumber', (string) $this->vatCheckMessage);
return;
}
}
/** @var User $user */
$user = auth()->user();
$user->forceFill([
'name' => $validated['name'],
'language' => $validated['language'],
])->save();
$user->profile()->updateOrCreate(
['user_id' => $user->id],
[
'salutation_key' => $validated['salutationKey'],
'first_name' => $validated['firstName'] ?: null,
'last_name' => $validated['lastName'] ?: null,
'title' => $validated['title'] ?: null,
'phone' => $validated['phone'] ?: null,
'address' => $validated['address'] ?: null,
'country_code' => $validated['countryCode'] ?: null,
'backlink_url' => $validated['backlinkUrl'] ?: null,
'show_stats' => $this->showStats,
'disable_footer_code' => $this->disableFooterCode,
'tax_id_number' => $validated['taxIdNumber'] ?: null,
]
);
if (! $this->billingHasInput()) {
$user->billingAddress()->delete();
} else {
$user->billingAddress()->updateOrCreate(
['user_id' => $user->id],
[
'salutation_key' => $validated['billingSalutationKey'] !== 'none' ? $validated['billingSalutationKey'] : null,
'company' => $validated['billingCompany'] ?: null,
'first_name' => $validated['billingFirstName'] ?: null,
'last_name' => $validated['billingLastName'] ?: null,
// Zusammengesetzte Empfängerzeile für Rechnungs-Snapshots.
'name' => trim($validated['billingFirstName'].' '.$validated['billingLastName']),
'address1' => $validated['billingAddress1'],
'address2' => $validated['billingAddress2'] ?: null,
'postal_code' => $validated['billingPostalCode'],
'city' => $validated['billingCity'],
'country_code' => $validated['billingCountryCode'],
// USt-ID auch an der Rechnungsadresse pflegen — sie wird
// pro Rechnung eingefroren und bestimmt die Steuer
// (EU-Befreiung nur mit gültiger USt-ID).
'vat_id' => $validated['taxIdNumber'] ?: null,
],
);
}
session()->flash('profile-status', __('Profil gespeichert.'));
}
public function with(): array
{
$user = auth()->user();
return [
'user' => $user,
'salutations' => collect((array) config('salutations.items', []))
->map(fn (array $labels) => $labels[$user->language] ?? $labels['de'] ?? '')
->all(),
'countries' => (array) config('countries.items', []),
'billingComplete' => $this->billingIsComplete(),
];
}
public function billingHasInput(): bool
{
return filled($this->billingCompany)
|| filled($this->billingFirstName)
|| filled($this->billingLastName)
|| filled($this->billingAddress1)
|| filled($this->billingAddress2)
|| filled($this->billingPostalCode)
|| filled($this->billingCity);
}
public function billingIsComplete(): bool
{
return filled($this->billingLastName)
&& filled($this->billingAddress1)
&& filled($this->billingPostalCode)
&& filled($this->billingCity)
&& filled($this->billingCountryCode);
}
}; ?>
<div class="space-y-8">
{{-- ============== PAGE HEADER ============== --}}
<header class="page-header">
<div class="min-w-0">
<div class="flex items-center gap-3 mb-3 flex-wrap">
<span class="badge hub dot">{{ __('User Backend') }}</span>
<span class="eyebrow muted">{{ __('Mein Bereich · Profil') }}</span>
</div>
<h1 class="text-[30px] font-bold tracking-[-0.6px] leading-[1.15] m-0 text-[color:var(--color-ink)]">
{{ __('Mein Profil') }}
</h1>
<p class="text-[13px] leading-[1.55] mt-2 m-0 max-w-[680px] text-[color:var(--color-ink-2)]">
{{ __('Persönliche Daten, Rechnungsadresse und Konto-Einstellungen. Firmendaten liegen separat in der Firmenverwaltung.') }}
</p>
</div>
<div class="flex items-center gap-2 flex-shrink-0">
<flux:button size="sm" variant="filled" icon="building-office" href="{{ route('me.press-kits.index') }}" wire:navigate>
{{ __('Firmen verwalten') }}
</flux:button>
</div>
</header>
@if (session('profile-status'))
<div class="px-4 py-3 rounded-[5px] border text-[12.5px] flex items-center gap-2
bg-[color:var(--color-ok-soft)] border-[color:var(--color-ok)]/30 text-[color:var(--color-gain-deep)]">
<flux:icon.check-circle class="size-[16px] flex-shrink-0" />
{{ session('profile-status') }}
</div>
@endif
@if (session('checkout-notice'))
<div class="px-4 py-3 rounded-[5px] border text-[12.5px] flex items-start gap-3
bg-[color:var(--color-warn-soft)] border-[color:var(--color-warn)]/30 text-[color:var(--color-ink-2)]">
<flux:icon.exclamation-triangle class="size-[16px] flex-shrink-0 mt-0.5 text-[color:var(--color-accent-deep)]" />
<div class="flex-1">{{ session('checkout-notice') }}</div>
</div>
@endif
<form wire:submit="saveProfile" class="space-y-6">
{{-- ============== 1) PERSÖNLICHE DATEN + KONTO ============== --}}
<div class="grid gap-6 lg:grid-cols-2">
<article class="panel" id="profil">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Persönliche Daten') }}</span>
</div>
<div class="p-5 space-y-4">
<p class="text-[12.5px] leading-[1.55] text-[color:var(--color-ink-3)] m-0">
{{ __('Ihre Kontaktdaten für Ansprache und Rückfragen — unabhängig von der Rechnungsadresse unten.') }}
</p>
<div class="grid gap-4 sm:grid-cols-2">
<flux:input wire:model="name" :label="__('Anzeigename')" required class="sm:col-span-2" />
<flux:select wire:model="salutationKey" :label="__('Anrede')">
@foreach ($salutations as $key => $label)
<option value="{{ $key }}">{{ $label }}</option>
@endforeach
</flux:select>
<flux:input wire:model="title" :label="__('Titel')" placeholder="Dr." />
<flux:input wire:model="firstName" :label="__('Vorname')" />
<flux:input wire:model="lastName" :label="__('Nachname')" />
<flux:input wire:model="phone" :label="__('Telefon')" />
<flux:input wire:model="backlinkUrl" :label="__('Backlink-URL')" placeholder="https://..." />
<flux:textarea wire:model="address" :label="__('Kontaktadresse')" class="sm:col-span-2" />
<flux:select wire:model="countryCode" :label="__('Land')" class="sm:col-span-2">
@foreach ($countries as $code => $name)
<option value="{{ $code }}">{{ $name }}</option>
@endforeach
</flux:select>
</div>
</div>
</article>
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Konto & Sicherheit') }}</span>
</div>
<div class="p-5 space-y-4">
<flux:input value="{{ $user->email }}" :label="__('E-Mail')" disabled :description="__('Änderung über Konto-Sicherheit möglich.')" />
<flux:select wire:model="language" :label="__('Sprache')">
<option value="de">Deutsch</option>
<option value="en">English</option>
</flux:select>
<div class="pt-3 border-t border-[color:var(--color-bg-rule)]">
<flux:button size="sm" variant="filled" icon="shield-check" href="{{ route('me.security') }}" wire:navigate>
{{ __('Konto-Sicherheit öffnen') }}
</flux:button>
</div>
</div>
</article>
</div>
{{-- ============== 2) RECHNUNGSADRESSE ============== --}}
<article class="panel" id="rechnungsadresse">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Rechnungsadresse') }}</span>
@if ($billingComplete)
<span class="badge ok dot">{{ __('vollständig') }}</span>
@else
<span class="badge warn dot">{{ __('unvollständig') }}</span>
@endif
</div>
<div class="p-5 grid gap-6 lg:grid-cols-[0.8fr_1.2fr]">
<div class="space-y-3">
<p class="text-[13px] leading-[1.55] text-[color:var(--color-ink-2)] m-0">
{{ __('Grundlage für Rechnungen und Pflicht für jede Tarif- oder Einzel-PM-Buchung. Die Daten werden bei der Buchung an Stripe übergeben.') }}
</p>
<p class="text-[12.5px] leading-[1.55] text-[color:var(--color-ink-3)] m-0">
{{ __('Pflichtangaben: Nachname, Straße, PLZ, Ort und Land. Firmenname und USt-ID sind optional.') }}
</p>
@if (! $billingComplete)
<div class="px-4 py-3 rounded-[5px] border text-[12.5px] flex items-start gap-3
bg-[color:var(--color-warn-soft)] border-[color:var(--color-warn)]/30 text-[color:var(--color-ink-2)]">
<flux:icon.exclamation-triangle class="size-[16px] flex-shrink-0 mt-0.5 text-[color:var(--color-accent-deep)]" />
<div class="flex-1">
{{ __('Ohne vollständige Rechnungsadresse können keine Tarife oder Einzel-PMs gebucht werden.') }}
</div>
</div>
@else
<div class="px-4 py-3 rounded-[5px] border text-[12.5px] flex items-start gap-3
bg-[color:var(--color-ok-soft)] border-[color:var(--color-ok)]/30 text-[color:var(--color-gain-deep)]">
<flux:icon.check-circle class="size-[16px] flex-shrink-0 mt-0.5" />
<div class="flex-1">
{{ __('Ihre Rechnungsadresse ist vollständig hinterlegt.') }}
</div>
</div>
@endif
<flux:button size="sm" variant="filled" icon="arrow-down-on-square" wire:click="copyProfileToBilling">
{{ __('Persönliche Daten übernehmen') }}
</flux:button>
</div>
<div class="space-y-5">
<div>
<div class="text-[11px] font-semibold tracking-[0.14em] uppercase text-[color:var(--color-ink-3)] mb-3">
{{ __('Rechnungsempfänger') }}
</div>
<div class="grid gap-4 sm:grid-cols-2">
<flux:select wire:model="billingSalutationKey" :label="__('Anrede')">
@foreach ($salutations as $key => $label)
<option value="{{ $key }}">{{ $label }}</option>
@endforeach
</flux:select>
<flux:input wire:model="billingCompany" :label="__('Firmenname (optional)')" />
<flux:input wire:model="billingFirstName" :label="__('Vorname')" />
<flux:input wire:model="billingLastName" :label="__('Nachname')" />
</div>
</div>
<div>
<div class="text-[11px] font-semibold tracking-[0.14em] uppercase text-[color:var(--color-ink-3)] mb-3">
{{ __('Anschrift') }}
</div>
<div class="grid gap-4 sm:grid-cols-2">
<flux:input wire:model="billingAddress1" :label="__('Straße und Hausnummer')" class="sm:col-span-2" />
<flux:input wire:model="billingAddress2" :label="__('Adresszusatz (optional)')" class="sm:col-span-2" />
<flux:input wire:model="billingPostalCode" :label="__('PLZ')" />
<flux:input wire:model="billingCity" :label="__('Ort')" />
<flux:select wire:model.live="billingCountryCode" :label="__('Land')" class="sm:col-span-2">
@foreach ($countries as $code => $name)
<option value="{{ $code }}">{{ $name }}</option>
@endforeach
</flux:select>
</div>
</div>
<div>
<div class="text-[11px] font-semibold tracking-[0.14em] uppercase text-[color:var(--color-ink-3)] mb-3">
{{ __('Steuern') }}
</div>
<flux:input
wire:model.live.debounce.600ms="taxIdNumber"
:label="__('USt-ID (optional)')"
placeholder="DE123456789"
:description="__('Für EU-Firmen außerhalb Deutschlands entfällt mit gültiger USt-ID die deutsche Umsatzsteuer (Reverse Charge).')"
/>
@if ($vatCheckMessage)
@if ($vatCheckStatus === 'valid')
<div class="mt-2 text-[12px] flex items-start gap-1.5 text-[color:var(--color-gain-deep)]">
<flux:icon.check-circle class="size-[14px] flex-shrink-0 mt-0.5" />
<span>{{ $vatCheckMessage }}</span>
</div>
@elseif (in_array($vatCheckStatus, ['invalid', 'format_invalid'], true))
<div class="mt-2 text-[12px] flex items-start gap-1.5 text-[color:var(--color-err,#b91c1c)]">
<flux:icon.x-circle class="size-[14px] flex-shrink-0 mt-0.5" />
<span>{{ $vatCheckMessage }}</span>
</div>
@else
<div class="mt-2 text-[12px] flex items-start gap-1.5 text-[color:var(--color-ink-3)]">
<flux:icon.information-circle class="size-[14px] flex-shrink-0 mt-0.5" />
<span>{{ $vatCheckMessage }}</span>
</div>
@endif
@endif
</div>
</div>
</div>
<div class="px-5 py-4 border-t border-[color:var(--color-bg-rule)] flex justify-end">
<flux:button type="submit" variant="primary">
{{ __('Speichern') }}
</flux:button>
</div>
</article>
{{-- ============== 3) EINSTELLUNGEN ============== --}}
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Einstellungen') }}</span>
</div>
<div class="p-5 grid gap-4 md:grid-cols-2">
<flux:switch
wire:model="showStats"
align="right"
:label="__('Statistiken anzeigen')"
:description="__('Statistiken und Kennzahlen in Ihren Pressemitteilungen anzeigen. Greift mit dem Relaunch der Portal-Seiten.')"
/>
<flux:switch
wire:model="disableFooterCode"
align="right"
:label="__('Footer-Code deaktivieren')"
:description="__('Automatische Footer-Codes in Pressemitteilungen für dieses Profil deaktivieren. Greift mit dem Relaunch der Portal-Seiten.')"
/>
</div>
<div class="px-5 py-4 border-t border-[color:var(--color-bg-rule)] flex justify-end">
<flux:button type="submit" variant="primary">
{{ __('Speichern') }}
</flux:button>
</div>
</article>
</form>
</div>