presseportale/resources/views/livewire/customer/press-releases/edit.blade.php
Kevin Adametz 036a53499f Responsive-Härtung: Seiten-Header, Kontextleiste, Stat-Cards
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 14:08:08 +00:00

1326 lines
57 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
use App\Enums\Portal;
use App\Enums\PressReleasePlaceholder;
use App\Enums\PressReleaseStatus;
use App\Models\Category;
use App\Models\Company;
use App\Models\Contact;
use App\Models\PressRelease;
use App\Services\Customer\CustomerCompanyContext;
use App\Services\PressRelease\BlacklistViolationException;
use App\Services\PressRelease\BookingRequiredException;
use App\Services\PressRelease\PressReleaseCoverImage;
use App\Services\PressRelease\PressReleaseHtmlSanitizer;
use App\Services\PressRelease\PressReleaseService;
use App\Services\PressRelease\QuotaExceededException;
use Flux\Flux;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked;
use Livewire\Attributes\On;
use Livewire\Attributes\Title;
use Livewire\Volt\Component;
new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] class extends Component
{
#[Locked]
public int $id;
public string $portal = '';
public string $language = 'de';
public int|string|null $companyId = null;
public string $companySearch = '';
public int|string|null $categoryId = null;
public int|string|null $contactId = null;
public string $title = '';
public string $subtitle = '';
public string $text = '';
public string $keywords = '';
public string $backlinkUrl = '';
public string $boilerplateOverride = '';
public bool $useBoilerplateOverride = false;
public string $publishMode = 'now';
public ?string $scheduledAt = null;
public ?string $scheduledDate = null;
public ?string $scheduledTime = null;
public bool $useEmbargo = false;
public ?string $embargoAt = null;
public string $currentStatus = '';
public ?int $contentScore = null;
public ?string $contentTier = null;
public string $placeholderVariant = '';
private ?PressRelease $cachedPressRelease = null;
public function mount(int $id): void
{
$this->id = $id;
$pr = $this->getMyPR();
$this->authorize('update', $pr);
abort_unless(
in_array($pr->status->value, ['draft', 'rejected']),
403,
__('Nur Entwürfe und abgelehnte Pressemitteilungen können bearbeitet werden.')
);
$this->currentStatus = $pr->status->value;
$this->contentScore = $pr->content_score;
$this->contentTier = $pr->content_tier?->value;
$this->portal = $pr->portal->value;
$this->language = $pr->language;
$this->companyId = $pr->company_id;
$this->categoryId = $pr->category_id;
$this->title = $pr->title;
$this->subtitle = $pr->subtitle ?? '';
$this->text = $pr->text;
$this->keywords = $pr->keywords ?? '';
$this->backlinkUrl = $pr->backlink_url ?? '';
$this->boilerplateOverride = $pr->boilerplate_override ?? '';
$this->useBoilerplateOverride = filled($pr->boilerplate_override);
$this->contactId = $pr->contacts()->withoutGlobalScopes()->first()?->id
?? $this->defaultContactIdFor((int) $pr->company_id);
if ($pr->scheduled_at) {
// DB-Wert ist UTC; für die Eingabefelder nach Europe/Berlin wandeln.
$scheduledAt = $pr->scheduled_at->copy()->setTimezone(PressRelease::DISPLAY_TIMEZONE);
$this->publishMode = 'scheduled';
$this->scheduledAt = $scheduledAt->format('Y-m-d\TH:i');
$this->scheduledDate = $scheduledAt->format('Y-m-d');
$this->scheduledTime = $scheduledAt->format('H:i');
}
$this->placeholderVariant = PressReleasePlaceholder::fromValueOrDefault($pr->placeholder_variant?->value)->value;
}
#[On('placeholder-selected')]
public function setPlaceholderVariant(string $variant): void
{
$this->placeholderVariant = PressReleasePlaceholder::fromValueOrDefault($variant)->value;
}
#[On('title-image-changed')]
public function refreshTitleImage(): void
{
unset($this->tags, $this->presubmitChecks);
}
public function updatedCompanyId(): void
{
$company = $this->selectedCompany();
if ($company?->portal) {
$this->portal = $company->portal->value;
}
if ($company) {
$contactStillValid = $this->companyContact((int) $this->contactId, (int) $company->id);
if (! $contactStillValid) {
$this->contactId = $this->defaultContactIdFor((int) $company->id);
}
} else {
$this->contactId = null;
}
unset($this->tags, $this->presubmitChecks);
}
public function updatedCompanySearch(): void
{
$this->resetErrorBag('companyId');
}
public function updatedPublishMode(): void
{
$this->syncScheduledAt();
if ($this->publishMode === 'now') {
$this->scheduledDate = null;
$this->scheduledTime = null;
$this->scheduledAt = null;
$this->resetErrorBag(['scheduledDate', 'scheduledTime', 'scheduledAt']);
}
}
public function updatedScheduledDate(): void
{
$this->syncScheduledAt();
$this->validateScheduledAtWhenReady();
}
public function updatedScheduledTime(): void
{
$this->syncScheduledAt();
$this->validateScheduledAtWhenReady();
}
public function addTag(string $tag): void
{
$tag = trim($tag);
if ($tag === '') {
return;
}
$existing = $this->tagsArray();
if (count($existing) >= 5) {
return;
}
if (in_array($tag, $existing, true)) {
return;
}
$existing[] = $tag;
$this->keywords = implode(', ', $existing);
unset($this->tags, $this->presubmitChecks);
}
public function removeTag(string $tag): void
{
$existing = array_values(array_filter(
$this->tagsArray(),
fn (string $existingTag): bool => $existingTag !== $tag,
));
$this->keywords = implode(', ', $existing);
unset($this->tags, $this->presubmitChecks);
}
/**
* Live-Re-Validation: sobald für ein Property bereits ein Error im Bag
* liegt, wird es bei jeder Änderung neu geprüft. So verschwindet ein
* roter Hinweis sofort, wenn der User das Feld korrekt ausfüllt — und
* der User muss nicht erst auf „Speichern" klicken.
*
* Die Termin-Synchronisierung liegt vollständig in den spezifischen
* `updated{PublishMode,ScheduledDate,ScheduledTime}`-Hooks; hier bleibt
* nur die generische Re-Validierung bereits fehlerhafter Felder.
*/
public function updated(string $property): void
{
if (! $this->getErrorBag()->has($property)) {
return;
}
try {
$this->validateOnly($property, $this->formRules());
} catch (\Illuminate\Validation\ValidationException) {
// Field bleibt invalid — Error-Bag wird automatisch befüllt.
}
}
/**
* Toast mit Sammelhinweis nach fehlgeschlagener Validierung.
*/
protected function notifyValidationError(?\Illuminate\Validation\ValidationException $exception = null): void
{
$count = $exception
? array_sum(array_map('count', $exception->errors()))
: count($this->getErrorBag()->all());
Flux::toast(
heading: __('Bitte Eingaben prüfen'),
text: $count > 1
? __(':count Felder benötigen deine Aufmerksamkeit.', ['count' => $count])
: __('Ein Feld benötigt deine Aufmerksamkeit.'),
variant: 'danger',
duration: 6000,
);
}
/**
* Single Source of Truth für die Validierungsregeln.
*
* @return array<string, array<int, mixed>>
*/
protected function formRules(): array
{
$rules = [
'language' => ['required', Rule::in(['de', 'en'])],
'companyId' => ['required', 'integer'],
'categoryId' => ['required', 'integer', Rule::exists('categories', 'id')],
'contactId' => ['nullable', 'integer'],
'title' => ['required', 'string', 'min:5', 'max:255'],
'subtitle' => ['nullable', 'string', 'max:255'],
'text' => ['required', 'string', 'min:50'],
'keywords' => ['nullable', 'string', 'max:255'],
'backlinkUrl' => ['nullable', 'url', 'max:255'],
'boilerplateOverride' => ['nullable', 'string', 'max:5000'],
'publishMode' => ['required', Rule::in(['now', 'scheduled'])],
];
if ($this->publishMode === 'scheduled') {
$rules['scheduledDate'] = ['required', 'date'];
$rules['scheduledTime'] = ['required', 'date_format:H:i'];
$rules['scheduledAt'] = [
'required',
'date',
// Termin wird in Europe/Berlin erfasst; deshalb hier zeitzonen-
// bewusst prüfen statt über die naive `after:`-Regel.
function (string $attribute, mixed $value, \Closure $fail): void {
$scheduledAt = $this->scheduledAtUtc();
if ($scheduledAt === null || $scheduledAt->lessThanOrEqualTo(now()->addMinutes(5))) {
$fail(__('Der Veröffentlichungstermin muss mindestens 5 Minuten in der Zukunft liegen.'));
}
},
];
} else {
$rules['scheduledDate'] = ['nullable'];
$rules['scheduledTime'] = ['nullable'];
$rules['scheduledAt'] = ['nullable'];
}
$rules['embargoAt'] = ['nullable'];
return $rules;
}
protected function syncScheduledAt(): void
{
if ($this->publishMode !== 'scheduled') {
$this->scheduledAt = null;
return;
}
if (blank($this->scheduledDate) && blank($this->scheduledTime) && filled($this->scheduledAt)) {
$scheduledAt = \Carbon\Carbon::parse($this->scheduledAt);
$this->scheduledDate = $scheduledAt->format('Y-m-d');
$this->scheduledTime = $scheduledAt->format('H:i');
return;
}
if (blank($this->scheduledDate) || blank($this->scheduledTime)) {
$this->scheduledAt = null;
return;
}
$this->scheduledAt = "{$this->scheduledDate}T{$this->scheduledTime}";
}
/**
* Wandelt den in Europe/Berlin erfassten Termin in den UTC-Zeitpunkt,
* wie er in der Datenbank gespeichert wird. Null, wenn kein Termin gesetzt.
*/
protected function scheduledAtUtc(): ?\Carbon\Carbon
{
if (blank($this->scheduledAt)) {
return null;
}
return \Carbon\Carbon::parse($this->scheduledAt, PressRelease::DISPLAY_TIMEZONE)->utc();
}
protected function validateScheduledAtWhenReady(): void
{
if (blank($this->scheduledAt)) {
return;
}
$this->resetErrorBag('scheduledAt');
$scheduledAt = $this->scheduledAtUtc();
if ($scheduledAt === null || $scheduledAt->lessThanOrEqualTo(now()->addMinutes(5))) {
$this->addError('scheduledAt', __('Der Veröffentlichungstermin muss mindestens 5 Minuten in der Zukunft liegen.'));
return;
}
try {
$this->validateOnly('scheduledAt', $this->formRules());
} catch (\Illuminate\Validation\ValidationException) {
// Termin bleibt invalid — Error-Bag wird automatisch befüllt.
}
}
public function save(bool $submitAfterSave = false): void
{
$this->syncScheduledAt();
$this->useEmbargo = false;
$this->embargoAt = null;
try {
$this->validate($this->formRules());
} catch (\Illuminate\Validation\ValidationException $e) {
$this->notifyValidationError($e);
throw $e;
}
$pr = $this->getMyPR();
$this->authorize('update', $pr);
$company = $this->selectedCompany();
if (! $company) {
$this->addError('companyId', __('Die gewählte Firma ist nicht Ihrem Account zugeordnet.'));
$this->notifyValidationError();
return;
}
$contact = null;
if ($this->contactId) {
$contact = $this->companyContact((int) $this->contactId, (int) $company->id);
if (! $contact) {
$this->addError('contactId', __('Der gewählte Pressekontakt gehört nicht zu dieser Firma.'));
$this->notifyValidationError();
return;
}
}
$this->portal = $company->portal?->value ?? Portal::Presseecho->value;
$cleanText = app(PressReleaseHtmlSanitizer::class)->clean($this->text);
$pr->update([
'portal' => $this->portal,
'language' => $this->language,
'company_id' => (int) $this->companyId,
'category_id' => (int) $this->categoryId,
'title' => $this->title,
'subtitle' => trim($this->subtitle) ?: null,
'text' => $cleanText,
'boilerplate_override' => $this->useBoilerplateOverride && trim($this->boilerplateOverride) !== ''
? trim($this->boilerplateOverride)
: null,
'placeholder_variant' => $this->placeholderVariant ?: PressReleasePlaceholder::default()->value,
'keywords' => $this->keywords ?: null,
'backlink_url' => $this->backlinkUrl ?: null,
'scheduled_at' => $this->publishMode === 'scheduled'
? $this->scheduledAtUtc()
: null,
'embargo_at' => null,
]);
$contentChanged = $pr->wasChanged(['title', 'text']);
$pr->contacts()->sync($contact ? [$contact->id] : []);
if ($submitAfterSave) {
$this->authorize('submitForReview', $pr);
try {
app(PressReleaseService::class)->submitForReview($pr->fresh());
} catch (BlacklistViolationException $e) {
Flux::toast(
heading: __('Automatisch abgelehnt'),
text: __('Unzulässiges Wort gefunden: ":word". Bitte überarbeiten.', ['word' => $e->word]),
variant: 'danger',
duration: 8000,
);
$this->redirect(route('me.press-releases.show', $pr->id), navigate: true);
return;
} catch (BookingRequiredException|QuotaExceededException $e) {
Flux::toast(
heading: __('Gespeichert, aber nicht eingereicht'),
text: $e->getMessage(),
variant: 'warning',
duration: 8000,
);
return;
}
Flux::toast(
heading: __('Eingereicht'),
text: __('Die Redaktion prüft die Pressemitteilung typischerweise innerhalb von 24 h.'),
variant: 'success',
);
} else {
// Inhaltliche Änderung einer bereits geprüften/bewerteten PM → neu
// prüfen (Re-Check ohne Routing) und neu bewerten. Beim Einreichen
// übernimmt das submitForReview.
if ($contentChanged) {
$service = app(PressReleaseService::class);
$fresh = $pr->fresh();
$service->reclassifyIfClassified($fresh);
$service->rescoreIfScored($fresh);
}
Flux::toast(
heading: __('Gespeichert'),
text: __('Deine Änderungen sind gesichert.'),
variant: 'success',
);
}
$this->redirect(route('me.press-releases.show', $pr->id), navigate: true);
}
public function saveAndSubmit(): void
{
$this->save(submitAfterSave: true);
}
public function with(): array
{
$user = auth()->user();
$context = app(CustomerCompanyContext::class);
$selectedCompany = $this->selectedCompany();
$companyOptions = $context->searchCompaniesFor(
$user,
$this->companySearch,
$this->companyId ? (int) $this->companyId : null,
10,
);
$categories = Category::query()
->with('translations')
->where('is_active', true)
->orderBy('id')
->get();
$pressRelease = $this->getMyPR()->load('images');
$cover = app(PressReleaseCoverImage::class);
return [
'companyOptions' => $companyOptions,
'categories' => $categories,
'selectedCompany' => $selectedCompany,
'selectedCompanyContacts' => $selectedCompany
? $this->companyContacts((int) $selectedCompany->id)
: Contact::query()->whereRaw('0 = 1')->get(),
'selectedPortalLabel' => $selectedCompany?->portal?->label() ?? __('Wird aus der Firma übernommen'),
'tagSuggestions' => $this->tagSuggestionsFor($selectedCompany),
'coverUrl' => $cover->coverUrl($pressRelease, 'cover'),
'coverIsPlaceholder' => $cover->coverIsPlaceholder($pressRelease),
'quotaTotal' => $user->pressReleaseQuotaTotal(),
'quotaRemaining' => $user->pressReleaseQuotaRemaining(),
];
}
#[Computed]
public function tags(): array
{
return $this->tagsArray();
}
#[Computed]
public function presubmitChecks(): array
{
$titleLen = mb_strlen(trim($this->title));
$textLen = app(PressReleaseHtmlSanitizer::class)->plainTextLength($this->text);
$tagsCount = count($this->tagsArray());
return [
[
'key' => 'title',
'status' => $titleLen >= 5 ? 'ok' : 'err',
'label' => __('Titel vorhanden'),
'sub' => $titleLen > 0 ? __(':n Zeichen', ['n' => $titleLen]) : __('Noch leer'),
],
[
'key' => 'text',
'status' => $textLen >= 600 ? 'ok' : ($textLen >= 50 ? 'warn' : 'err'),
'label' => __('Mindestlänge Fließtext erreicht'),
'sub' => __(':n / 600 Zeichen empfohlen', ['n' => number_format($textLen, 0, ',', '.')]),
],
[
'key' => 'company',
'status' => $this->companyId ? 'ok' : 'err',
'label' => __('Firma zugeordnet'),
'sub' => $this->selectedCompany()?->name ?? __('Keine Firma gewählt'),
],
[
'key' => 'category',
'status' => $this->categoryId ? 'ok' : 'err',
'label' => __('Kategorie gewählt'),
'sub' => $this->categoryId ? '' : __('Kategorie ist Pflicht'),
],
[
'key' => 'contact',
'status' => $this->contactId ? 'ok' : 'warn',
'label' => __('Pressekontakt zugeordnet'),
'sub' => $this->contactId ? '' : __('empfohlen — Journalisten greifen direkt zum Hörer'),
],
[
'key' => 'tags',
'status' => $tagsCount >= 1 ? 'ok' : 'warn',
'label' => __('Themen-Tags vergeben'),
'sub' => $tagsCount >= 1
? trans_choice('{1}:n Tag|[2,*]:n Tags', $tagsCount, ['n' => $tagsCount])
: __('empfohlen für SEO & Auffindbarkeit'),
],
];
}
/**
* @return list<string>
*/
private function tagsArray(): array
{
if (trim($this->keywords) === '') {
return [];
}
return collect(explode(',', $this->keywords))
->map(fn (string $tag): string => trim($tag))
->filter()
->unique()
->values()
->all();
}
private function defaultContactIdFor(int $companyId): ?int
{
return $this->companyContacts($companyId)->first()?->id;
}
/**
* @return Collection<int, Contact>
*/
private function companyContacts(int $companyId): Collection
{
return Contact::withoutGlobalScopes()
->where('company_id', $companyId)
->orderBy('last_name')
->orderBy('first_name')
->get(['id', 'company_id', 'first_name', 'last_name', 'responsibility', 'phone', 'email']);
}
private function companyContact(int $contactId, int $companyId): ?Contact
{
return Contact::withoutGlobalScopes()
->where('company_id', $companyId)
->whereKey($contactId)
->first();
}
private function getMyPR(): PressRelease
{
// Pro Livewire-Request memoisiert: mount(), with() und save() greifen
// sonst jeweils mit einer eigenen Query auf dieselbe PM zu.
return $this->cachedPressRelease ??= PressRelease::withoutGlobalScopes()
->where('user_id', auth()->id())
->findOrFail($this->id);
}
private function selectedCompany(): ?Company
{
if (! $this->companyId) {
return null;
}
return app(CustomerCompanyContext::class)
->findFor(auth()->user(), (int) $this->companyId);
}
/**
* @return list<string>
*/
private function tagSuggestionsFor(?Company $company): array
{
$defaults = [
__('Mittelstand'),
__('Unternehmen'),
__('Eröffnung'),
__('Innovation'),
__('Nachhaltigkeit'),
];
if (! $company) {
return $defaults;
}
$portalLabel = $company->portal?->label();
return array_values(array_unique(array_filter([
$portalLabel,
$company->country_code === 'DE' ? __('Deutschland') : null,
...$defaults,
])));
}
}; ?>
<div class="space-y-8 pr-editor-shell" x-data="{ tagInput: '' }">
{{-- ============== PAGE HEADER ============== --}}
<header class="page-header">
<div class="min-w-0">
<div class="flex items-center gap-3 mb-3 flex-nowrap whitespace-nowrap">
<span class="badge hub dot">{{ __('User Backend') }}</span>
<span class="eyebrow muted">{{ __('Mein Bereich · Bearbeiten') }}</span>
@if ($currentStatus === 'rejected')
<span class="badge err dot">{{ __('Abgelehnt') }}</span>
@else
<span class="badge muted dot">{{ __('Entwurf') }}</span>
@endif
<span class="badge hub">ID {{ $id }}</span>
</div>
<h1 class="text-[30px] font-bold tracking-[-0.6px] leading-[1.15] m-0 text-[color:var(--color-ink)]">
{{ __('Pressemitteilung bearbeiten') }}
</h1>
<p class="text-[13px] leading-[1.55] mt-2 m-0 max-w-[640px] text-[color:var(--color-ink-2)]">
{{ __('Schreibfläche links, Steuerung rechts. Pflichtfelder werden rechts in der Checkliste angezeigt.') }}
</p>
</div>
<div class="flex items-center gap-2 flex-shrink-0">
<flux:button variant="filled" icon="eye" href="{{ route('me.press-releases.show', $id) }}" wire:navigate>
{{ __('Vorschau / Detail') }}
</flux:button>
<flux:button variant="filled" icon="arrow-left" href="{{ route('me.press-releases.index') }}" wire:navigate>
{{ __('Zur Liste') }}
</flux:button>
</div>
</header>
{{-- ============== 2-COLUMN GRID ============== --}}
<div class="grid gap-6 pr-editor-layout">
{{-- =================== LINKS: SCHREIBFLÄCHE =================== --}}
<div class="space-y-6 min-w-0">
{{-- 1) FIRMA-SELEKTOR --}}
<section class="panel">
<div class="p-4 flex flex-wrap items-center gap-4">
<span class="pr-form-label" style="margin-bottom:0;">{{ __('Für Firma') }}</span>
<div class="min-w-[260px]">
<flux:select
wire:model.live="companyId"
variant="combobox"
:filter="false"
clearable
placeholder="{{ __('Firma suchen…') }}"
>
<x-slot name="input">
<flux:select.input
wire:model.live.debounce.300ms="companySearch"
placeholder="{{ __('Name eingeben…') }}"
/>
</x-slot>
@foreach ($companyOptions as $company)
<flux:select.option :value="$company->id" wire:key="company-option-{{ $company->id }}">
{{ $company->name }}{{ $company->portal ? ' ('.$company->portal->abbreviation().')' : '' }}
</flux:select.option>
@endforeach
<x-slot name="empty">
<flux:select.option.empty>
@if (blank(trim($companySearch)))
{{ __('Keine Firma verfügbar.') }}
@else
{{ __('Keine Firma gefunden.') }}
@endif
</flux:select.option.empty>
</x-slot>
</flux:select>
</div>
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
{{ __('Boilerplate und Pressekontakt werden bei Wechsel angepasst.') }}
</span>
<span class="flex-1"></span>
@if ($selectedCompany)
<flux:button size="sm" variant="filled" icon="building-office"
href="{{ route('me.press-kits.show', $selectedCompany->id) }}" wire:navigate>
{{ __('Firmenprofil') }}
</flux:button>
@endif
</div>
<flux:error name="companyId" />
</section>
{{-- 2) TITEL --}}
<section class="panel">
<div class="p-5 pb-4">
<div class="flex items-center justify-between mb-2 gap-4">
<span class="pr-form-label" style="margin-bottom:0;">
{{ __('Titel / Headline') }} <span class="req">*</span>
</span>
<div class="flex items-center gap-3">
@php
$titleLen = mb_strlen($title);
$titleClass = $titleLen >= 40 && $titleLen <= 90 ? 'good' : ($titleLen > 0 ? 'warn' : '');
$titleBar = min(100, max(0, ($titleLen / 100) * 100));
@endphp
<span class="pr-meter {{ $titleClass }}">
<span class="bar"><i style="width: {{ $titleBar }}%;"></i></span>
{{ $titleLen }} / 100
</span>
<span class="pr-bald-badge">{{ __('KI-Titel · bald') }}</span>
</div>
</div>
<flux:input
wire:model.live.debounce.300ms="title"
placeholder="{{ __('Aussagekräftiger Titel — wer, was, wo?') }}"
size="lg"
/>
<p class="pr-form-help">
{{ __('4090 Zeichen empfohlen. Konkret, ohne Marketing-Floskeln.') }}
</p>
<flux:error name="title" />
</div>
</section>
{{-- 3) SUBTITLE --}}
<section class="panel">
<div class="p-5 pb-4">
<div class="flex items-center justify-between mb-2 gap-4">
<span class="pr-form-label" style="margin-bottom:0;">
{{ __('Untertitel') }}
<span class="text-[color:var(--color-ink-4)] font-normal" style="letter-spacing:0;text-transform:none;">
— {{ __('optional') }}
</span>
</span>
@php
$subLen = mb_strlen($subtitle);
$subBar = min(100, max(0, ($subLen / 200) * 100));
@endphp
<span class="pr-meter">
<span class="bar"><i style="width: {{ $subBar }}%;"></i></span>
{{ $subLen }} / 200
</span>
</div>
<flux:input
wire:model.live.debounce.300ms="subtitle"
placeholder="{{ __('Kurzer Zusatztext / Dachzeile…') }}"
/>
<flux:error name="subtitle" />
</div>
</section>
{{-- 4) FLIESSTEXT --}}
<section class="panel">
<div class="p-5 pb-4">
<div class="flex items-center justify-between mb-2 gap-4">
<span class="pr-form-label" style="margin-bottom:0;">
{{ __('Fließtext') }} <span class="req">*</span>
</span>
<div class="flex items-center gap-3">
@php
$textLen = app(\App\Services\PressRelease\PressReleaseHtmlSanitizer::class)->plainTextLength($text);
$textClass = $textLen >= 600 ? 'good' : ($textLen >= 50 ? 'warn' : '');
$textBar = min(100, max(0, ($textLen / 3500) * 100));
@endphp
<span class="pr-meter {{ $textClass }}">
<span class="bar"><i style="width: {{ $textBar }}%;"></i></span>
{{ number_format($textLen, 0, ',', '.') }} / 3.500 Z.
</span>
<span class="pr-bald-badge">{{ __('KI-Lektorat · bald') }}</span>
</div>
</div>
<flux:editor
wire:model.live.debounce.500ms="text"
toolbar="heading | bold italic | bullet ordered blockquote | link | undo redo"
placeholder="{{ __('Hier weiterschreiben…') }}"
/>
<flux:error name="text" />
<div class="pr-ai-hint mt-4">
<span class="ico">
<flux:icon name="sparkles" variant="micro" />
</span>
<span>
<strong class="font-semibold">{{ __('KI-Lektorat') }}</strong>
{{ __('liest Korrektur, schlägt Kürzungen vor und prüft auf werbliche Sprache. Erscheint hier inline — bald verfügbar.') }}
</span>
</div>
</div>
</section>
@if ($coverIsPlaceholder)
{{-- 5) TITELBILD-PLATZHALTER --}}
<section class="panel">
<div class="p-5">
<div class="mb-3 flex items-center justify-between gap-3">
<span class="pr-form-label" style="margin-bottom:0;">{{ __('Titelbild-Platzhalter') }}</span>
<flux:modal.trigger name="placeholder-picker">
<flux:button size="xs" variant="filled" icon="swatch">
{{ __('Platzhalter wählen') }}
</flux:button>
</flux:modal.trigger>
</div>
<x-portal.press-release-placeholder
:variant="$placeholderVariant"
:title="$title ?: null"
class="mx-auto aspect-[1280/580] w-full max-w-[1280px] rounded-[6px] border border-[color:var(--color-bg-rule)]" />
<p class="mt-2 text-[11.5px] text-[color:var(--color-ink-3)] m-0">
{{ __('Dieser Platzhalter wird angezeigt, bis ein eigenes Titelbild hochgeladen ist.') }}
</p>
</div>
</section>
<livewire:components.press-release-placeholder-picker :current="$placeholderVariant" />
@endif
{{-- 6) MEDIEN — Image-Manager direkt eingebunden --}}
<livewire:components.press-release-images-manager :press-release-id="$id" :wire:key="'pr-images-'.$id" />
{{-- 6) ANHÄNGE — TEMPORÄR DEAKTIVIERT
Datei-Uploads erfordern eine vollständige Sicherheitsprüfung
(Virus-/Malware-Scan, MIME-Validierung, Storage-Quoten).
Wird in einer späteren Phase aktiviert.
<livewire:components.press-release-attachments-manager :press-release-id="$id" :wire:key="'pr-attachments-'.$id" />
--}}
{{-- 7) BOILERPLATE --}}
<section class="panel">
<div class="p-5">
<div class="flex items-center justify-between mb-3 gap-4">
<span class="pr-form-label" style="margin-bottom:0;">
{{ __('Über das Unternehmen') }}
<span class="text-[color:var(--color-ink-4)] font-normal" style="letter-spacing:0;text-transform:none;">
— {{ __('Boilerplate aus Firma') }}
</span>
</span>
<flux:checkbox
wire:model.live="useBoilerplateOverride"
:label="__('Für diese PM überschreiben')"
/>
</div>
@if ($selectedCompany?->boilerplate)
<div class="pr-boiler">
<p class="m-0">{!! nl2br(e($selectedCompany->boilerplate)) !!}</p>
@if ($selectedCompany->website)
<p class="m-0 text-[12px] text-[color:var(--color-ink-3)] mt-3">
<span class="font-semibold text-[color:var(--color-ink-2)]">{{ __('Web') }}:</span>
{{ $selectedCompany->website }}
</p>
@endif
</div>
@else
<div class="pr-boiler text-[color:var(--color-ink-3)]">
{{ __('Für diese Firma ist noch kein Boilerplate-Text hinterlegt. Du kannst entweder einen Override-Text für diese PM setzen oder das Firmenprofil ergänzen.') }}
</div>
@endif
@if ($useBoilerplateOverride)
<div class="mt-3">
<flux:textarea
wire:model.live.debounce.500ms="boilerplateOverride"
rows="5"
placeholder="{{ __('Boilerplate-Text speziell für diese PM…') }}"
/>
<flux:error name="boilerplateOverride" />
</div>
@endif
<p class="pr-form-help">
{{ __('Wird automatisch unter jeder Pressemitteilung dieser Firma angefügt. Pro PM editierbar.') }}
</p>
</div>
</section>
</div>
{{-- /Schreibfläche --}}
{{-- =================== RECHTS: SETTINGS-SIDEBAR =================== --}}
<aside class="space-y-4 pr-editor-side self-start">
{{-- Status & Absenden --}}
<article class="panel" style="border-color:var(--color-hub);">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Status & Absenden') }}</span>
@if ($currentStatus === 'rejected')
<span class="badge err dot">{{ __('Abgelehnt') }}</span>
@else
<span class="badge muted dot">{{ __('Entwurf') }}</span>
@endif
</div>
<div class="p-5">
<div class="rounded-[4px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3 mb-3">
@php
$okCount = collect($this->presubmitChecks)->where('status', 'ok')->count();
$totalCount = count($this->presubmitChecks);
@endphp
<div class="flex items-center justify-between mb-1">
<span class="eyebrow muted" style="font-size:9.5px;letter-spacing:0.16em;">
{{ __('Pre-Submit-Check') }}
</span>
<span class="text-[10.5px] font-mono font-semibold text-[color:var(--color-ok)]">
{{ $okCount }} / {{ $totalCount }} ok
</span>
</div>
@foreach ($this->presubmitChecks as $check)
<div class="pr-check-row {{ $check['status'] }}">
<span class="ic">
@if ($check['status'] === 'ok')
<flux:icon name="check" variant="micro" class="size-3" />
@elseif ($check['status'] === 'warn')
<flux:icon name="exclamation-triangle" variant="micro" class="size-3" />
@else
<flux:icon name="x-mark" variant="micro" class="size-3" />
@endif
</span>
<span class="lbl">
{{ $check['label'] }}
@if (! empty($check['sub']))
<span class="sub">{{ $check['sub'] }}</span>
@endif
</span>
</div>
@endforeach
</div>
<flux:modal.trigger name="confirm-submit-review">
<flux:button
type="button"
variant="primary"
icon="paper-airplane"
class="w-full"
wire:loading.attr="disabled"
>
{{ $currentStatus === 'rejected' ? __('Speichern & erneut einreichen') : __('Speichern & zur Prüfung') }}
</flux:button>
</flux:modal.trigger>
<p class="text-[11px] text-[color:var(--color-ink-3)] mt-2 leading-[1.45] m-0">
{{ __('Warnungen blockieren nicht. Pflichtfelder blockieren. Die Redaktion prüft typ. innerhalb von 24h.') }}
</p>
<hr class="border-0 border-t border-[color:var(--color-bg-rule)] my-3" />
<flux:button
type="button"
variant="filled"
icon="bookmark"
class="w-full"
wire:click="save"
wire:loading.attr="disabled"
>
{{ __('Nur speichern') }}
</flux:button>
</div>
</article>
{{-- Content-Score (Qualitäts-Feedback während des Schreibens) --}}
@if (! is_null($contentScore))
@php
$tierEnum = $contentTier ? \App\Enums\PressReleaseContentTier::from($contentTier) : null;
$tiers = config('scoring.content_score.tiers');
$nextThreshold = $contentScore < (int) $tiers['gepruft']
? (int) $tiers['gepruft']
: ($contentScore < (int) $tiers['hochwertig'] ? (int) $tiers['hochwertig'] : null);
@endphp
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Qualität') }}</span>
@if ($tierEnum)
<span class="badge {{ $tierEnum === \App\Enums\PressReleaseContentTier::Hochwertig ? 'ok' : ($tierEnum === \App\Enums\PressReleaseContentTier::Geprueft ? 'hub' : 'muted') }}">{{ $tierEnum->label() }}</span>
@endif
</div>
<div class="p-4">
<div class="text-[26px] font-bold leading-none text-[color:var(--color-ink)]">
{{ $contentScore }}<span class="text-[14px] font-medium text-[color:var(--color-ink-3)]">/100</span>
</div>
@if ($nextThreshold)
<p class="text-[12px] text-[color:var(--color-ink-2)] mt-2 m-0">
{{ __('Noch :points Punkte bis zur nächsten Stufe.', ['points' => $nextThreshold - $contentScore]) }}
</p>
@else
<p class="text-[12px] text-[color:var(--color-ink-2)] mt-2 m-0">
{{ __('Höchste Stufe erreicht.') }}
</p>
@endif
<p class="text-[11px] text-[color:var(--color-ink-3)] mt-2 leading-[1.45] m-0">
{{ __('Der Score wird nach dem Speichern automatisch neu berechnet.') }}
</p>
</div>
</article>
@endif
{{-- Kategorie (Pflichtfeld - prominent direkt unter Status) --}}
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">
{{ __('Kategorie') }}
<span class="text-[color:var(--color-err)]">*</span>
</span>
</div>
<div class="p-5">
<flux:field>
<flux:select wire:model.live="categoryId">
<option value="">{{ __('Bitte wählen…') }}</option>
@foreach ($categories as $cat)
<option value="{{ $cat->id }}">{{ $cat->translations->firstWhere('locale', 'de')?->name ?? $cat->id }}</option>
@endforeach
</flux:select>
<flux:error name="categoryId" />
<flux:description>{{ __('Pflichtfeld — steuert, in welcher Rubrik die PM erscheint.') }}</flux:description>
</flux:field>
</div>
</article>
{{-- Portal (Read-only, Badge in Portal-Farbe) --}}
@php
$portalPillClass = 'portal-pill';
if ($portal === 'presseecho') {
$portalPillClass = 'portal-pill pe';
} elseif ($portal === 'businessportal24') {
$portalPillClass = 'portal-pill bp';
}
@endphp
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Portal') }}</span>
</div>
<div class="p-5">
<div class="flex items-center gap-3">
<span class="{{ $portalPillClass }}">
<span class="pdot"></span>{{ $selectedPortalLabel }}
</span>
<span class="text-[11px] text-[color:var(--color-ink-4)]">
{{ __('automatisch aus der Firma') }}
</span>
</div>
<p class="text-[11px] text-[color:var(--color-ink-4)] mt-3 m-0 leading-[1.45]">
{{ __('Cross-Publishing auf beide Portale folgt in Phase 2.') }}
</p>
</div>
</article>
{{-- Pressekontakt --}}
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Pressekontakt') }}</span>
</div>
<div class="p-5 space-y-3">
@if ($selectedCompanyContacts->isEmpty())
<p class="text-[12px] text-[color:var(--color-ink-3)] m-0">
{{ __('Diese Firma hat noch keine Pressekontakte.') }}
</p>
@if ($selectedCompany)
<flux:button size="sm" variant="filled" icon="plus" class="w-full"
href="{{ route('me.press-kits.show', $selectedCompany->id) }}" wire:navigate>
{{ __('Kontakt im Firmenprofil anlegen') }}
</flux:button>
@endif
@else
<flux:field>
<flux:label>{{ __('Kontakt für diese PM') }}</flux:label>
<flux:select wire:model.live="contactId">
<option value="">{{ __('Bitte wählen…') }}</option>
@foreach ($selectedCompanyContacts as $contact)
@php
$contactName = trim(($contact->first_name ?? '').' '.($contact->last_name ?? ''))
?: __('Kontakt #:n', ['n' => $contact->id]);
$contactRole = $contact->responsibility ?: __('Kontakt');
@endphp
<option value="{{ $contact->id }}">
{{ $contactName }} — {{ $contactRole }}
</option>
@endforeach
</flux:select>
<flux:error name="contactId" />
</flux:field>
@if (! $contactId)
<div class="flex items-start gap-2 text-[11.5px] text-[color:var(--color-warn)]">
<flux:icon name="exclamation-triangle" variant="mini" class="size-4 flex-shrink-0 mt-0.5" />
<span>{{ __('Noch kein Pressekontakt ausgewählt. Empfohlen, aber nicht zwingend.') }}</span>
</div>
@else
@php($activeContact = $selectedCompanyContacts->firstWhere('id', (int) $contactId))
@if ($activeContact && empty($activeContact->phone))
<div class="flex items-start gap-2 text-[11.5px] text-[color:var(--color-warn)]">
<flux:icon name="exclamation-triangle" variant="mini" class="size-4 flex-shrink-0 mt-0.5" />
<span>{{ __('Kein Telefon hinterlegt — Journalisten greifen oft direkt zum Hörer.') }}</span>
</div>
@endif
@endif
@endif
</div>
</article>
{{-- Themen-Tags --}}
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Themen-Tags') }}</span>
<span class="text-[10.5px] text-[color:var(--color-ink-4)]">
<strong class="font-mono text-[color:var(--color-ink-2)]">{{ count($this->tags) }}</strong> / 5
</span>
</div>
<div class="p-5 space-y-3">
<div class="border border-[color:var(--color-bg-rule)] rounded-[4px] bg-[color:var(--color-bg-card)] px-2 py-2 min-h-[58px] flex flex-wrap items-center gap-1.5">
@forelse ($this->tags as $tag)
<span class="pr-tag-chip" wire:key="tag-{{ $tag }}">
{{ $tag }}
<button type="button" class="x" wire:click="removeTag(@js($tag))" title="{{ __('Entfernen') }}">×</button>
</span>
@empty
@if (count($this->tags) === 0)
<span class="text-[11.5px] text-[color:var(--color-ink-4)] italic px-1.5">
{{ __('Tag tippen + Enter…') }}
</span>
@endif
@endforelse
<input
type="text"
x-model="tagInput"
@keydown.enter.prevent="if (tagInput.trim()) { $wire.addTag(tagInput.trim()); tagInput = ''; }"
@keydown.comma.prevent="if (tagInput.trim()) { $wire.addTag(tagInput.trim()); tagInput = ''; }"
class="flex-1 min-w-[80px] border-0 bg-transparent text-[12px] text-[color:var(--color-ink)] focus:outline-none p-1"
placeholder="{{ count($this->tags) === 0 ? '' : '+ Tag' }}"
@disabled(count($this->tags) >= 5)
/>
</div>
@if (! empty($tagSuggestions))
<div>
<div class="eyebrow muted mb-1.5" style="font-size:9.5px;">{{ __('Vorschläge') }}</div>
<div class="flex flex-wrap gap-1.5">
@foreach ($tagSuggestions as $suggestion)
@if (! in_array($suggestion, $this->tags, true))
<button
type="button"
class="pr-tag-suggest"
wire:click="addTag(@js($suggestion))"
>+ {{ $suggestion }}</button>
@endif
@endforeach
</div>
</div>
@endif
<p class="text-[10.5px] text-[color:var(--color-ink-4)] m-0 leading-[1.45]">
{{ __('Tags helfen bei SEO und Auffindbarkeit.') }}
</p>
</div>
</article>
{{-- Veröffentlichung --}}
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Veröffentlichung') }}</span>
</div>
<div class="p-5 space-y-3">
<label class="pr-pub-opt {{ $publishMode === 'now' ? 'is-checked' : '' }}">
<input type="radio" wire:model.live="publishMode" value="now" class="sr-only" />
<span class="dot-out"></span>
<span>
<span class="text-[12.5px] font-semibold text-[color:var(--color-hub)] block leading-tight">
{{ __('Sofort nach Freigabe') }}
</span>
<span class="text-[11px] text-[color:var(--color-ink-3)] block mt-0.5 leading-tight">
{{ __('geht live, sobald die Redaktion grünes Licht gibt') }}
</span>
</span>
</label>
<label class="pr-pub-opt {{ $publishMode === 'scheduled' ? 'is-checked' : '' }}">
<input type="radio" wire:model.live="publishMode" value="scheduled" class="sr-only" />
<span class="dot-out"></span>
<span class="flex-1">
<span class="text-[12.5px] font-semibold text-[color:var(--color-hub)] leading-tight">
{{ __('Geplanter Termin') }}
</span>
<span class="text-[11px] text-[color:var(--color-ink-3)] block mt-0.5 leading-tight">
{{ __('Datum + Uhrzeit — wird automatisch zum Termin veröffentlicht') }}
</span>
</span>
</label>
@if ($publishMode === 'scheduled')
<div class="grid gap-3 sm:grid-cols-2">
<flux:field>
<flux:label>{{ __('Datum') }}</flux:label>
<flux:date-picker
wire:model.live="scheduledDate"
type="input"
:placeholder="__('Datum wählen')"
with-today
/>
<flux:error name="scheduledDate" />
</flux:field>
<flux:field>
<flux:label>{{ __('Uhrzeit') }}</flux:label>
<flux:time-picker
wire:model.live="scheduledTime"
type="input"
:placeholder="__('Uhrzeit wählen')"
/>
<flux:error name="scheduledTime" />
</flux:field>
</div>
<flux:error name="scheduledAt" />
<p class="text-[11px] text-[color:var(--color-ink-3)] leading-tight">{{ __('Frühestens 5 Min. in der Zukunft.') }}</p>
@endif
</div>
</article>
{{-- Weitere Felder --}}
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Weitere Felder') }}</span>
</div>
<div class="p-5 space-y-3">
<flux:field>
<flux:label>{{ __('Backlink-URL') }}</flux:label>
<flux:input wire:model="backlinkUrl" type="url" placeholder="https://…" />
<flux:error name="backlinkUrl" />
</flux:field>
<flux:field>
<flux:label>{{ __('Sprache') }}</flux:label>
<flux:select wire:model="language">
<option value="de">Deutsch</option>
<option value="en">English</option>
</flux:select>
</flux:field>
</div>
</article>
{{-- Phase-2-Footer --}}
<div class="rounded-[5px] border p-3.5"
style="background:var(--color-accent-soft);border-color:color-mix(in srgb, var(--color-accent) 50%, transparent);">
<div class="flex items-center gap-2 mb-2">
<flux:icon name="sparkles" variant="micro" class="size-3.5 text-[color:var(--color-accent-deep)]" />
<span class="eyebrow accent">{{ __('Phase 2 — bald') }}</span>
</div>
<ul class="text-[11.5px] text-[color:var(--color-accent-deep)] leading-[1.55] list-none p-0 m-0 space-y-1">
<li>· {{ __('KI-Titel-Optimierung & KI-Lektorat live') }}</li>
<li>· {{ __('Versionshistorie & Kommentare') }}</li>
<li>· {{ __('Portal-Vorschau (presseecho vs. BP24)') }}</li>
</ul>
</div>
</aside>
</div>
{{-- Einreichungs-Modal (öffnet über „Speichern & zur Prüfung") --}}
<x-press-release-submit-modal
name="confirm-submit-review"
action="saveAndSubmit"
:confirm-label="$currentStatus === 'rejected' ? __('Speichern & erneut einreichen') : __('Speichern & einreichen')"
:quota-total="$quotaTotal"
:quota-remaining="$quotaRemaining" />
</div>