presseportale/resources/views/livewire/customer/press-releases/edit.blade.php
Kevin Adametz 31da9ea8b6
Some checks are pending
linter / quality (push) Waiting to run
tests / ci (push) Waiting to run
livewire: Volt entfernt — Komponenten als Livewire-4-Single-File-Components, Route::livewire(), Livewire::test(); livewire/volt deinstalliert
- 83 Komponenten: Livewire\Volt\Component → Livewire\Component
- 73 Routen (admin, auth, customer): Volt::route() → Route::livewire()
- 88 Testdateien: Volt::test/actingAs → Livewire::test/actingAs
- VoltServiceProvider gelöscht; composer remove livewire/volt
- config/livewire.php auf v4-Schlüssel (component_layout, component_placeholder,
  component_locations, make_command: SFC ohne ⚡). Der alte Schlüssel layout war
  wirkungslos; alle gerouteten Komponenten setzen ihr Layout selbst.
- CLAUDE.md, AGENTS.md, boost.json, .cursorrules, Skill volt-development
- Roadmap: erledigt; Larastan erfasst SFCs weiterhin nicht (Blade-Dateien)

Suite 2.325 grün, einer übersprungen; Larastan und Pint ohne Meldung;
route:cache und view:cache laufen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:59:36 +00:00

