presseportale/resources/views/livewire/customer/press-releases/create.blade.php
Kevin Adametz 2cde7284a1 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

1101 lines
51 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\PressReleaseHtmlSanitizer;
use App\Services\PressRelease\PressReleaseService;
use App\Services\PressRelease\QuotaExceededException;
use Flux\Flux;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Attributes\Title;
use Livewire\Component;
new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] 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;
public string $portal = 'presseecho';
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 $placeholderVariant = '';
public bool $hasCompanies = true;
public function mount(): void
{
$user = auth()->user();
$context = app(CustomerCompanyContext::class);
// Ohne Firma kein PM-Formular: statt eines leeren Editors, in dem
// weder Firma wählbar noch Speichern möglich ist, erscheint eine
// Meldung mit dem direkten Weg zur Firmen-Anlage.
$this->hasCompanies = $context->companyCountFor($user) > 0;
if (! $this->hasCompanies) {
return;
}
$firstCompany = $context->selectedCompany($user) ?? $context->latestCompaniesFor($user, 1)->first();
if ($firstCompany) {
$this->companyId = $firstCompany->id;
$this->portal = $firstCompany->portal?->value ?? Portal::Presseecho->value;
$this->contactId = $this->defaultContactIdFor((int) $firstCompany->id);
}
$this->placeholderVariant = PressReleasePlaceholder::default()->value;
}
#[On('placeholder-selected')]
public function setPlaceholderVariant(string $variant): void
{
$this->placeholderVariant = PressReleasePlaceholder::fromValueOrDefault($variant)->value;
}
public function updatedCompanyId(): void
{
$company = $this->selectedCompany();
if ($company?->portal) {
$this->portal = $company->portal->value;
}
$this->contactId = $company
? $this->defaultContactIdFor((int) $company->id)
: null;
unset($this->tags, $this->presubmitChecks);
}
public function updatedCompanySearch(): void
{
$this->resetErrorBag('companyId');
}
/**
* Single Source of Truth für die Validierungsregeln.
*
* @return array<string, array<int, mixed>>
*/
protected function formRules(): array
{
return [
...$this->baseFormRules(),
'companyId' => ['required', 'integer'],
];
}
// Tag-Verwaltung (addTag/removeTag/tagsArray) liegt im Trait
// App\Livewire\Concerns\InteractsWithPressReleaseTags.
/**
* Lazy Auto-Draft: legt sofort einen Entwurf an, damit Titelbild und
* Einstellungen schon vor dem finalen Speichern gepflegt werden
* können. Erfordert nur Firma und Kategorie (category_id ist NOT NULL),
* alle übrigen Felder werden – soweit erfasst – übernommen. Anschließend
* wird in den vollwertigen Editor (Edit-Seite) weitergeleitet, wo der
* Bild-Manager direkt zur Verfügung steht.
*/
public function ensureDraft(): void
{
try {
$this->validate([
'companyId' => ['required', 'integer'],
'categoryId' => ['required', 'integer', Rule::exists('categories', 'id')],
], [
'companyId.required' => __('Bitte zuerst eine Firma wählen, bevor du ein Titelbild hochlädst.'),
'categoryId.required' => __('Bitte zuerst eine Kategorie wählen, bevor du ein Titelbild hochlädst.'),
]);
} catch (\Illuminate\Validation\ValidationException $e) {
$this->notifyValidationError($e);
throw $e;
}
$user = auth()->user();
$company = $this->selectedCompany();
if (! $company) {
$this->addError('companyId', __('Die gewählte Firma ist nicht Ihrem Account zugeordnet.'));
$this->notifyValidationError();
return;
}
$this->portal = $company->portal?->value ?? Portal::Presseecho->value;
$slug = (new PressRelease)->generateUniqueSlug($this->title ?: __('Entwurf'), [
'portal' => $this->portal,
'language' => $this->language,
]);
$pr = new PressRelease([
'uuid' => (string) Str::uuid(),
'user_id' => $user->id,
'status' => PressReleaseStatus::Draft->value,
...$this->pressReleaseAttributes($slug),
]);
$pr->saveWithUniqueSlugRetry($this->title ?: __('Entwurf'), [
'portal' => $this->portal,
'language' => $this->language,
]);
if ($this->contactId) {
$contact = $this->companyContact((int) $this->contactId, (int) $company->id);
if ($contact) {
$pr->contacts()->sync([$contact->id]);
}
}
Flux::toast(
heading: __('Entwurf gesichert'),
text: __('Du kannst jetzt ein Titelbild hochladen und alle Einstellungen vornehmen. Der Entwurf liegt unter „Meine PMs".'),
variant: 'success',
);
$this->redirect(route('me.press-releases.edit', $pr->id), navigate: true);
}
/**
* Validiert das Formular und legt die Pressemitteilung als Entwurf an.
* Gibt den persistierten Entwurf zurück oder null, wenn Firma/Kontakt nicht
* auflösbar sind (Validierungsfehler wirft ValidationException).
*
* Wichtig: Dies ist der einzige Persistenz-Pfad. Jede Aktion (Speichern,
* Einreichen, „Buchung auswählen") MUSS zuerst hierüber speichern, bevor
* navigiert wird — sonst geht der erfasste Inhalt verloren.
*/
private function persistDraft(): ?PressRelease
{
$this->syncScheduledAt();
$this->useEmbargo = false;
$this->embargoAt = null;
try {
$this->validate($this->formRules());
} catch (\Illuminate\Validation\ValidationException $e) {
$this->notifyValidationError($e);
throw $e;
}
$user = auth()->user();
$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;
$slug = (new PressRelease)->generateUniqueSlug($this->title, [
'portal' => $this->portal,
'language' => $this->language,
]);
// Immer als Entwurf anlegen — der Weg nach "review" führt ausschließlich
// über submitForReview() (Blacklist, Gate, Quota, Status-Log, KI).
$pr = new PressRelease([
'uuid' => (string) Str::uuid(),
'user_id' => $user->id,
'status' => PressReleaseStatus::Draft->value,
...$this->pressReleaseAttributes($slug),
]);
$pr->saveWithUniqueSlugRetry($this->title, [
'portal' => $this->portal,
'language' => $this->language,
]);
if ($contact) {
$pr->contacts()->sync([$contact->id]);
}
return $pr;
}
/**
* Speichert den Entwurf und leitet anschließend zur Buchungsseite — wird
* vom Submit-Modal genutzt, wenn keine Buchung vorliegt. So bleibt der
* erfasste Inhalt erhalten (kein Sprung ohne Speichern).
*/
public function saveDraftAndChooseBooking(): void
{
$pr = $this->persistDraft();
if (! $pr) {
return;
}
Flux::toast(
heading: __('Entwurf gespeichert'),
text: __('Ihr Entwurf ist gesichert. Wählen Sie eine Buchung, um ihn anschließend einzureichen.'),
variant: 'success',
);
$this->redirect(route('me.bookings.index'), navigate: true);
}
public function save(string $submitStatus = 'draft'): void
{
// KI-Kennzeichnung (Art. 50 KI-VO): Pflicht nur beim Einreichen —
// zuerst prüfen, damit die Fehler direkt im Submit-Modal erscheinen.
if ($submitStatus === 'review') {
$this->validateAiDeclaration();
$this->persistLegalAcceptances();
}
$pr = $this->persistDraft();
if (! $pr) {
return;
}
if ($submitStatus === 'review') {
$this->authorize('submitForReview', $pr);
try {
app(PressReleaseService::class)->submitForReview($pr);
} 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: __('Als Entwurf gespeichert'),
text: $e->getMessage(),
variant: 'warning',
duration: 8000,
);
$this->redirect(route('me.press-releases.show', $pr->id), navigate: true);
return;
}
Flux::toast(
heading: __('Eingereicht'),
text: __('Die automatische Inhaltsprüfung ist in der Regel innerhalb weniger Minuten abgeschlossen.'),
variant: 'success',
);
} else {
Flux::toast(
heading: __('Entwurf gespeichert'),
text: __('Deine Änderungen sind gesichert. Du kannst die PM jederzeit weiter bearbeiten.'),
variant: 'success',
);
}
$this->redirect(route('me.press-releases.show', $pr->id), navigate: true);
}
/**
* Gemeinsame Spaltenwerte für Create- und Auto-Draft-Anlage.
*
* @return array<string, mixed>
*/
private function pressReleaseAttributes(string $slug): array
{
return [
'portal' => $this->portal,
'language' => $this->language,
'company_id' => (int) $this->companyId,
'category_id' => (int) $this->categoryId,
'title' => trim($this->title) !== '' ? $this->title : __('Unbenannter Entwurf'),
'subtitle' => trim($this->subtitle) ?: null,
'slug' => $slug,
'text' => app(PressReleaseHtmlSanitizer::class)->clean($this->text),
'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(),
];
}
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();
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),
'quotaTotal' => $user->pressReleaseQuotaTotal(),
'quotaRemaining' => $user->pressReleaseQuotaRemaining(),
];
}
#[Computed]
public function tags(): array
{
return $this->tagsArray();
}
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 · Neue PM') }}</span>
<span class="badge muted dot">{{ __('Entwurf') }}</span>
</div>
<h1 class="text-[30px] font-bold tracking-[-0.6px] leading-[1.15] m-0 text-[color:var(--color-ink)]">
{{ __('Neue Pressemitteilung') }}
</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="arrow-left" href="{{ route('me.press-releases.index') }}" wire:navigate>
{{ __('Zur Liste') }}
</flux:button>
</div>
</header>
@if (! $hasCompanies)
{{-- ============== KEINE FIRMA: MELDUNG STATT FORMULAR ============== --}}
<article class="panel">
<div class="p-8 flex flex-col items-center text-center gap-4">
<div class="w-14 h-14 rounded-[6px] flex items-center justify-center
bg-[color:var(--color-hub-soft)] border border-[color:var(--color-hub-soft-2)] text-[color:var(--color-hub)]">
<flux:icon.building-office class="size-6" />
</div>
<div class="space-y-1 max-w-[520px]">
<h2 class="text-[16px] font-semibold text-[color:var(--color-ink)] m-0">
{{ __('Ohne Firma kann keine Pressemitteilung angelegt werden.') }}
</h2>
<p class="text-[13px] leading-[1.55] text-[color:var(--color-ink-2)] m-0">
{{ __('Jede Pressemitteilung erscheint im Namen einer Firma. Bitte legen Sie zuerst eine Firma an — danach können Sie hier direkt loslegen.') }}
</p>
</div>
<div class="flex flex-wrap items-center justify-center gap-2 pt-1">
<flux:button variant="primary" icon="plus" href="{{ route('me.press-kits.create') }}" wire:navigate>
{{ __('Firma anlegen') }}
</flux:button>
<flux:button variant="filled" href="{{ route('me.press-releases.index') }}" wire:navigate>
{{ __('Zur PM-Übersicht') }}
</flux:button>
</div>
</div>
</article>
@else
{{-- ============== 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 space-y-3">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-4">
<span class="pr-form-label shrink-0" style="margin-bottom:0;">{{ __('Für Firma') }}</span>
<flux:select
wire:model.live="companyId"
variant="combobox"
:filter="false"
clearable
placeholder="{{ __('Firma suchen…') }}"
class="w-full sm:flex-1"
>
<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>
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
{{ __('Boilerplate und Pressekontakt werden vorbefüllt.') }}
</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>
</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 space-y-3">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-4">
<span class="pr-form-label shrink-0" style="margin-bottom:0;">
{{ __('Kategorie') }} <span class="req">*</span>
</span>
<div class="w-full sm:flex-1">
<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>
</div>
<span class="block text-[11.5px] text-[color:var(--color-ink-3)]">
{{ __('Pflichtfeld — steuert, in welcher Rubrik die PM erscheint.') }}
</span>
<flux:error name="categoryId" />
</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>
{{-- 6) TITELBILD --}}
<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;">
{{ __('Titelbild') }}
</span>
<span class="pr-bald-badge">{{ __('KI-Bildgenerierung · bald') }}</span>
</div>
{{-- Titelbild-Platzhalter (bis ein eigenes Bild hochgeladen ist) --}}
<div class="mb-4">
<div class="mb-2 flex items-center justify-between gap-3">
<span class="text-[12px] font-semibold text-[color:var(--color-ink-2)]">{{ __('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>
{{-- Anzeige analog Detailansicht: max. 1280×580, zentriert begrenzt --}}
<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>
<div class="rounded-[5px] border border-dashed border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-5 text-center">
<flux:icon name="photo" class="mx-auto mb-2 size-8 text-[color:var(--color-ink-4)]" />
<p class="text-[12.5px] text-[color:var(--color-ink-2)] m-0">
{{ __('Lade ein eigenes Titelbild hoch. Dafür wird automatisch ein Entwurf gesichert — danach kannst du alles weiter bearbeiten.') }}
</p>
<flux:button
type="button"
variant="primary"
icon="arrow-up-tray"
class="mt-3"
wire:click="ensureDraft"
wire:loading.attr="disabled"
wire:target="ensureDraft"
>
{{ __('Titelbild hochladen & Entwurf sichern') }}
</flux:button>
<p class="mt-2 text-[11px] text-[color:var(--color-ink-4)] m-0">
{{ __('Erfordert nur Firma + Kategorie. Du landest danach im Editor mit Bild-Upload.') }}
</p>
</div>
</div>
</section>
{{-- Titelbild-Platzhalter-Auswahl --}}
<livewire:components.press-release-placeholder-picker :current="$placeholderVariant" />
{{-- 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.
<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;">
{{ __('Anhänge / Downloads') }}
<span class="text-[color:var(--color-ink-4)] font-normal" style="letter-spacing:0;text-transform:none;">
— {{ __('optional, nach Speichern verfügbar') }}
</span>
</span>
</div>
<div class="rounded-[5px] border border-dashed border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-4 text-[12px] text-[color:var(--color-ink-3)]">
{{ __('PDF, Pressemappen und andere Dokumente kannst du nach dem ersten Speichern hinzufügen.') }}
</div>
</div>
</section>
--}}
{{-- 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>
<span class="badge muted dot">{{ __('Entwurf') }}</span>
</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"
>
{{ __('Zur Prüfung freigeben') }}
</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('draft')"
wire:loading.attr="disabled"
>
{{ __('Als Entwurf speichern') }}
</flux:button>
</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 @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 „Zur Prüfung freigeben") --}}
{{-- Im Erstellen-Formular gibt es noch keinen Bild-Upload (der folgt im
Editor nach dem Entwurf-Sichern), daher entfällt die Bildrechte-Frage. --}}
<x-press-release-submit-modal
name="confirm-submit-review"
action="save('review')"
booking-action="saveDraftAndChooseBooking"
:confirm-label="__('Zur Prüfung freigeben')"
:quota-total="$quotaTotal"
:quota-remaining="$quotaRemaining"
:has-title-image="false"
:legal-documents="$this->legalDocumentsForSubmit()" />
@endif
</div>