1069 lines
49 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\AiDeclarationRequiredException;
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 App\Scopes\PortalScope;
use Flux\Flux;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked;
use Livewire\Attributes\On;
use Livewire\Attributes\Title;
use Livewire\Component;
new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] class extends Component
{
use \App\Livewire\Concerns\InteractsWithAiDeclaration;
use \App\Livewire\Concerns\InteractsWithPressReleaseContacts;
use \App\Livewire\Concerns\InteractsWithPressReleaseScheduling;
use \App\Livewire\Concerns\InteractsWithPressReleaseTags;
use \App\Livewire\Concerns\ProvidesPressReleaseFormRules;
use \App\Livewire\Concerns\ProvidesPressReleasePresubmitChecks;
#[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 ?? '';
// Legacy-Plaintext (ältere Importe) in editierbares HTML wandeln, sonst
// verschwinden die Zeilenumbrüche beim Laden in den Flux-Editor.
$this->text = app(PressReleaseHtmlSanitizer::class)->toEditorHtml($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()->withoutGlobalScope(PortalScope::class)->first()?->id
?? $this->defaultContactIdFor((int) $pr->company_id);
if ($pr->scheduled_at) {
$scheduledAt = $pr->scheduled_at;
$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;
// KI-Grad vorbelegen (bereits deklariert), gemappt auf die zwei
// Formular-Optionen; die verbindliche Bestätigung muss bei jedem
// Submit erneut gesetzt werden.
$this->aiTextDeclaration = $pr->ai_text_declaration?->formOption()->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');
}
// Tag-Verwaltung (addTag/removeTag/tagsArray) liegt im Trait
// App\Livewire\Concerns\InteractsWithPressReleaseTags.
/**
* Single Source of Truth für die Validierungsregeln.
*
* @return array<string, array<int, mixed>>
*/
protected function formRules(): array
{
return [
...$this->baseFormRules(),
'companyId' => ['required', 'integer'],
];
}
/**
* Validiert und speichert die Bearbeitung. Gibt [PressRelease, contentChanged]
* zurück oder null bei Firma-/Kontakt-Fehlern (Validierung wirft).
*
* Wichtig: einziger Persistenz-Pfad. Jede Aktion (Speichern, Einreichen,
* „Buchung auswählen") MUSS zuerst hierüber speichern, bevor navigiert wird.
*
* @return array{0: \App\Models\PressRelease, 1: bool}|null
*/
private function persistEdit(): ?array
{
$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 null;
}
$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 null;
}
}
$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->resolveScheduledAt()
: null,
'embargo_at' => null,
...$this->aiDeclarationAttributes(),
]);
$contentChanged = $pr->wasChanged(['title', 'text']);
$pr->contacts()->sync($contact ? [$contact->id] : []);
return [$pr, $contentChanged];
}
/**
* Speichert die Bearbeitung und leitet zur Buchungsseite — wird vom
* Submit-Modal genutzt, wenn keine Buchung vorliegt. So gehen die
* erfassten Änderungen nicht verloren (kein Sprung ohne Speichern).
*/
public function saveDraftAndChooseBooking(): void
{
$result = $this->persistEdit();
if (! $result) {
return;
}
[$pr, $contentChanged] = $result;
if ($contentChanged) {
app(PressReleaseService::class)->reprocessAfterContentChange($pr);
}
Flux::toast(
heading: __('Gespeichert'),
text: __('Ihre Änderungen sind gesichert. Wählen Sie eine Buchung, um die PM anschließend einzureichen.'),
variant: 'success',
);
$this->redirect(route('me.bookings.index'), navigate: true);
}
public function save(bool $submitAfterSave = false): void
{
// KI-Kennzeichnung (Art. 50 KI-VO): Pflicht nur beim Einreichen —
// zuerst prüfen, damit die Fehler direkt im Submit-Modal erscheinen.
if ($submitAfterSave) {
$this->validateAiDeclaration();
$this->persistLegalAcceptances();
}
$result = $this->persistEdit();
if (! $result) {
return;
}
[$pr, $contentChanged] = $result;
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 (AiDeclarationRequiredException|BookingRequiredException|QuotaExceededException $e) {
Flux::toast(
heading: __('Gespeichert, aber nicht eingereicht'),
text: $e->getMessage(),
variant: 'warning',
duration: 8000,
);
return;
}
Flux::toast(
heading: __('Eingereicht'),
text: __('Die automatische Inhaltsprüfung ist in der Regel innerhalb weniger Minuten abgeschlossen.'),
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) {
app(PressReleaseService::class)->reprocessAfterContentChange($pr);
}
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);
// Bei abgelehnten PMs die Begründung direkt im Editor zeigen
// (QA-Befund 07.07.), analog zur Detailseite.
$latestRejection = null;
if ($pressRelease->status->value === 'rejected') {
$latestRejection = $pressRelease->statusLogs
->firstWhere(fn ($log) => $log->to_status?->value === 'rejected');
}
return [
'latestRejection' => $latestRejection,
'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();
}
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::accessibleBy(auth()->user())
->findOrFail($this->id);
}
private function selectedCompany(): ?Company
{
if (! $this->companyId) {
return null;
}
return app(CustomerCompanyContext::class)
->findFor(auth()->user(), (int) $this->companyId);
}
}; ?>
<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>
{{-- ============== REJECTION-HINWEIS ============== --}}
@if ($currentStatus === 'rejected' && $latestRejection)
<article class="panel" style="border-color:var(--color-err); border-left-width:3px;">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Diese Pressemitteilung wurde abgelehnt') }}</span>
<span class="badge err dot">{{ __('Handlung erforderlich') }}</span>
</div>
<div class="p-5 flex items-start gap-3">
<div class="w-9 h-9 rounded-[5px] flex items-center justify-center flex-shrink-0
bg-[color:var(--color-err-soft)] border border-[color:var(--color-err)]/30 text-[color:var(--color-loss)]">
<flux:icon.exclamation-triangle class="size-[18px]" />
</div>
<div class="flex-1 text-[13px] text-[color:var(--color-ink-2)]">
@if ($latestRejection->reason)
<strong class="text-[color:var(--color-ink)] font-semibold">{{ __('Begründung') }}:</strong>
<span class="block mt-1 whitespace-pre-line">{{ $latestRejection->reason }}</span>
@else
{{ __('Bitte überarbeiten Sie den Inhalt und reichen Sie die Pressemitteilung erneut ein.') }}
@endif
<span class="mt-2 block text-[11.5px] text-[color:var(--color-ink-3)]">
{{ __('Abgelehnt am') }} {{ $latestRejection->created_at->format('d.m.Y H:i') }}
</span>
</div>
</div>
</article>
@endif
{{-- ============== 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 @error('companyId') is-invalid @enderror" data-error-anchor="companyId">
<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) KATEGORIE (Pflichtfeld — bewusst prominent vor der Headline) --}}
<section class="panel @error('categoryId') is-invalid @enderror" data-error-anchor="categoryId">
<div class="p-4">
<div class="flex flex-wrap items-center gap-4">
<span class="pr-form-label" style="margin-bottom:0;">
{{ __('Kategorie') }} <span class="req">*</span>
</span>
<div class="min-w-[260px]">
<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>
</div>
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
{{ __('Pflichtfeld — steuert, in welcher Rubrik die PM erscheint.') }}
</span>
</div>
<flux:error name="categoryId" class="mt-2" />
</div>
</section>
{{-- 3) TITEL --}}
<section class="panel @error('title') is-invalid @enderror" data-error-anchor="title">
<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">
{{ __('40–90 Zeichen empfohlen. Konkret, ohne Marketing-Floskeln.') }}
</p>
<flux:error name="title" />
</div>
</section>
{{-- 4) SUBTITLE --}}
<section class="panel @error('subtitle') is-invalid @enderror" data-error-anchor="subtitle">
<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>
{{-- 5) FLIESSTEXT --}}
<section class="panel @error('text') is-invalid @enderror" data-error-anchor="text">
<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" />
<p class="mt-2 mb-0 text-[11.5px] leading-[1.5] text-[color:var(--color-ink-3)]">
{{ __('Links im Text sind ausdrücklich erwünscht. Externe Links werden bei Veröffentlichung automatisch als sponsored/nofollow ausgezeichnet (Google-Vorgabe für Pressemitteilungen) — Links auf Ihr Unternehmensprofil im Portal bleiben follow.') }}
</p>
<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)
{{-- 6) 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
{{-- 7) MEDIEN — Image-Manager direkt eingebunden --}}
@if (config('images.rights_v2'))
<livewire:components.press-release-images-manager-v2 :press-release-id="$id" :wire:key="'pr-images-v2-'.$id" />
@else
<livewire:components.press-release-images-manager :press-release-id="$id" :wire:key="'pr-images-'.$id" />
@endif
{{-- 7) 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" />
--}}
{{-- 8) BOILERPLATE --}}
<section class="panel @error('boilerplateOverride') is-invalid @enderror" data-error-anchor="boilerplateOverride">
<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:switch
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">
<x-portal.pr-error-summary :errors="$errors" />
<x-portal.pr-presubmit-check :checks="$this->presubmitChecks" />
<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 Inhaltsprüfung dauert typ. bis zu 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))
<x-content-score-panel :score="$contentScore" :tier="$contentTier"
:note="__('Der Score wird nach dem Speichern automatisch neu berechnet.')" />
@endif
{{-- 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 @error('contactId') is-invalid @enderror" data-error-anchor="contactId">
<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 @if ($errors->hasAny(['publishMode', 'scheduledDate', 'scheduledTime', 'scheduledAt'])) is-invalid @endif" data-error-anchor="scheduledAt">
<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 Inhaltsprüfung 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 @if ($errors->hasAny(['backlinkUrl', 'language', 'keywords'])) is-invalid @endif" data-error-anchor="backlinkUrl">
<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"
booking-action="saveDraftAndChooseBooking"
:confirm-label="$currentStatus === 'rejected' ? __('Speichern & erneut zur Prüfung freigeben') : __('Speichern & zur Prüfung freigeben')"
:quota-total="$quotaTotal"
:quota-remaining="$quotaRemaining"
:has-title-image="! $coverIsPlaceholder"
:legal-documents="$this->legalDocumentsForSubmit()" />
</div>