presseportale/resources/views/livewire/customer/press-releases/show.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

2345 lines
129 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\BoostPlacement;
use App\Enums\PressReleaseStatus;
use App\Enums\SinglePurchaseStatus;
use App\Enums\SinglePurchaseType;
use App\Exceptions\BoostNotAllowedException;
use App\Exceptions\InsufficientCreditsException;
use App\Models\BookingConfirmation;
use App\Models\LegalDocument;
use App\Models\PressRelease;
use App\Models\PressReleaseImage;
use App\Models\SinglePurchase;
use App\Services\Auth\MagicLinkGenerator;
use App\Services\Billing\CreditPricingService;
use App\Services\Billing\ProofPdfService;
use App\Services\PressRelease\AiDeclarationRequiredException;
use App\Services\PressRelease\BlacklistViolationException;
use App\Services\PressRelease\BookingRequiredException;
use App\Services\PressRelease\BoostService;
use App\Services\Image\ImageRightsTextRenderer;
use App\Services\PressRelease\Images\CustomerImageConfirmation;
use App\Services\PressRelease\Images\ImageUploadException;
use App\Services\PressRelease\PressReleaseCoverImage;
use App\Services\PressRelease\PressReleaseService;
use App\Services\PressRelease\QuotaExceededException;
use App\Services\PressRelease\VisibilityStats;
use App\Services\SearchConsole\IndexStatusChecker;
use App\Scopes\PortalScope;
use Flux\Flux;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Component;
new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends Component
{
use \App\Livewire\Concerns\InteractsWithAiDeclaration;
#[Locked]
public int $id;
public ?string $shareUrl = null;
/** Zeitraum der Verlaufsgrafik „Auslieferung" in Tagen (Roadmap 1c.1). */
#[Url(as: 'verlauf', except: 30)]
public int $viewRange = 30;
public ?string $shareExpiresAt = null;
public bool $depublicationConfirmed = false;
/** Spur A/D (Phase 4): Korrektur-Formular sichtbar? */
public bool $correctionOpen = false;
/** @var array<string, string> Bearbeitbare Feld-Werte der Korrektur. */
public array $correctionValues = [];
/** @var array{fields: array<string, array{distance_percent: float, changed: bool}>, max_distance_percent: float, has_changes: bool, is_minor: bool, exceeds_paid_limit: bool}|null */
public ?array $correctionPreview = null;
/**
* Spur D (Phase 7): Ergebnis der einmaligen KI-Vorprüfung im
* Änderungs-Modal — rot stoppt vor der Zahlung.
*
* @var array{classification: string, reasons: list<string>}|null
*/
public ?array $paidChangePrecheck = null;
/** Spur D: Unternehmer-/AGB-Bestätigung im Änderungs-Modal. */
public bool $changeConfirmed = false;
/** Spur B (DSGVO-Modal): Schwärzbegriffe, ein Begriff pro Zeile. */
public string $dsgvoTerms = '';
public bool $dsgvoDetachContacts = false;
public bool $dsgvoRemoveImages = false;
/** Spur B: Begründung des Anliegens (Pflicht). */
public string $dsgvoMessage = '';
/** @var array{fields: array<string, array{hits: int, after: string}>, total_hits: int}|null */
public ?array $dsgvoPreview = null;
/**
* Kontext fürs „Nicht genügend Credits"-Modal (QA-Befund 07.07.):
* ['label' => Add-on-Name, 'required' => int, 'available' => int].
*
* @var array{label: string, required: int, available: int}|null
*/
public ?array $creditShortfall = null;
/**
* Kontext fürs Kauf-Bestätigungs-Modal (Entscheidung 10.07.): Add-ons
* buchen erst nach explizitem „Kostenpflichtig buchen", damit ein Klick
* auf eine Laufzeit nicht sofort Credits abbucht. Läuft bereits ein
* Boost derselben Stufe, tragen extendsUntil/newEndsAt den
* Verlängerungs-Hinweis.
*
* @var array{type: 'boost'|'proof', placement: string|null, label: string, credits: int, days: int|null, extendsUntil: string|null, newEndsAt: string|null}|null
*/
public ?array $pendingPurchase = null;
/**
* Pflicht-Schalter im Kauf-Modal: ohne aktive Bestätigung führt
* buyPendingAddOn keine Abbuchung aus.
*/
public bool $purchaseConfirmed = false;
/**
* „Bild bestätigen" (Bildkennzeichnung B.4, Decision-Update E10): Schalter
* für die Rechtebestätigung eines im Auftrag hochgeladenen Titelbilds.
*/
public bool $imageRightsConfirmed = false;
/**
* Kennung des angezeigten Bestätigungstexts (Audit N06): Bestätigt wird
* nur der Wortlaut, den die Seite gerade zeigt.
*/
#[Locked]
public ?string $imageLegalTextKey = null;
/**
* Bild, das die Seite zur Bestätigung anzeigt (Umsetzungsaudit 29.09.,
* B-04): Kennung und Datei-Hash werden beim Rendern gesetzt und beim
* Bestätigen gegen das aktuelle Titelbild geprüft.
*/
#[Locked]
public ?int $confirmImageId = null;
#[Locked]
public ?string $confirmImageSha = null;
public function confirmImageRights(CustomerImageConfirmation $confirmation, ImageRightsTextRenderer $texts): void
{
$pr = $this->getMyPR();
// Eigene Berechtigung statt `update` (Audit N05): nur der Kontoinhaber,
// auch bei veröffentlichter PM (Admin-Bildtausch).
$this->authorize('confirmImageRights', $pr);
$image = $pr->images()->with('latestRightsConfirmation')->orderByDesc('is_preview')->orderBy('sort_order')->orderBy('id')->first();
if ($image === null || ! $image->awaitsCustomerConfirmation()) {
return;
}
// Bestätigt wird nur das Bild, das der Kunde gesehen hat (B-04).
if ($this->confirmImageId !== $image->id || $this->confirmImageSha === null || $image->original_sha256 === null || ! hash_equals($this->confirmImageSha, $image->original_sha256)) {
$this->imageRightsConfirmed = false;
$this->addError('imageRightsConfirmed', __('Das Bild wurde inzwischen geändert. Bitte prüfen Sie das aktuelle Titelbild und bestätigen Sie erneut.'));
return;
}
$this->validate(
['imageRightsConfirmed' => ['accepted']],
['imageRightsConfirmed.accepted' => __('Die Bestätigung ist erforderlich, damit das Bild veröffentlicht werden kann.')],
);
$legalText = $texts->current();
if ($legalText?->key !== $this->imageLegalTextKey) {
$this->imageLegalTextKey = $legalText?->key;
$this->imageRightsConfirmed = false;
$this->addError('imageRightsConfirmed', __('Der Bestätigungstext wurde aktualisiert. Bitte lesen Sie die neue Fassung und bestätigen Sie erneut.'));
return;
}
try {
$confirmation->confirm(
$image,
auth()->user(),
$legalText?->key,
request()->ip(),
request()->userAgent(),
route('agb', ['edition' => 'de']),
$this->confirmImageSha,
);
} catch (ImageUploadException $exception) {
$this->addError('imageRightsConfirmed', $exception->getMessage());
return;
}
$this->imageRightsConfirmed = false;
Flux::toast(text: __('Bildrechte bestätigt. Das Titelbild kann jetzt veröffentlicht werden.'), variant: 'success');
}
/**
* Angezeigtes Titelbild für die Bestätigung merken (B-04).
*/
private function rememberConfirmationImage(?PressReleaseImage $image): ?PressReleaseImage
{
$this->confirmImageId = $image?->id;
$this->confirmImageSha = $image?->original_sha256;
return $image;
}
public function mount(int $id): void
{
$this->id = $id;
$pr = $this->getMyPR();
$this->authorize('view', $pr);
// 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;
}
public function submitForReview(): void
{
$pr = $this->getMyPR();
$this->authorize('submitForReview', $pr);
// KI-Kennzeichnung (Art. 50 KI-VO): Pflicht-Deklaration aus dem
// Submit-Modal validieren und vor dem Einreichen persistieren.
$this->validateAiDeclaration();
$this->persistLegalAcceptances();
$pr->update($this->aiDeclarationAttributes());
try {
app(PressReleaseService::class)->submitForReview($pr);
} catch (BlacklistViolationException $e) {
Flux::modal('confirm-submit-review')->close();
Flux::toast(
heading: __('Automatisch abgelehnt'),
text: __('Unzulässiges Wort gefunden: ":word". Bitte überarbeiten.', ['word' => $e->word]),
variant: 'danger',
duration: 8000,
);
return;
} catch (AiDeclarationRequiredException|BookingRequiredException|QuotaExceededException $e) {
Flux::modal('confirm-submit-review')->close();
Flux::toast(
heading: __('Nicht eingereicht'),
text: $e->getMessage(),
variant: 'warning',
duration: 8000,
);
return;
}
Flux::modal('confirm-submit-review')->close();
Flux::toast(
heading: __('Eingereicht'),
text: __('Die automatische Inhaltsprüfung ist in der Regel innerhalb weniger Minuten abgeschlossen.'),
variant: 'success',
);
}
public function generateShareLink(MagicLinkGenerator $generator): void
{
$pr = $this->getMyPR();
$this->authorize('view', $pr);
$share = $generator->createPressReleaseShareLink($pr, auth()->user());
$this->shareUrl = $share['url'];
$this->shareExpiresAt = $share['expires_at']->format('d.m.Y H:i');
Flux::toast(text: __('Vorschau-Link wurde erzeugt.'), variant: 'success');
}
/**
* Kauf-Bestätigung für einen Boost öffnen (Abbuchung erst in
* buyPendingAddOn). Stufe: Startseiten-Boost oder Kategorie-Boost.
*/
public function confirmBoost(int $days, string $placement = 'home'): void
{
$placement = BoostPlacement::tryFrom($placement);
if (! $placement) {
return;
}
$options = app(CreditPricingService::class)->boostOptions($placement);
if (! isset($options[$days])) {
return;
}
// Läuft bereits ein Boost dieser Stufe, verlängert die Buchung dessen
// Laufzeit — das Modal weist ausdrücklich darauf hin (10.07.).
$activeUntil = app(BoostService::class)->activeUntil($this->getMyPR(), $placement);
$this->purchaseConfirmed = false;
$this->pendingPurchase = [
'type' => 'boost',
'placement' => $placement->value,
'label' => __(':placement — :days Tage', ['placement' => $placement->label(), 'days' => $days]),
'credits' => $options[$days],
'days' => $days,
'extendsUntil' => $activeUntil?->format('d.m.Y H:i'),
'newEndsAt' => $activeUntil?->copy()->addDays($days)->format('d.m.Y H:i'),
];
Flux::modal('confirm-addon-purchase')->show();
}
/**
* Kauf-Bestätigung für den Veröffentlichungsnachweis öffnen.
*/
public function confirmProof(): void
{
$this->purchaseConfirmed = false;
$this->pendingPurchase = [
'type' => 'proof',
'placement' => null,
'label' => __('Veröffentlichungsnachweis (PDF)'),
'credits' => app(ProofPdfService::class)->priceCredits(),
'days' => null,
'extendsUntil' => null,
'newEndsAt' => null,
];
Flux::modal('confirm-addon-purchase')->show();
}
/**
* Bestätigten Add-on-Kauf ausführen — der einzige Weg, auf dem die
* Buttons der Add-on-Sektion tatsächlich Credits abbuchen.
*/
public function buyPendingAddOn(): void
{
// Serverseitige Absicherung des Pflicht-Schalters (der Button ist
// clientseitig zusätzlich deaktiviert).
if ($this->pendingPurchase !== null && ! $this->purchaseConfirmed) {
Flux::toast(
text: __('Bitte bestätigen Sie die kostenpflichtige Buchung über den Schalter.'),
variant: 'warning',
);
return;
}
$pending = $this->pendingPurchase;
$this->pendingPurchase = null;
$this->purchaseConfirmed = false;
Flux::modal('confirm-addon-purchase')->close();
if ($pending === null) {
return;
}
if ($pending['type'] === 'boost') {
$this->buyBoost($pending['days'], $pending['placement'] ?? 'home');
return;
}
$this->buyProof();
}
/**
* Boost (Platzierung) aus der Wallet buchen — nur veröffentlichte, grüne
* PMs sind boostbar; ausgebuchte Slots lehnt der BoostService ab (Gate).
*/
public function buyBoost(int $days, string $placement = 'home'): void
{
$placement = BoostPlacement::tryFrom($placement);
if (! $placement) {
return;
}
$pr = $this->getMyPR();
$this->authorize('view', $pr);
try {
app(BoostService::class)->boost(auth()->user(), $pr, $days, $placement);
Flux::toast(
heading: __(':placement gebucht', ['placement' => $placement->label()]),
text: $placement === BoostPlacement::Home
? __('Ihre Pressemitteilung wird jetzt auf Start- und Branchenseite hervorgehoben.')
: __('Ihre Pressemitteilung wird jetzt auf der Branchenseite ihrer Rubrik hervorgehoben.'),
variant: 'success',
);
} catch (InsufficientCreditsException $e) {
$this->showCreditShortfallModal(__('Der Boost'), $e);
} catch (BoostNotAllowedException $e) {
Flux::toast(text: $e->getMessage(), variant: 'warning', duration: 8000);
}
}
/**
* Veröffentlichungsnachweis-PDF kaufen (3 Credits, einmalig je PM). Danach
* steht der Download über die Nachweis-Route bereit.
*/
public function buyProof(): void
{
$pr = $this->getMyPR();
$this->authorize('downloadProof', $pr);
try {
app(ProofPdfService::class)->purchase(auth()->user(), $pr);
Flux::toast(
heading: __('Nachweis gekauft'),
text: __('Der Veröffentlichungsnachweis steht jetzt zum Download bereit.'),
variant: 'success',
);
} catch (InsufficientCreditsException $e) {
$this->showCreditShortfallModal(__('Der Veröffentlichungsnachweis'), $e);
}
}
/**
* Statt eines flüchtigen Toasts ein Modal mit Aufladen-Link öffnen
* (QA-Befund 07.07.): der Kunde soll direkt zur Wallet finden.
*/
private function showCreditShortfallModal(string $label, InsufficientCreditsException $e): void
{
$this->creditShortfall = [
'label' => $label,
'required' => $e->required,
'available' => $e->available,
];
Flux::modal('insufficient-credits')->show();
}
/**
* Spur A/D (Meldeverfahren Phase 4): Korrektur-Formular öffnen und mit
* den aktuellen Werten vorbefüllen.
*/
public function startCorrection(): void
{
$pr = $this->getMyPR();
$this->authorize('view', $pr);
$this->correctionValues = [
'title' => (string) ($pr->title ?? ''),
'subtitle' => (string) ($pr->subtitle ?? ''),
// Legacy-Plaintext für den HTML-Editor in <p>/<br> wandeln —
// sonst kollabieren die Umbrüche und gingen beim Speichern verloren.
'text' => app(\App\Services\PressRelease\PressReleaseHtmlSanitizer::class)->toEditorHtml($pr->text),
'keywords' => (string) ($pr->keywords ?? ''),
];
$this->correctionOpen = true;
$this->correctionPreview = null;
}
/**
* Pflicht-Vorschau: Levenshtein-Gate stuft klein (kostenlos, sofort)
* vs. groß (Spur D, 10 €) ein — der Nutzer wählt das nie selbst.
*/
public function previewCorrection(\App\Services\PressRelease\CorrectionService $correctionService): void
{
$pr = $this->getMyPR();
$this->authorize('view', $pr);
$classification = $correctionService->classify($pr, $this->correctionValues);
if (! $classification['has_changes']) {
$this->addError('correction', __('Es wurden keine Änderungen erkannt.'));
$this->correctionPreview = null;
return;
}
$this->resetErrorBag('correction');
$this->correctionPreview = $classification;
}
public function applyCorrection(\App\Services\PressRelease\CorrectionService $correctionService): void
{
$pr = $this->getMyPR();
$this->authorize('view', $pr);
if ($this->correctionPreview === null) {
$this->addError('correction', __('Bitte zuerst die Vorschau prüfen.'));
return;
}
try {
$legalRequest = $correctionService->apply(
$pr,
$this->correctionValues,
(string) auth()->user()->email,
\App\Enums\LegalRequestVerificationMethod::LoggedIn,
auth()->user(),
);
} catch (\App\Services\PressRelease\CorrectionTooLargeException) {
$this->addError('correction', __('Die Änderung überschreitet den Rahmen einer kleinen Korrektur — bitte als kostenpflichtige Änderung einreichen.'));
return;
} catch (\App\Services\PressRelease\CorrectionRateLimitException $e) {
$this->addError('correction', $e->getMessage());
return;
}
$user = auth()->user();
if ($user->email && $user->wantsEmailNotification(\App\Enums\EmailNotificationType::PressReleaseCorrected)) {
\Illuminate\Support\Facades\Mail::to($user->email)
->queue(new \App\Mail\PressReleaseChangeApplied($user, $pr, $legalRequest));
}
$this->correctionOpen = false;
$this->correctionPreview = null;
Flux::toast(__('Korrektur gespeichert (:reference) — die Änderung ist sofort live.', ['reference' => $legalRequest->reference()]));
}
/**
* Spur D Schritt 1 (Phase 7): Modal mit der Zwischenerklärung öffnen —
* die kostenpflichtige Änderung läuft über eine einmalige KI-Vorprüfung
* VOR der Zahlung. Je PM ist der Vorgang nur einmal möglich; ein
* abgebrochener Stripe-Checkout darf aber wieder aufgenommen werden.
*/
public function openPaidChangeModal(): void
{
$pr = $this->getMyPR();
$this->authorize('view', $pr);
if ($this->correctionPreview === null) {
$this->addError('correction', __('Bitte zuerst die Vorschau prüfen.'));
return;
}
if ((int) config('billing.change_price_cents') <= 0) {
$this->addError('correction', __('Die kostenpflichtige Änderung ist derzeit nicht online buchbar. Bitte wenden Sie sich an :email.', ['email' => (string) config('legal.contact_email')]));
return;
}
if ($this->correctionPreview['exceeds_paid_limit'] ?? false) {
$this->addError('correction', __('Die Änderung ist zu umfangreich (über :limit % Distanz) und kann nicht als Änderung gebucht werden.', ['limit' => (int) config('legal.change_max_distance_percent')]));
return;
}
$existing = $this->latestChangeRequest($pr);
if ($existing !== null) {
$check = $existing->payload['ki_check']['classification'] ?? null;
if ($existing->status === \App\Enums\LegalRequestStatus::Open && in_array($check, ['green', 'yellow'], true)) {
// Abgebrochene Zahlung: direkt wieder zum Zahlungs-Schritt —
// es gilt die bereits geprüfte Fassung aus der Anfrage.
$this->paidChangePrecheck = [
'classification' => (string) $check,
'reasons' => (array) ($existing->payload['ki_check']['reasons'] ?? []),
];
Flux::modal('paid-change')->show();
return;
}
$this->addError('correction', __('Die kostenpflichtige Änderung ist je Pressemitteilung nur einmal möglich — für diese Mitteilung wurde sie bereits genutzt oder abgelehnt.'));
return;
}
$this->paidChangePrecheck = null;
$this->changeConfirmed = false;
Flux::modal('paid-change')->show();
}
/**
* Spur D Schritt 2: einmalige KI-Vorprüfung der geänderten Fassung.
* Rot → Change-Anfrage wird direkt abgelehnt, keine Zahlung. Gelb/Grün →
* die Anfrage bleibt offen und der Zahlungs-Schritt wird freigeschaltet.
* Der Checkout-Controller verlangt genau diese Anfrage als Nachweis.
*/
public function runPaidChangePrecheck(\App\Services\PressRelease\CorrectionService $correctionService): void
{
$pr = $this->getMyPR();
$this->authorize('view', $pr);
if ($this->correctionPreview === null) {
$this->addError('correction', __('Bitte zuerst die Vorschau prüfen.'));
Flux::modal('paid-change')->close();
return;
}
if ($this->latestChangeRequest($pr) !== null) {
$this->addError('correction', __('Die kostenpflichtige Änderung ist je Pressemitteilung nur einmal möglich — für diese Mitteilung wurde sie bereits genutzt oder abgelehnt.'));
Flux::modal('paid-change')->close();
return;
}
$result = $correctionService->precheckProposedContent($pr, $this->correctionValues);
$isRed = $result->classification === \App\Enums\PressReleaseClassification::Red;
\App\Models\LegalRequest::query()->create([
'press_release_id' => $pr->id,
'portal' => $pr->portal?->value,
'type' => \App\Enums\LegalRequestType::Change->value,
'status' => $isRed
? \App\Enums\LegalRequestStatus::Rejected->value
: \App\Enums\LegalRequestStatus::Open->value,
'requester_email' => (string) auth()->user()->email,
'message' => __('Kostenpflichtige Änderung (Spur D) über das Kunden-Panel eingereicht.'),
'payload' => [
'source' => 'self_service',
'new_values' => $this->correctionValues,
'fields' => $this->correctionPreview['fields'],
'ki_check' => [
'classification' => $result->classification->value,
'reasons' => $result->reasons,
'provider' => $result->provider,
'model' => $result->model,
'checked_at' => now()->toIso8601String(),
],
],
'requester_ip' => request()->ip(),
'verified_at' => now(),
'verification_method' => \App\Enums\LegalRequestVerificationMethod::LoggedIn->value,
'resolved_at' => $isRed ? now() : null,
]);
$this->paidChangePrecheck = [
'classification' => $result->classification->value,
'reasons' => $result->reasons,
];
}
/**
* Unternehmer-Pflicht-Checkbox im Änderungs-Modal (Spur D) —
* protokolliert wie bei der Depublikation; der Checkout-Controller
* verlangt die zeitnahe Bestätigung zusätzlich serverseitig.
*/
public function updatedChangeConfirmed(bool $value): void
{
if (! $value) {
return;
}
$agb = LegalDocument::current(LegalDocument::TYPE_AGB);
BookingConfirmation::query()->create([
'user_id' => auth()->id(),
'booking_type' => 'press_release_change',
'booking_ref' => (string) $this->id,
'legal_document_id' => $agb?->id,
'agb_version' => $agb?->version ?? (string) config('legal.agb_version'),
'ip' => request()->ip(),
'confirmed_at' => now(),
]);
}
/**
* Jüngste Änderungs-Anfrage (Spur D) dieser PM — Grundlage der
* „nur einmal möglich"-Regel.
*/
private function latestChangeRequest(PressRelease $pr): ?\App\Models\LegalRequest
{
return \App\Models\LegalRequest::query()
->where('press_release_id', $pr->id)
->where('type', \App\Enums\LegalRequestType::Change->value)
->latest('id')
->first();
}
/**
* Anliegen-Modal → Korrektur/Änderung: Formular öffnen und zum
* Panel-Abschnitt scrollen.
*/
public function goToCorrection(): void
{
$this->startCorrection();
Flux::modal('pm-concerns')->close();
$this->js("document.getElementById('korrektur-panel')?.scrollIntoView({behavior: 'smooth'})");
}
/**
* Anliegen-Modal → Depublizierung: zum Panel-Abschnitt scrollen.
*/
public function goToDepublication(): void
{
Flux::modal('pm-concerns')->close();
$this->js("document.getElementById('depublikation-panel')?.scrollIntoView({behavior: 'smooth'})");
}
public function openDsgvoModal(): void
{
Flux::modal('pm-concerns')->close();
Flux::modal('pm-dsgvo')->show();
}
/**
* Spur B (strukturierter Schwärzungsauftrag, wie im Web-Formular):
* Pflicht-Vorschau vor der Einreichung — verändert nichts an der PM.
*/
public function previewDsgvo(\App\Services\PressRelease\AnonymizationService $anonymizationService): void
{
$pr = $this->getMyPR();
$this->authorize('view', $pr);
$terms = $this->dsgvoTermList();
if ($terms === [] && ! $this->dsgvoDetachContacts && ! $this->dsgvoRemoveImages) {
$this->addError('dsgvo', __('Bitte mindestens einen Begriff angeben oder eine der Entfernungs-Optionen auswählen.'));
$this->dsgvoPreview = null;
return;
}
$preview = $anonymizationService->preview($pr, $terms);
if ($terms !== [] && $preview['total_hits'] === 0) {
$this->addError('dsgvo', __('Die angegebenen Begriffe kommen in der Mitteilung nicht vor. Bitte prüfen Sie die Schreibweise (ganzwörtliche Suche).'));
$this->dsgvoPreview = null;
return;
}
$this->resetErrorBag('dsgvo');
$this->dsgvoPreview = $preview;
}
/**
* Einreichung ≠ Ausführung: erzeugt die verifizierte DSGVO-Anfrage für
* die Admin-Queue (Spur B bleibt admin-gegated), die PM bleibt unberührt.
*/
public function submitDsgvo(): void
{
$pr = $this->getMyPR();
$this->authorize('view', $pr);
if ($this->dsgvoPreview === null) {
$this->addError('dsgvo', __('Bitte zuerst die Vorschau prüfen.'));
return;
}
if (mb_strlen(trim($this->dsgvoMessage)) < 10) {
$this->addError('dsgvo', __('Bitte begründen Sie Ihr Anliegen kurz (mindestens 10 Zeichen).'));
return;
}
$alreadyOpen = \App\Models\LegalRequest::query()
->where('press_release_id', $pr->id)
->where('type', \App\Enums\LegalRequestType::Dsgvo->value)
->open()
->exists();
if ($alreadyOpen) {
$this->addError('dsgvo', __('Für diese Pressemitteilung liegt bereits eine offene Datenschutz-Anfrage vor.'));
return;
}
$legalRequest = \App\Models\LegalRequest::query()->create([
'press_release_id' => $pr->id,
'portal' => $pr->portal?->value,
'type' => \App\Enums\LegalRequestType::Dsgvo->value,
'status' => \App\Enums\LegalRequestStatus::Open->value,
'requester_email' => (string) auth()->user()->email,
'message' => trim($this->dsgvoMessage),
'payload' => [
'source' => 'self_service',
'concern' => 'dsgvo',
'redaction' => [
'terms' => $this->dsgvoTermList(),
'detach_contacts' => $this->dsgvoDetachContacts,
'remove_images_requested' => $this->dsgvoRemoveImages,
'preview_total_hits' => (int) ($this->dsgvoPreview['total_hits'] ?? 0),
],
],
'requester_ip' => request()->ip(),
'verified_at' => now(),
'verification_method' => \App\Enums\LegalRequestVerificationMethod::LoggedIn->value,
]);
\Illuminate\Support\Facades\Mail::to($legalRequest->requester_email)->queue(
(new \App\Mail\LegalRequestReceived($legalRequest))->afterCommit()
);
\Illuminate\Support\Facades\Mail::to((string) config('legal.contact_email'))->queue(
(new \App\Mail\LegalRequestSubmitted($legalRequest))->afterCommit()
);
$this->reset('dsgvoTerms', 'dsgvoDetachContacts', 'dsgvoRemoveImages', 'dsgvoMessage', 'dsgvoPreview');
Flux::modal('pm-dsgvo')->close();
Flux::toast(__('Datenschutz-Anfrage :reference eingereicht — unser Team prüft und setzt die Schwärzung um.', ['reference' => $legalRequest->reference()]));
}
/**
* @return list<string>
*/
private function dsgvoTermList(): array
{
return collect(preg_split('/\r\n|\r|\n/', $this->dsgvoTerms) ?: [])
->map(fn (string $term): string => trim($term))
->filter()
->unique()
->values()
->all();
}
/**
* Unternehmer-Pflicht-Checkbox vor dem Depublikations-Checkout (T1neu):
* protokolliert wie im Buchungs-Modal — der Checkout-Controller verlangt
* eine zeitnahe Bestätigung zusätzlich serverseitig.
*/
public function updatedDepublicationConfirmed(bool $value): void
{
if (! $value) {
return;
}
$agb = LegalDocument::current(LegalDocument::TYPE_AGB);
BookingConfirmation::query()->create([
'user_id' => auth()->id(),
'booking_type' => 'depublication',
'booking_ref' => (string) $this->id,
'legal_document_id' => $agb?->id,
'agb_version' => $agb?->version ?? (string) config('legal.agb_version'),
'ip' => request()->ip(),
'confirmed_at' => now(),
]);
}
/**
* Anzeige-Daten der beiden Boost-Stufen (Startseiten-/Kategorie-Boost)
* fürs Add-on-Panel: Preise, laufende Platzierung, Slot-Verfügbarkeit
* („Derzeit ausgebucht — frei ab …", Entscheidung 13.07.).
*
* @return array<string, array{label: string, description: string, options: array<int, int>, isBoosted: bool, activeUntil: \Illuminate\Support\Carbon|null, available: bool, free: int, limit: int, freeAt: \Illuminate\Support\Carbon|null}>
*/
private function boostAddOns(PressRelease $pr, BoostService $boostService): array
{
$pricing = app(CreditPricingService::class);
$canBoost = $boostService->canBoost($pr);
$addOns = [];
foreach (BoostPlacement::cases() as $placement) {
// Slot-Abfrage nur, wenn die PM überhaupt boostbar ist.
$availability = $canBoost
? $boostService->slotAvailability($pr, $placement)
: ['available' => false, 'free' => 0, 'limit' => $boostService->slotLimit($placement), 'freeAt' => null];
$addOns[$placement->value] = [
'label' => $placement->label(),
'description' => $placement === BoostPlacement::Home
? __('Hervorhebung auf Start- und Branchenseite.')
: __('Hervorhebung auf der Branchenseite Ihrer Rubrik.'),
'options' => $pricing->boostOptions($placement),
'isBoosted' => $boostService->isBoosted($pr, $placement),
'activeUntil' => $boostService->activeUntil($pr, $placement),
'available' => $availability['available'],
'free' => $availability['free'],
'limit' => $availability['limit'],
'freeAt' => $availability['freeAt'],
];
}
return $addOns;
}
public function with(): array
{
$pr = $this->getMyPR();
$this->authorize('view', $pr);
$categoryName = $pr->category?->translations->firstWhere('locale', 'de')?->name ?? '–';
$latestRejection = null;
if ($pr->status->value === 'rejected') {
$latestRejection = $pr->statusLogs
->firstWhere(fn ($log) => $log->to_status?->value === 'rejected');
}
$cover = app(PressReleaseCoverImage::class);
$user = auth()->user();
// Angezeigte Fassung des Bestätigungstexts merken (Audit N06).
$imageLegalText = app(ImageRightsTextRenderer::class)->current();
$this->imageLegalTextKey = $imageLegalText?->key;
$company = $pr->company;
$boostService = app(BoostService::class);
$proofService = app(ProofPdfService::class);
// Auslieferung belegen (Roadmap 1c.1, Stufe 1): Rohzahlen und
// Tagesverlauf aus eigener Hand, nur für Meldungen, die je online
// waren. Der Indexierungsstatus (1c.4) kommt erst mit, wenn die
// Kundenansicht freigegeben ist (E8).
$stats = app(VisibilityStats::class);
$wasPublic = $pr->published_at !== null || (int) $pr->hits > 0;
$range = VisibilityStats::ranges()->contains($this->viewRange) ? $this->viewRange : 30;
$indexChecks = $wasPublic && config('search_console.customer_visible')
? app(IndexStatusChecker::class)->latestFor($pr)
: [];
return [
'pr' => $pr,
'visibility' => $wasPublic ? $stats->releaseSummary($pr) : null,
'indexChecks' => $indexChecks,
'helpPageUrl' => rtrim((string) config('domains.domain_portal_url'), '/').'/de/gute-pressemitteilung',
'dailyViews' => $wasPublic ? $stats->dailyViews($pr, $range) : [],
'viewRanges' => VisibilityStats::ranges(),
'publicLinks' => $pr->status === PressReleaseStatus::Published
? $pr->publicPortalLinks()
: [],
'creditBalance' => $user->creditBalance(),
'canBoost' => $boostService->canBoost($pr),
'boostAddOns' => $this->boostAddOns($pr, $boostService),
'proofPrice' => $proofService->priceCredits(),
'proofPurchased' => $proofService->hasPurchased($user, $pr),
'companyLogoUrl' => $company?->logoUrl(),
'companyInitials' => $this->companyInitials($company?->name),
'companyMetaLine' => $this->companyMetaLine($company),
'categoryName' => $categoryName,
'coverUrl' => $cover->coverUrl($pr, 'cover'),
'coverIsPlaceholder' => $cover->coverIsPlaceholder($pr),
'titleImage' => $titleImage = $this->rememberConfirmationImage($pr->images()->with('latestRightsConfirmation')->orderByDesc('is_preview')->orderBy('sort_order')->orderBy('id')->first()),
'awaitsImageConfirmation' => $titleImage?->awaitsCustomerConfirmation() === true && auth()->user()->can('confirmImageRights', $pr),
'imageLegalShort' => $imageLegalText
? app(ImageRightsTextRenderer::class)->renderShort($imageLegalText, route('agb', ['edition' => 'de']))
: null,
'imageLegalLong' => $imageLegalText
? app(ImageRightsTextRenderer::class)->renderLong($imageLegalText, route('agb', ['edition' => 'de']))
: null,
'quotaTotal' => $user->pressReleaseQuotaTotal(),
'quotaRemaining' => $user->pressReleaseQuotaRemaining(),
'canEdit' => auth()->user()->can('update', $pr)
&& in_array($pr->status->value, ['draft', 'rejected']),
// Einreichen zur Prüfung ist hinter eine bezahlte Buchung gegated
// (Decision-Update §5.1, Schalter billing.enforce_booking).
'hasActiveBooking' => $user->hasActiveBooking(),
'latestRejection' => $latestRejection,
'contacts' => $pr->contacts,
'statusLogs' => $pr->statusLogs,
// T12: Depublikation ist config-gated (ohne Preis kein Angebot).
'depublicationPriceCents' => (int) config('billing.depublication_price_cents'),
// Nur ein BEZAHLTER Vorgang blendet das Angebot aus — ein
// abgebrochener Checkout (Pending) wird beim nächsten Klick
// wiederverwendet (CheckoutController::depublication).
'depublicationRequested' => SinglePurchase::query()
->where('press_release_id', $pr->id)
->where('type', SinglePurchaseType::Depublication->value)
->where('status', SinglePurchaseStatus::Paid->value)
->exists(),
];
}
/**
* Initialen für die Logo-Kachel (analog zur Firmenübersicht).
*/
private function companyInitials(?string $name): string
{
$name = trim((string) $name);
if ($name === '') {
return '–';
}
$letters = collect(preg_split('/\s+/u', $name) ?: [])
->map(fn (string $word): string => mb_substr($word, 0, 1))
->filter()
->take(2)
->implode('');
return mb_strtoupper($letters ?: mb_substr($name, 0, 2));
}
/**
* Kompakte Meta-Zeile: Ort (letzte Adresszeile) · Firmentyp.
*/
private function companyMetaLine(?\App\Models\Company $company): string
{
if (! $company) {
return '';
}
$parts = [];
$lastAddressLine = collect(preg_split('/\r?\n/', trim((string) $company->address)) ?: [])
->map(fn ($line) => trim((string) $line))
->filter()
->last();
if (is_string($lastAddressLine) && $lastAddressLine !== '') {
$parts[] = $lastAddressLine;
}
if ($company->type?->label()) {
$parts[] = $company->type->label();
}
return implode(' · ', $parts);
}
private ?PressRelease $cachedPressRelease = null;
private function getMyPR(): PressRelease
{
// Pro Livewire-Request memoisiert: mount(), with() und die Add-on-
// Aktionen rufen getMyPR() sonst jeweils mit der vollen Eager-Load-
// Query erneut auf. Firmen-Kennzahlen direkt mit eager laden, statt
// sie nachträglich per loadCount() nachzuziehen.
return $this->cachedPressRelease ??= PressRelease::accessibleBy(auth()->user())
->with([
'company' => fn ($query) => $query
->select(['id', 'name', 'email', 'phone', 'address', 'portal', 'logo_path', 'legacy_portal', 'is_active', 'type'])
->withCount(['pressReleases', 'contacts']),
'category.translations',
'contacts' => fn ($query) => $query
->withoutGlobalScope(PortalScope::class)
->orderBy('last_name')
->orderBy('first_name')
->select(['contacts.id', 'contacts.company_id', 'contacts.first_name', 'contacts.last_name', 'contacts.responsibility', 'contacts.email', 'contacts.phone']),
'statusLogs.changedBy:id,name,email',
])
->findOrFail($this->id);
}
}; ?>
<div class="space-y-8">
@php
$statusClass = $pr->status->badgeClass();
@endphp
{{-- Flash-Banner ersetzt durch <flux:toast /> im Layout. --}}
{{-- ============== PAGE HEADER ============== --}}
<header class="page-header">
<div class="min-w-0">
<div class="flex items-center gap-3 mb-3 flex-wrap">
<span class="badge hub dot">{{ __('User Backend') }}</span>
<span class="eyebrow muted">{{ __('Mein Bereich · Pressemitteilung') }}</span>
<span @class(['badge', $statusClass])>{{ $pr->status->label() }}</span>
@if ($pr->content_tier?->isPubliclyBadged())
<span class="badge {{ $pr->content_tier === \App\Enums\PressReleaseContentTier::Hochwertig ? 'ok' : 'hub' }}">
{{ $pr->content_tier === \App\Enums\PressReleaseContentTier::Hochwertig ? '★ ' : '✓ ' }}{{ $pr->content_tier->label() }}
</span>
@endif
<span class="badge hub">{{ strtoupper($pr->language) }}</span>
</div>
<h1 class="text-[30px] font-bold tracking-[-0.6px] leading-[1.15] m-0 text-[color:var(--color-ink)]">
{{ $pr->title }}
</h1>
@if ($pr->subtitle)
<p class="text-[18px] font-medium tracking-[-0.2px] leading-[1.35] mt-2 m-0 max-w-[720px] text-[color:var(--color-ink-2)]">
{{ $pr->subtitle }}
</p>
@endif
<p class="text-[13px] leading-[1.55] mt-2 m-0 max-w-[720px] text-[color:var(--color-ink-2)]">
{{ $pr->company?->name ?? '–' }}
<span class="text-[color:var(--color-bg-rule)] mx-1">·</span>
{{ $categoryName }}
<span class="text-[color:var(--color-bg-rule)] mx-1">·</span>
{{ $pr->created_at->format('d.m.Y') }}
</p>
</div>
<div class="flex items-center gap-2 flex-shrink-0 flex-wrap justify-end">
{{-- Direktlinks zur öffentlichen Seite (QA-Befund 07.07.):
bei Portal::Both je Portal ein eigener Button. --}}
@foreach ($publicLinks as $link)
<flux:button variant="primary" icon="arrow-top-right-on-square"
href="{{ $link['url'] }}" target="_blank" rel="noopener">
{{ count($publicLinks) > 1
? __('Auf :portal ansehen', ['portal' => $link['label']])
: __('Öffentlich ansehen') }}
</flux:button>
@endforeach
@if ($canEdit)
<flux:button variant="filled" icon="pencil" href="{{ route('me.press-releases.edit', $pr->id) }}" wire:navigate>
{{ __('Bearbeiten') }}
</flux:button>
@endif
<flux:button variant="filled" icon="link" wire:click="generateShareLink">
{{ __('Vorschau-Link') }}
</flux:button>
@if ($pr->status === \App\Enums\PressReleaseStatus::Published)
{{-- Meldeverfahren: Anliegen-Auswahl im Modal — Korrektur/Änderung
und Depublizierung springen zu den Panel-Abschnitten, DSGVO
öffnet das strukturierte Schwärzungsformular. --}}
<flux:modal.trigger name="pm-concerns">
<flux:button variant="filled" icon="flag">
{{ __('Ändern/Melden') }}
</flux:button>
</flux:modal.trigger>
@endif
<flux:button variant="filled" icon="arrow-left" href="{{ route('me.press-releases.index') }}" wire:navigate>
{{ __('Zurück') }}
</flux:button>
</div>
</header>
{{-- ============== SHARE-LINK ERFOLG ============== --}}
@if ($shareUrl)
<article class="panel" style="border-color:var(--color-ok);">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Öffentlicher Vorschau-Link erstellt') }}</span>
<span class="badge ok dot">{{ __('gültig bis :date', ['date' => $shareExpiresAt]) }}</span>
</div>
<div class="p-5 space-y-3">
<flux:input readonly copyable :value="$shareUrl" />
<div class="flex flex-wrap items-center gap-2">
@if ($pr->portal === \App\Enums\Portal::Both)
{{-- PM erscheint auf beiden Portalen: Vorschau je Portal öffnen --}}
<flux:button size="sm" variant="primary" icon="arrow-top-right-on-square"
href="{{ $shareUrl }}?portal=presseecho" target="_blank" rel="noopener">
{{ __('Vorschau presseecho') }}
</flux:button>
<flux:button size="sm" variant="primary" icon="arrow-top-right-on-square"
href="{{ $shareUrl }}?portal=businessportal24" target="_blank" rel="noopener">
{{ __('Vorschau businessportal24') }}
</flux:button>
@else
<flux:button size="sm" variant="primary" icon="arrow-top-right-on-square"
href="{{ $shareUrl }}" target="_blank" rel="noopener">
{{ __('Vorschau öffnen') }}
</flux:button>
@endif
</div>
</div>
</article>
@endif
{{-- ============== REJECTION-HINWEIS ============== --}}
@if ($pr->status === PressReleaseStatus::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>
@php
$maxAttempts = max(1, (int) config('scoring.classification.max_automatic_attempts', 3));
@endphp
@if ($pr->requiresManualReview())
<span class="mt-1 block text-[11.5px] text-[color:var(--color-ink-3)]">
{{ __('Für diese Mitteilung entscheidet ab jetzt ein Mensch: Nach der nächsten Freigabe prüft die Redaktion direkt, ohne automatische Vorprüfung.') }}
</span>
@elseif ($pr->auto_check_attempts > 0)
<span class="mt-1 block text-[11.5px] text-[color:var(--color-ink-3)]">
{{ __('Automatische Prüfung :used von :max genutzt. Bleibt der Text danach Rot, prüft ein Mensch.', ['used' => $pr->auto_check_attempts, 'max' => $maxAttempts]) }}
<a href="{{ $helpPageUrl }}" target="_blank" rel="noopener" class="underline underline-offset-2">{{ __('Was eine gute Pressemitteilung ausmacht') }}</a>
</span>
@endif
</div>
</div>
</article>
@endif
{{-- ============== STATUS-WORKFLOW (oben, farblich abgehoben) ============== --}}
@if ($pr->status === PressReleaseStatus::Draft || $pr->status === PressReleaseStatus::Rejected)
<article class="panel"
style="border-color:var(--color-{{ $pr->status === PressReleaseStatus::Rejected ? 'err' : 'hub' }}); border-left-width:3px;
background:var(--color-{{ $pr->status === PressReleaseStatus::Rejected ? 'err' : 'hub' }}-soft);">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Status-Workflow') }}</span>
<span @class(['badge', 'dot', $pr->status === PressReleaseStatus::Rejected ? 'err' : 'hub'])>
{{ $pr->status === PressReleaseStatus::Rejected ? __('Überarbeiten') : __('Entwurf') }}
</span>
</div>
<div class="p-5 space-y-4">
@unless ($hasActiveBooking)
{{-- Veröffentlichungs-Gate: Einreichen zur Prüfung setzt eine
bezahlte Option voraus. Speichern als Entwurf bleibt frei. --}}
<div class="px-4 py-3 rounded-[5px] border text-[13px] flex items-start gap-3
bg-[color:var(--color-warn-soft)] border-[color:var(--color-warn)]/30 text-[color:var(--color-ink-2)]">
<flux:icon.lock-closed class="size-[16px] flex-shrink-0 mt-0.5 text-[color:var(--color-accent-deep)]" />
<div class="flex-1">
<span class="font-semibold text-[color:var(--color-ink)]">{{ __('Noch keine Buchung.') }}</span>
{{ __('Den Entwurf können Sie frei speichern. Zum Einreichen und Veröffentlichen benötigen Sie eine bezahlte Option (Tarif, Einzel-PM oder Extra-PM aus Guthaben).') }}
</div>
</div>
@endunless
<div class="flex flex-wrap items-center gap-3">
<p class="text-[13px] text-[color:var(--color-ink-2)] m-0 flex-1 min-w-[220px]">
{{ $pr->status === PressReleaseStatus::Rejected
? __('Sie können den Text bearbeiten und erneut zur Prüfung freigeben.')
: __('Geben Sie den Entwurf zur Prüfung frei, sobald er vollständig ist. Online geht er erst nach der Prüfung.') }}
</p>
<div class="flex items-center gap-2 flex-shrink-0 flex-wrap">
@if ($canEdit)
<flux:button variant="filled" icon="pencil" href="{{ route('me.press-releases.edit', $pr->id) }}" wire:navigate>
{{ __('Bearbeiten') }}
</flux:button>
@endif
@if ($hasActiveBooking)
<flux:modal.trigger name="confirm-submit-review">
<flux:button type="button" variant="primary">
{{ $pr->status === PressReleaseStatus::Rejected ? __('Erneut zur Prüfung freigeben') : __('Zur Prüfung freigeben') }}
</flux:button>
</flux:modal.trigger>
@else
<flux:button variant="primary" icon="credit-card" href="{{ route('me.bookings.index') }}" wire:navigate>
{{ __('Buchung wählen') }}
</flux:button>
@endif
</div>
</div>
</div>
</article>
@endif
@if ($pr->status === PressReleaseStatus::Published)
<article class="panel" style="border-color:var(--color-ok); border-left-width:3px; background:var(--color-ok-soft);">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Status-Workflow') }}</span>
<span class="badge ok dot">{{ __('Live') }}</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-ok-soft)] border border-[color:var(--color-ok)]/30 text-[color:var(--color-gain-deep)]">
<flux:icon.check-circle class="size-[18px]" />
</div>
<p class="flex-1 text-[13px] text-[color:var(--color-ink-2)] m-0">
{{ __('Diese Pressemitteilung ist veröffentlicht (seit :date).', ['date' => $pr->published_at?->format('d.m.Y H:i') ?? '–']) }}
</p>
</div>
</article>
@endif
{{-- ============== ADD-ONS (Boost + Veröffentlichungsnachweis) ============== --}}
@if ($pr->status === PressReleaseStatus::Published)
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Add-ons') }}</span>
<span class="badge">{{ __(':n Credits Guthaben', ['n' => $creditBalance]) }}</span>
</div>
<div class="p-5 grid gap-5 md:grid-cols-3">
{{-- Boost-Stufen: Startseiten-Boost + Kategorie-Boost (13.07.) --}}
@foreach ($boostAddOns as $placementValue => $addOn)
<div class="space-y-3 @if (! $loop->first) md:border-l md:border-[color:var(--color-bg-rule)] md:pl-5 @endif">
<div class="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-hub-soft)] text-[color:var(--color-hub)]">
@if ($placementValue === 'home')
<flux:icon.rocket-launch class="size-[18px]" />
@else
<flux:icon.tag class="size-[18px]" />
@endif
</div>
<div class="min-w-0">
<h3 class="text-[14px] font-bold text-[color:var(--color-ink)] m-0">{{ $addOn['label'] }}</h3>
<p class="text-[12.5px] text-[color:var(--color-ink-2)] mt-1 mb-0">
{{ $addOn['description'] }}
</p>
</div>
</div>
@if ($addOn['isBoosted'])
<div class="rounded-[6px] border border-[color:var(--color-ok)]/30 bg-[color:var(--color-ok-soft)] px-3 py-2 text-[12.5px] text-[color:var(--color-gain-deep)]">
{{ __('Aktiv bis :date', ['date' => $addOn['activeUntil']?->format('d.m.Y H:i')]) }}
</div>
@endif
@if ($canBoost && $addOn['available'])
<div class="flex flex-wrap gap-2">
@foreach ($addOn['options'] as $days => $credits)
<flux:button size="sm" variant="primary"
wire:click="confirmBoost({{ $days }}, '{{ $placementValue }}')" wire:loading.attr="disabled">
{{ __(':days Tage für :credits Credits buchen', ['days' => $days, 'credits' => $credits]) }}
</flux:button>
@endforeach
</div>
@if ($addOn['isBoosted'])
<p class="text-[11.5px] text-[color:var(--color-ink-3)] m-0">{{ __('Ein weiterer Kauf verlängert die Laufzeit.') }}</p>
@else
{{-- Slot-Transparenz (13.07.): knappe Plätze sichtbar machen. --}}
<p class="text-[11.5px] text-[color:var(--color-ink-3)] m-0">
{{ __('Noch :free von :limit Plätzen frei.', ['free' => $addOn['free'], 'limit' => $addOn['limit']]) }}
</p>
@endif
@elseif ($canBoost)
{{-- Slot-Inventar erschöpft: ehrlich ausweisen statt verkaufen (13.07.). --}}
<div class="rounded-[6px] border border-[color:var(--color-warn)]/40 bg-[color:var(--color-warn-soft)] px-3 py-2 text-[12.5px] text-[color:var(--color-ink)]">
@if ($addOn['freeAt'])
{{ __('Derzeit ausgebucht — der nächste Platz wird am :date frei.', ['date' => $addOn['freeAt']->format('d.m.Y')]) }}
@else
{{ __('Derzeit ausgebucht.') }}
@endif
</div>
@else
<p class="text-[12px] text-[color:var(--color-ink-3)] m-0">
{{ __('Nur grün geprüfte Pressemitteilungen sind boostbar.') }}
</p>
@endif
</div>
@endforeach
{{-- Veröffentlichungsnachweis --}}
<div class="space-y-3 md:border-l md:border-[color:var(--color-bg-rule)] md:pl-5">
<div class="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-hub-soft)] text-[color:var(--color-hub)]">
<flux:icon.document-check class="size-[18px]" />
</div>
<div class="min-w-0">
<h3 class="text-[14px] font-bold text-[color:var(--color-ink)] m-0">{{ __('Veröffentlichungsnachweis') }}</h3>
<p class="text-[12.5px] text-[color:var(--color-ink-2)] mt-1 mb-0">
{{ __('PDF mit Datum, Portal und URL — :credits Credits.', ['credits' => $proofPrice]) }}
</p>
</div>
</div>
@if ($proofPurchased)
<flux:button size="sm" variant="primary" icon="arrow-down-tray"
href="{{ route('me.press-releases.proof', $pr->id) }}">
{{ __('Nachweis herunterladen') }}
</flux:button>
@else
<flux:button size="sm" variant="primary" wire:click="confirmProof" wire:loading.attr="disabled">
{{ __('Für :credits Credits kaufen', ['credits' => $proofPrice]) }}
</flux:button>
@endif
</div>
</div>
</article>
@endif
@if ($pr->status === PressReleaseStatus::Review)
<article class="panel" style="border-color:var(--color-warn); border-left-width:3px; background:var(--color-warn-soft);">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Status-Workflow') }}</span>
<span class="badge warn dot">{{ __('Geduld bitte') }}</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-warn-soft)] border border-[color:var(--color-warn)]/30 text-[color:var(--color-accent-deep)]">
<flux:icon.clock class="size-[18px]" />
</div>
<div class="flex-1 text-[13px] text-[color:var(--color-ink-2)]">
@if ($pr->isAwaitingManualReview())
{{-- E3: Automatik erschöpft — ein Mensch prüft. Kein Termin
versprechen, den wir nicht halten. --}}
<p class="m-0 font-semibold text-[color:var(--color-ink)]">{{ __('Ihre Pressemitteilung wird von einem Menschen geprüft.') }}</p>
<p class="m-0 mt-1">{{ __('Die automatische Prüfung hat den Text mehrfach nicht freigegeben. Jetzt sieht sich die Redaktion Text und Begründungen an und schaltet frei oder meldet sich mit einem Kommentar. Sie werden per E-Mail benachrichtigt.') }}</p>
@else
<p class="m-0">{{ __('Ihre Pressemitteilung wird gerade geprüft. Sie werden benachrichtigt, sobald eine Entscheidung vorliegt.') }}</p>
@endif
</div>
</div>
</article>
@endif
{{-- ============== KONTAKTE + STATUS/VERLAUF ============== --}}
<div class="grid gap-6 xl:grid-cols-2">
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Firma & Pressekontakt') }}</span>
</div>
<div class="p-5 space-y-4">
{{-- Firma der Pressemitteilung — gleiche Kachel wie in der Firmenübersicht --}}
@if ($pr->company)
<div class="firm-card">
<div class="flex items-start justify-between gap-3">
<div class="logo {{ $companyLogoUrl ? '' : 'lg-'.['brew', 'mv', 'soft', 'warm'][$pr->company->id % 4] }}">
@if ($companyLogoUrl)
<img src="{{ $companyLogoUrl }}" alt="{{ $pr->company->name }}" loading="lazy" />
@else
{{ $companyInitials }}
@endif
</div>
@if ($pr->company->is_active)
<span class="badge ok dot">{{ __('Aktiv') }}</span>
@else
<span class="badge err dot">{{ __('Inaktiv') }}</span>
@endif
</div>
<div class="min-w-0">
<h3 class="name">{{ $pr->company->name }}</h3>
@if (filled($companyMetaLine))
<div class="meta-line">{{ $companyMetaLine }}</div>
@endif
<div class="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-[11.5px] text-[color:var(--color-ink-3)]">
@if ($pr->company->email)
<span>{{ $pr->company->email }}</span>
@endif
@if ($pr->company->phone)
<span>{{ $pr->company->phone }}</span>
@endif
</div>
</div>
<div class="flex items-center gap-2 flex-wrap">
@if ($pr->company->portal === \App\Enums\Portal::Both)
<span class="portal-pill pe"><span class="pdot"></span>presseecho</span>
<span class="portal-pill bp"><span class="pdot"></span>businessportal24</span>
@elseif ($pr->company->portal === \App\Enums\Portal::Presseecho)
<span class="portal-pill pe"><span class="pdot"></span>presseecho</span>
@elseif ($pr->company->portal === \App\Enums\Portal::Businessportal24)
<span class="portal-pill bp"><span class="pdot"></span>businessportal24</span>
@endif
</div>
<div class="kpis">
<div class="kpi">
<span class="k">{{ number_format($pr->company->press_releases_count ?? 0, 0, ',', '.') }}</span>
<span class="l">{{ __('PMs') }}</span>
</div>
<div class="kpi">
<span class="k">{{ number_format($pr->company->contacts_count ?? 0, 0, ',', '.') }}</span>
<span class="l">{{ __('Kontakte') }}</span>
</div>
</div>
<div class="flex items-center gap-2 pt-1">
<a href="{{ route('me.press-kits.show', $pr->company->id) }}" wire:navigate class="card-action primary" style="flex:1;">
<flux:icon.arrow-right class="size-3" />
{{ __('Firma öffnen') }}
</a>
</div>
</div>
@endif
{{-- Pressekontakt(e) direkt darunter --}}
<div class="space-y-2">
@forelse ($contacts as $contact)
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[13px] font-semibold text-[color:var(--color-ink)]">
{{ $contact->displayName() }}
</div>
<div class="text-[12px] text-[color:var(--color-ink-3)]">
{{ $contact->responsibility ?: __('Keine Rolle hinterlegt') }}
</div>
<div class="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-[11.5px] text-[color:var(--color-ink-3)]">
@if ($contact->email)
<a href="mailto:{{ $contact->email }}"
class="text-[color:var(--color-hub)] underline underline-offset-2 decoration-[color:var(--color-hub)]/40 hover:decoration-[color:var(--color-hub)]">
{{ $contact->email }}
</a>
@endif
@if ($contact->phone)
<span>{{ $contact->phone }}</span>
@endif
</div>
</div>
@empty
<div class="rounded-[5px] border border-dashed border-[color:var(--color-bg-rule)] p-4 text-[12.5px] text-[color:var(--color-ink-3)]">
{{ __('Dieser Pressemitteilung ist noch kein Pressekontakt zugeordnet.') }}
@if ($pr->company)
<a href="{{ route('me.press-kits.show', $pr->company->id) }}" wire:navigate
class="font-medium text-[color:var(--color-hub)] hover:underline">
{{ __('Kontakte in der Firma prüfen.') }}
</a>
@endif
</div>
@endforelse
</div>
</div>
</article>
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Status & Verlauf') }}</span>
<span @class(['badge', $statusClass])>{{ $pr->status->label() }}</span>
</div>
<div class="p-5">
<div class="grid gap-2 sm:grid-cols-2">
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Aktueller Status') }}</div>
<div class="mt-1.5">
<span @class(['badge', $statusClass])>{{ $pr->status->label() }}</span>
</div>
</div>
{{-- Ergebnis der KI-Inhaltsprüfung (13.07.): erklärt sichtbar,
warum die Boost-Platzierung (nur Grün) verfügbar ist oder nicht. --}}
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Inhaltsprüfung') }}</div>
<div class="mt-1.5">
@if ($pr->classification)
<span class="badge {{ $pr->classification->badgeClass() }} dot">{{ $pr->classification->label() }}</span>
@else
<span class="badge muted">{{ __('Ausstehend') }}</span>
@endif
</div>
@if ($pr->classification === \App\Enums\PressReleaseClassification::Yellow)
<div class="text-[11px] text-[color:var(--color-ink-3)] mt-1.5 leading-[1.45]">
{{ __('Die Boost-Platzierung ist grün geprüften Meldungen vorbehalten.') }}
</div>
@endif
</div>
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Erstellt') }}</div>
<div class="text-[13px] font-semibold text-[color:var(--color-ink)] mt-1">
{{ $pr->created_at?->format('d.m.Y H:i') ?? '–' }}
</div>
</div>
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Veröffentlicht') }}</div>
<div class="text-[13px] font-semibold text-[color:var(--color-ink)] mt-1">
{{ $pr->published_at?->format('d.m.Y H:i') ?? '–' }}
</div>
</div>
@if ($visibility === null)
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Aufrufe') }}</div>
<div class="text-[13px] font-semibold text-[color:var(--color-ink)] mt-1">{{ __('noch nicht veröffentlicht') }}</div>
</div>
@endif
@if ($pr->scheduled_at)
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Geplante Veröffentlichung') }}</div>
<div class="text-[13px] font-semibold text-[color:var(--color-ink)] mt-1">
{{ $pr->scheduledAtLocal()->format('d.m.Y H:i') }}
</div>
</div>
@endif
@if ($pr->embargo_at)
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Sperrfrist bis') }}</div>
<div class="text-[13px] font-semibold text-[color:var(--color-ink)] mt-1">
{{ $pr->embargoAtLocal()->format('d.m.Y H:i') }}
</div>
</div>
@endif
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Portal') }}</div>
<div class="mt-1 flex items-center gap-2">
<span class="text-[13px] font-semibold text-[color:var(--color-ink)]">{{ $pr->portal?->label() ?? '–' }}</span>
<span class="badge hub">{{ strtoupper($pr->language) }}</span>
</div>
</div>
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Kategorie') }}</div>
<div class="text-[13px] font-semibold text-[color:var(--color-ink)] mt-1">
{{ $categoryName }}
</div>
</div>
</div>
@if (filled($pr->keywords))
<div class="mt-3 rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold mb-2">{{ __('Themen') }}</div>
<div class="flex flex-wrap gap-1.5">
@foreach (array_filter(array_map('trim', explode(',', $pr->keywords))) as $keyword)
<span class="badge hub">{{ $keyword }}</span>
@endforeach
</div>
</div>
@endif
@if ($pr->backlink_url)
<div class="mt-3 rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold mb-1">{{ __('Backlink') }}</div>
<a href="{{ $pr->backlink_url }}" target="_blank"
class="text-[12.5px] break-all text-[color:var(--color-hub)] underline underline-offset-2 decoration-[color:var(--color-hub)]/40 hover:decoration-[color:var(--color-hub)]">
{{ $pr->backlink_url }}
</a>
</div>
@endif
@if ($pr->no_export)
<div class="mt-3 flex items-center gap-2 text-[12px] text-[color:var(--color-ink-3)]">
<flux:icon.no-symbol variant="micro" class="size-3.5" />
<span>{{ __('Kein Export aktiv (PM wird nicht über Feeds verteilt).') }}</span>
</div>
@endif
<div class="my-4 border-t border-[color:var(--color-bg-rule)]"></div>
@if ($statusLogs->isNotEmpty())
<ol class="space-y-3 border-s border-[color:var(--color-bg-rule)] ps-4">
@foreach ($statusLogs as $log)
<li class="text-[12.5px]">
<div class="flex flex-wrap items-center gap-2">
@php
$logClass = $log->to_status?->badgeClass() ?? 'muted';
@endphp
<span @class(['badge', $logClass])>{{ $log->to_status?->label() }}</span>
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
{{ $log->created_at->format('d.m.Y H:i') }}
</span>
@if ($log->changedBy)
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
{{ __('durch :name', ['name' => $log->changedBy->name]) }}
</span>
@endif
</div>
@if ($log->reason)
<p class="mt-1.5 text-[color:var(--color-ink-2)] m-0">{{ $log->reason }}</p>
@endif
</li>
@endforeach
</ol>
@else
<p class="text-[12.5px] text-[color:var(--color-ink-3)] m-0">
{{ __('Noch keine Statusänderungen protokolliert.') }}
</p>
@endif
</div>
</article>
{{-- ============== AUSLIEFERUNG (Roadmap 1c.1, Stufe 1) ==============
Nur Daten aus eigener Hand; nichts, was von Dritten abhängt.
Kein Link auf eine site:-Suche (Anhang 17.09.2026). --}}
@if ($visibility !== null)
@php
$measured = array_values(array_filter($dailyViews, fn ($day) => $day['views'] !== null));
$peak = max(1, (int) collect($measured)->max('views'));
$rangeSum = (int) collect($measured)->sum('views');
$barCount = count($dailyViews);
$chartWidth = 600;
$chartHeight = 96;
$gap = $barCount > 45 ? 1 : 2;
$barWidth = max(1, ($chartWidth - ($barCount - 1) * $gap) / max(1, $barCount));
@endphp
<article class="panel" id="auslieferung">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Auslieferung') }}</span>
<div class="flex items-center gap-1">
@foreach ($viewRanges as $days)
<button type="button" wire:click="$set('viewRange', {{ $days }})"
@class([
'px-2 py-0.5 rounded-[4px] text-[11px] font-semibold transition-colors',
'bg-[color:var(--color-hub)] text-white' => $viewRange === $days,
'text-[color:var(--color-ink-3)] hover:bg-[color:var(--color-bg-elev)]' => $viewRange !== $days,
])>{{ __(':days Tage', ['days' => $days]) }}</button>
@endforeach
</div>
</div>
<div class="p-5 space-y-4">
<div class="grid gap-2 sm:grid-cols-3">
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Aufrufe gesamt') }}</div>
<div class="text-[18px] font-bold tabular-nums text-[color:var(--color-ink)] mt-1">{{ number_format($visibility['hits'], 0, ',', '.') }}</div>
<div class="text-[11px] text-[color:var(--color-ink-3)] mt-0.5">{{ __('seit Veröffentlichung') }}</div>
</div>
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Aufrufe :days Tage', ['days' => $viewRange]) }}</div>
<div class="text-[18px] font-bold tabular-nums text-[color:var(--color-ink)] mt-1">{{ number_format($rangeSum, 0, ',', '.') }}</div>
<div class="text-[11px] text-[color:var(--color-ink-3)] mt-0.5">
@if ($visibility['last_view_at'])
{{ __('zuletzt :date', ['date' => $visibility['last_view_at']->format('d.m.Y')]) }}
@else
{{ __('noch kein gemessener Aufruf') }}
@endif
</div>
</div>
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Geteilt') }}</div>
<div class="text-[18px] font-bold tabular-nums text-[color:var(--color-ink)] mt-1">{{ number_format($visibility['shares_total'], 0, ',', '.') }}</div>
@if ($visibility['shares'] !== [])
<div class="text-[11px] text-[color:var(--color-ink-3)] mt-0.5">{{ collect($visibility['shares'])->map(fn ($n, $label) => $label.' '.$n)->implode(' · ') }}</div>
@endif
</div>
</div>
<div>
<div class="flex items-baseline justify-between mb-2">
<span class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Aufrufe je Tag') }}</span>
<span class="text-[11px] tabular-nums text-[color:var(--color-ink-4)]">{{ __('Spitze :n', ['n' => number_format($peak, 0, ',', '.')]) }}</span>
</div>
<svg viewBox="0 0 {{ $chartWidth }} {{ $chartHeight }}" class="w-full h-24 block" role="img" aria-label="{{ __('Aufrufe je Tag, letzte :days Tage', ['days' => $viewRange]) }}">
@foreach ($dailyViews as $i => $day)
@php
$x = $i * ($barWidth + $gap);
$h = $day['views'] === null ? 3 : max($day['views'] > 0 ? 2 : 1, round($day['views'] / $peak * ($chartHeight - 4)));
@endphp
<rect x="{{ $x }}" y="{{ $chartHeight - $h }}" width="{{ $barWidth }}" height="{{ $h }}" rx="1"
@class(['fill-[color:var(--color-hub)]' => $day['views'] !== null && $day['views'] > 0, 'fill-[color:var(--color-bg-rule)]' => $day['views'] === 0, 'fill-[color:var(--color-bg-rule)] opacity-40' => $day['views'] === null])>
<title>{{ $day['date']->format('d.m.Y') }}: {{ $day['views'] === null ? __('keine Messung') : trans_choice(':n Aufruf|:n Aufrufe', $day['views'], ['n' => $day['views']]) }}</title>
</rect>
@endforeach
</svg>
<div class="flex justify-between mt-1 text-[10.5px] tabular-nums text-[color:var(--color-ink-4)]">
<span>{{ $dailyViews[0]['date']->format('d.m.') }}</span>
<span>{{ __('heute') }}</span>
</div>
@if (collect($dailyViews)->contains(fn ($day) => $day['views'] === null))
<p class="mt-2 text-[11px] text-[color:var(--color-ink-4)] m-0">
{{ __('Blasse Tage liegen vor Beginn der Tageszählung (:date) — die Gesamtsumme enthält sie trotzdem.', ['date' => \Illuminate\Support\Carbon::parse((string) config('press_releases.events.recorded_since'))->format('d.m.Y')]) }}
</p>
@endif
</div>
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Sitemap für Suchmaschinen') }}</div>
<div class="text-[12.5px] text-[color:var(--color-ink-2)] mt-1">
@if (($visibility['sitemap_role'] ?? null) === 'copy')
{{-- Kopie bei Doppelveröffentlichung (Anhang 2, 1c.8): Leser sehen beide Fassungen, die Sitemap nennt nur die Erstquelle. --}}
{{ __('Nicht in der Sitemap — für Suchmaschinen ist die Erstfassung auf dem anderen Portal die maßgebliche Adresse (rel=canonical). Leser finden die Meldung weiterhin hier.') }}
@elseif ($visibility['in_sitemap_since'])
{{ __('Gelistet seit :date (:portals) — Veröffentlichung und Sitemap-Eintrag fallen zusammen.', ['date' => $visibility['in_sitemap_since']->format('d.m.Y'), 'portals' => implode(', ', $visibility['sitemap_portals'] ?? [])]) }}
@elseif (($visibility['sitemap_role'] ?? null) === 'source')
{{-- Fassung auch auf dem anderen Portal: Die Rolle kann gewechselt haben, ein Datum wäre geraten (N17). --}}
{{ __('In der Sitemap gelistet (:portals). Die Meldung liegt auch auf dem anderen Portal; seit wann diese Fassung die maßgebliche ist, lässt sich nicht genau sagen.', ['portals' => implode(', ', $visibility['sitemap_portals'] ?? [])]) }}
@else
{{ __('Derzeit nicht gelistet — die Meldung ist nicht öffentlich.') }}
@endif
</div>
</div>
{{-- RSS-Feeds (Roadmap 3.1): Branchen- und Portal-Feed je Portal. --}}
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('RSS-Feeds') }}</div>
@if (($visibility['feeds'] ?? []) !== [])
<ul class="mt-1 m-0 p-0 list-none space-y-0.5 text-[12.5px]">
@foreach ($visibility['feeds'] as $feed)
<li class="flex flex-wrap items-baseline justify-between gap-x-3">
<a href="{{ $feed['url'] }}" target="_blank" rel="noopener" class="text-[color:var(--color-ink-2)] underline underline-offset-2 hover:text-[color:var(--color-ink)]">{{ $feed['label'] }}</a>
<span class="tabular-nums text-[11.5px] text-[color:var(--color-ink-3)]">{{ trans_choice(':n Abruf|:n Abrufe', $feed['fetches_30d'] ?? 0, ['n' => number_format($feed['fetches_30d'] ?? 0, 0, ',', '.')]) }} · {{ __('30 Tage') }}</span>
</li>
@endforeach
</ul>
<p class="mt-2 mb-0 text-[11px] text-[color:var(--color-ink-4)]">{{ __('Die Feeds führen die jüngsten :n Meldungen. Gezählt werden Abrufe der Feeds (ein Reader-Dienst holt einmal für alle seine Nutzer) — wer sie abonniert hat, ist uns nicht bekannt.', ['n' => \App\Http\Controllers\Web\FeedController::MAX_ITEMS]) }}</p>
@else
<p class="mt-1 mb-0 text-[12.5px] text-[color:var(--color-ink-3)]">{{ __('Derzeit in keinem Feed — die Meldung ist nicht öffentlich.') }}</p>
@endif
</div>
{{-- Abrufe durch Such- und KI-Dienste (Roadmap 1c.6). Ein
Abruf ist kein Zitat — die Anzeige belegt nur, dass der
Crawler des Dienstes die Seite geholt hat. --}}
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Abrufe durch Such- und KI-Dienste') }}</div>
@if ($visibility['crawlers'] !== [])
<ul class="mt-1 m-0 p-0 list-none divide-y divide-[color:var(--color-bg-rule)]">
@foreach ($visibility['crawlers'] as $crawler)
<li class="flex flex-wrap items-baseline justify-between gap-x-3 py-1 text-[12.5px]">
<span class="text-[color:var(--color-ink)]">{{ $crawler['label'] }}
<span class="text-[11px] text-[color:var(--color-ink-4)]">{{ $crawler['kind'] === 'search' ? __('Suche') : __('KI') }}</span>
</span>
<span class="tabular-nums text-[color:var(--color-ink-2)]">{{ __('zuletzt :date', ['date' => $crawler['last_at']->format('d.m.Y')]) }} · {{ trans_choice(':n Abruf|:n Abrufe', $crawler['fetches'], ['n' => number_format($crawler['fetches'], 0, ',', '.')]) }}</span>
</li>
@endforeach
</ul>
<p class="mt-2 mb-0 text-[11px] text-[color:var(--color-ink-4)]">{{ __('Zugeordnet laut Kennung des Abrufers (User-Agent), die sich nicht überprüfen lässt. Ein Abruf belegt, dass der Crawler des Dienstes die Seite geholt hat — nicht, dass sie dort angezeigt oder zitiert wird. Crawler-Abrufe zählen nicht als Aufrufe.') }}</p>
@else
<p class="mt-1 mb-0 text-[12.5px] text-[color:var(--color-ink-3)]">
{{ __('Noch kein Abruf protokolliert.') }}
@if ($visibility['crawlers_since'] && $visibility['crawlers_since']->isFuture())
{{ __('Das Protokoll beginnt am :date.', ['date' => $visibility['crawlers_since']->format('d.m.Y')]) }}
@elseif ($visibility['crawlers_since'])
{{ __('Erfasst seit :date; frühere Abrufe lassen sich nicht nachtragen.', ['date' => $visibility['crawlers_since']->format('d.m.Y')]) }}
@endif
</p>
@endif
</div>
{{-- Indexierungsstatus (Roadmap 1c.4) — Kundenansicht per
config search_console.customer_visible (E8), sonst leer. --}}
@if ($indexChecks !== [])
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Google-Index') }}</div>
<ul class="mt-1 m-0 p-0 list-none space-y-1.5 text-[12.5px]">
@foreach ($indexChecks as $check)
@php $source = app(IndexStatusChecker::class)->sourceVerdict($check); @endphp
<li>
<div class="flex flex-wrap items-baseline justify-between gap-x-3">
<span class="text-[color:var(--color-ink)]">{{ $check->portal->label() }}: {{ $check->state->label() }}</span>
<span class="text-[11px] tabular-nums text-[color:var(--color-ink-4)]">{{ __('Stand :date', ['date' => $check->checked_at->format('d.m.Y')]) }}@if ($check->last_crawl_time) · {{ __('zuletzt gecrawlt :date', ['date' => $check->last_crawl_time->format('d.m.Y')]) }}@endif</span>
</div>
{{-- Quellenauskunft (Anhang 2, 1c.9): welche Adresse Google als
maßgeblich führt — ohne Aussage darüber, warum. --}}
@if ($source['kind'] === 'self')
<div class="text-[11.5px] text-[color:var(--color-ink-3)]">{{ __('Google führt diese Adresse als maßgebliche Quelle.') }}</div>
@elseif ($source['kind'] === 'own_other')
<div class="text-[11.5px] text-[color:var(--color-ink-3)]">{{ __('Google führt die Fassung auf :host als maßgebliche Quelle; diese Adresse gilt als Kopie.', ['host' => $source['host']]) }}</div>
@elseif ($source['kind'] === 'foreign')
<div class="text-[11.5px] text-[color:var(--color-ink-3)]">{{ __('Google führt eine Fassung auf :host als maßgebliche Quelle — ein anderes Portal mit demselben Text. Diese Adresse gilt dort als Kopie.', ['host' => $source['host']]) }}</div>
@endif
</li>
@endforeach
</ul>
<p class="mt-2 mb-0 text-[11px] text-[color:var(--color-ink-4)]">{{ __('Momentaufnahme aus der Google Search Console. Ob und wann Google eine Seite aufnimmt und welche Fassung sie als Quelle führt, entscheidet Google — warum, ist nicht bekannt.') }}</p>
</div>
@endif
</div>
</article>
@endif
{{-- Content-Score wie im Editor (gemeinsame Komponente, 13.07.). --}}
@if (! is_null($pr->content_score))
<x-content-score-panel :score="$pr->content_score" :tier="$pr->content_tier" />
@endif
</div>
{{-- ============== TITELBILD (Hero) ============== --}}
{{-- Harte Obergrenze 1280x580 px: Container deckelt Breite und Seitenverhältnis,
damit das Bild auf großen Screens nicht über die Detailgröße hinauswächst. --}}
<article class="panel overflow-hidden mx-auto w-full max-w-[1280px]">
<div class="relative aspect-[1280/580] w-full">
<img src="{{ $coverUrl }}" alt="{{ $pr->title }}"
class="absolute inset-0 h-full w-full object-cover" loading="lazy" />
</div>
@if ($coverIsPlaceholder)
<div class="flex items-center gap-2 border-t border-[color:var(--color-bg-rule)] px-5 py-2.5 text-[12px] text-[color:var(--color-ink-3)]">
<flux:icon.photo variant="micro" class="size-3.5" />
<span>{{ __('Platzhalter-Titelbild — laden Sie im Editor ein eigenes Bild hoch.') }}</span>
</div>
@elseif ($titleImage && ($imageCreditInfo = \App\Support\ImageCredit::for($titleImage, $pr->company))->hasDetails())
{{-- Bildnachweis, KI-Kennzeichnung und Bestätigungsstand aus der
gemeinsamen Auflösung (Bildkennzeichnung D.1/D.3). --}}
<div class="flex flex-wrap items-center gap-2 border-t border-[color:var(--color-bg-rule)] px-5 py-2.5 text-[12px] text-[color:var(--color-ink-3)]">
@if ($imageCreditInfo->aiLabel)
<span class="badge hub">{{ $imageCreditInfo->aiLabel }}</span>
@endif
@if ($imageCreditInfo->credit)
<span>{{ $imageCreditInfo->creditLabel }}: {{ $imageCreditInfo->credit }}</span>
@endif
@if ($imageCreditInfo->confirmation)
<span class="text-[color:var(--color-ink-4)]">· {{ __('Rechte bestätigt am :date (:key, AGB :agb, :channel)', ['date' => $imageCreditInfo->confirmation->confirmed_at->format('d.m.Y H:i'), 'key' => $imageCreditInfo->confirmation->legal_text_key, 'agb' => $imageCreditInfo->confirmation->agb_version ?? '–', 'channel' => $imageCreditInfo->confirmation->channel->value]) }}</span>
@elseif ($titleImage->awaitsCustomerConfirmation())
<span class="badge">{{ __('wartet auf Ihre Bestätigung') }}</span>
@endif
</div>
@endif
</article>
{{-- ============== BILD BESTÄTIGEN (Admin-Upload, E10) ============== --}}
@if ($awaitsImageConfirmation)
<article class="panel" data-image-confirmation>
<div class="panel-head">
<span class="section-eyebrow">{{ __('Titelbild bestätigen') }}</span>
</div>
<div class="p-5 space-y-4 max-w-[760px]">
<p class="m-0 text-[13.5px] leading-relaxed">
{{ __('Dieses Titelbild wurde von unserem Team für Sie hinterlegt. Es wird erst veröffentlicht, wenn Sie bestätigen, dass Sie zur Veröffentlichung berechtigt sind und die Angaben stimmen.') }}
</p>
<dl class="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-[12.5px] m-0">
<dt class="text-[color:var(--color-ink-3)]">{{ __('Bildnachweis') }}</dt>
<dd class="m-0">{{ $titleImage->copyright ?: '—' }}</dd>
<dt class="text-[color:var(--color-ink-3)]">{{ __('KI-Kennzeichnung') }}</dt>
<dd class="m-0">{{ $titleImage->ai_declaration?->label() ?? '—' }}</dd>
</dl>
<flux:switch wire:model="imageRightsConfirmed" align="right" :label="__('Ich bestätige')" :description="$imageLegalShort" />
<flux:error name="imageRightsConfirmed" />
@if ($imageLegalLong)
<details class="group">
<summary class="flex cursor-pointer list-none items-center gap-1 text-[12px] text-[color:var(--color-ink-3)] [&::-webkit-details-marker]:hidden">
<flux:icon.chevron-right class="size-3.5 transition-transform group-open:rotate-90" />
{{ __('Vollständiger Wortlaut') }}
</summary>
<blockquote class="m-0 mt-2 rounded-md border border-[color:var(--color-bg-rule)] p-3 text-[12px] leading-relaxed text-[color:var(--color-ink-3)]">{{ $imageLegalLong }}</blockquote>
</details>
@endif
<div>
<flux:button variant="primary" wire:click="confirmImageRights">{{ __('Bildrechte bestätigen') }}</flux:button>
</div>
</div>
</article>
@endif
{{-- ============== INHALT ============== --}}
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Inhalt') }}</span>
</div>
<div class="p-5">
{{-- KI-Label (Art. 50 KI-VO): erscheint so auch in der
Veröffentlichung — am Anfang des Inhalts, in Einreicher-Stimme. --}}
<x-portal.ai-public-label :label="$pr->aiTextPublicLabel()" class="mb-4 max-w-[760px]" />
<div class="pr-content max-w-[760px]">
{!! $pr->renderedText() !!}
</div>
</div>
</article>
{{-- ============== VERÖFFENTLICHUNGS-MODAL ============== --}}
@if ($pr->status === PressReleaseStatus::Draft || $pr->status === PressReleaseStatus::Rejected)
<x-press-release-submit-modal
name="confirm-submit-review"
action="submitForReview"
:confirm-label="__('Zur Prüfung freigeben')"
:quota-total="$quotaTotal"
:quota-remaining="$quotaRemaining"
:has-title-image="! $coverIsPlaceholder"
:legal-documents="$this->legalDocumentsForSubmit()" />
@endif
{{-- ============== BOILERPLATE-OVERRIDE ============== --}}
@if ($pr->boilerplate_override)
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Eigener Abbinder (Boilerplate)') }}</span>
<span class="badge hub">{{ __('Override') }}</span>
</div>
<div class="p-5">
<p class="text-[12px] text-[color:var(--color-ink-3)] mt-0 mb-3">
{{ __('Dieser Text wird für diese Pressemitteilung anstelle des Standard-Abbinders der Firma verwendet.') }}
</p>
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-4 text-[13px] leading-[1.6] text-[color:var(--color-ink-2)] whitespace-pre-line">
{{ $pr->boilerplate_override }}
</div>
</div>
</article>
@endif
{{-- ============== ÄNDERUNG (Meldeverfahren Phase 4, Spur A/D) ==============
Kleine Korrektur (Levenshtein-Gate) kostenlos + sofort live; größere
Änderung wird automatisch als Spur D (10 €) eingestuft. --}}
@if ($pr->status === PressReleaseStatus::Published)
<article class="panel" id="korrektur-panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Änderung & Korrektur') }}</span>
</div>
<div class="p-5 space-y-4">
<div class="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-hub-soft)] text-[color:var(--color-hub)]">
<flux:icon.pencil-square class="size-[18px]" />
</div>
<div class="min-w-0">
<h3 class="text-[14px] font-bold text-[color:var(--color-ink)] m-0">{{ __('Veröffentlichte Mitteilung korrigieren oder ändern') }}</h3>
<p class="text-[12.5px] text-[color:var(--color-ink-2)] mt-1 mb-0">
{{ __('Kleine Korrekturen (Tippfehler, Kontaktdaten — ohne neuen Inhalt) sind kostenlos und sofort live. Größere Änderungen (Distanz über :percent %) kosten :price € zzgl. USt., werden vor der Zahlung einmalig automatisch geprüft und sind je Mitteilung nur einmal möglich. Die Einstufung übernimmt das System anhand der Vorschau.', ['percent' => (int) config('legal.correction_max_distance_percent'), 'price' => number_format((int) config('billing.change_price_cents') / 100, 2, ',', '.')]) }}
{{ trans_choice('Pro Mitteilung ist höchstens eine kostenlose Korrektur pro Woche möglich.|Pro Mitteilung sind höchstens :count kostenlose Korrekturen pro Woche möglich.', (int) config('legal.corrections_per_week'), ['count' => (int) config('legal.corrections_per_week')]) }}
</p>
</div>
</div>
@if (! $correctionOpen)
<flux:button size="sm" variant="filled" icon="pencil" wire:click="startCorrection">
{{ __('Korrektur/Änderung starten') }}
</flux:button>
@else
<div class="space-y-3">
<flux:field>
<flux:input wire:model="correctionValues.title" :label="__('Titel')" />
</flux:field>
<flux:field>
<flux:input wire:model="correctionValues.subtitle" :label="__('Untertitel')" />
</flux:field>
<flux:field>
<flux:label>{{ __('Text') }}</flux:label>
<flux:editor
wire:model="correctionValues.text"
toolbar="heading | bold italic | bullet ordered blockquote | link | undo redo"
/>
</flux:field>
<flux:field>
<flux:input wire:model="correctionValues.keywords" :label="__('Keywords')" />
</flux:field>
<flux:error name="correction" />
<div class="flex flex-wrap gap-2">
<flux:button size="sm" variant="filled" icon="eye" wire:click="previewCorrection">
{{ $correctionPreview === null ? __('Vorschau prüfen (Pflicht)') : __('Vorschau aktualisieren') }}
</flux:button>
@if ($correctionPreview !== null && $correctionPreview['is_minor'])
<flux:button size="sm" variant="primary" icon="check" wire:click="applyCorrection"
wire:confirm="{{ __('Korrektur jetzt speichern? Die Änderung ist sofort öffentlich sichtbar.') }}">
{{ __('Kostenlos speichern — sofort live') }}
</flux:button>
@elseif ($correctionPreview !== null && ! ($correctionPreview['exceeds_paid_limit'] ?? false))
<flux:button size="sm" variant="primary" icon="credit-card" wire:click="openPaidChangeModal">
{{ __('Als kostenpflichtige Änderung einreichen (:price € zzgl. USt.)', ['price' => number_format((int) config('billing.change_price_cents') / 100, 2, ',', '.')]) }}
</flux:button>
@endif
<flux:button size="sm" variant="ghost" wire:click="$set('correctionOpen', false)">
{{ __('Abbrechen') }}
</flux:button>
</div>
@if ($correctionPreview !== null)
<div class="rounded-[6px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] px-3 py-2.5 text-[12.5px] space-y-1">
<p class="font-semibold text-[color:var(--color-ink)] m-0">
@if ($correctionPreview['is_minor'])
{{ __('Einstufung: Kleine Korrektur — kostenlos, wird sofort übernommen.') }}
@elseif ($correctionPreview['exceeds_paid_limit'] ?? false)
{{ __('Einstufung: Zu umfangreich (max. Distanz :percent %, Grenze :limit %) — eine so weitgehende Änderung ist nicht buchbar. Für einen neuen Text nutzen Sie bitte die Depublikation und eine Neuveröffentlichung.', ['percent' => $correctionPreview['max_distance_percent'], 'limit' => (int) config('legal.change_max_distance_percent')]) }}
@else
{{ __('Einstufung: Größere Änderung (max. Distanz :percent %) — kostenpflichtig (:price € zzgl. USt.) nach einmaliger automatischer Prüfung.', ['percent' => $correctionPreview['max_distance_percent'], 'price' => number_format((int) config('billing.change_price_cents') / 100, 2, ',', '.')]) }}
@endif
</p>
@foreach ($correctionPreview['fields'] as $field => $meta)
@continue(! $meta['changed'])
<p class="m-0 text-[color:var(--color-ink-2)]">
{{ $field }}: {{ __('Änderungsdistanz :percent %', ['percent' => $meta['distance_percent']]) }}
</p>
@endforeach
</div>
@endif
</div>
@endif
</div>
</article>
@endif
{{-- ============== DEPUBLIKATION (T12, Meldeverfahren Weg 3) ==============
Bewusst ganz unten (QA-Befund 07.07.): seltener Ausnahme-Service,
soll nicht zwischen Add-ons und Inhalt um Aufmerksamkeit konkurrieren. --}}
@if ($pr->status === PressReleaseStatus::Published && $depublicationPriceCents > 0)
@php
$depubNet = number_format($depublicationPriceCents / 100, 2, ',', '.');
$depubGross = number_format($depublicationPriceCents * (1 + (float) config('billing.vat_rate')) / 100, 2, ',', '.');
@endphp
<article class="panel" id="depublikation-panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Depublikation (Service)') }}</span>
</div>
<div class="p-5 space-y-3">
<div class="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-hub-soft)] text-[color:var(--color-hub)]">
<flux:icon.eye-slash class="size-[18px]" />
</div>
<div class="min-w-0">
<h3 class="text-[14px] font-bold text-[color:var(--color-ink)] m-0">{{ __('Aus der öffentlichen Ansicht entfernen') }}</h3>
<p class="text-[12.5px] text-[color:var(--color-ink-2)] mt-1 mb-0">
{{ __('Freiwillige Entfernung dieser veröffentlichten Mitteilung — die öffentliche URL zeigt danach dauerhaft einen Entfernt-Hinweis. Kopien in Suchmaschinen oder Drittarchiven können zeitweise weiter verfügbar sein. Preis: :net € zzgl. USt. (:gross € inkl. 19 % USt.)', ['net' => $depubNet, 'gross' => $depubGross]) }}
</p>
</div>
</div>
@if ($depublicationRequested)
<div class="rounded-[6px] border border-[color:var(--color-ok)]/30 bg-[color:var(--color-ok-soft)] px-3 py-2 text-[12.5px] text-[color:var(--color-gain-deep)]">
{{ __('Für diese Pressemitteilung liegt bereits ein Depublikations-Vorgang vor.') }}
</div>
@else
<div class="space-y-2">
<flux:switch
align="left"
wire:model.live="depublicationConfirmed"
:label="__('Ich bestelle in Ausübung meiner gewerblichen oder selbständigen beruflichen Tätigkeit (§ 14 BGB) und akzeptiere die AGB.')"
/>
<p class="text-[11.5px] text-[color:var(--color-ink-3)] m-0">
<a href="{{ route('agb', ['edition' => 'de']) }}" target="_blank" rel="noopener" class="underline underline-offset-2 hover:text-[color:var(--color-ink)]">
{{ __('AGB (Version :version)', ['version' => \App\Models\LegalDocument::currentAgbVersion()]) }}
</a>
· {{ __('Gesetzliche Ansprüche (z. B. DSGVO) bleiben davon unberührt und sind immer kostenlos.') }}
</p>
</div>
@if ($depublicationConfirmed)
<flux:button size="sm" variant="primary" icon="lock-closed" href="{{ route('me.checkout.depublication', $pr->id) }}">
{{ __('Kostenpflichtig depublizieren') }}
</flux:button>
@else
<flux:button size="sm" variant="primary" icon="lock-closed" disabled>
{{ __('Kostenpflichtig depublizieren') }}
</flux:button>
@endif
@endif
</div>
</article>
@endif
{{-- ============== MODAL: ANLIEGEN-AUSWAHL (Ändern/Melden) ==============
Vier Wege: Korrektur & Änderung springen zum Panel-Abschnitt,
Depublizierung ebenso, DSGVO öffnet das Schwärzungsformular. --}}
<flux:modal name="pm-concerns" class="w-full max-w-md">
<div class="space-y-5">
<div>
<flux:text class="eyebrow muted">{{ __('Ändern/Melden') }}</flux:text>
<flux:heading size="lg">{{ __('Was möchten Sie tun?') }}</flux:heading>
</div>
<div class="space-y-2">
<button type="button" wire:click="goToCorrection"
class="w-full text-left rounded-[6px] border border-[color:var(--color-bg-rule)] px-4 py-3 hover:bg-[color:var(--color-bg-elev)] transition-colors">
<span class="block text-[13px] font-bold text-[color:var(--color-ink)]">{{ __('Kleine Korrektur') }} <span class="font-normal text-[color:var(--color-ink-3)]">· {{ __('kostenlos') }}</span></span>
<span class="block text-[12.5px] text-[color:var(--color-ink-2)] mt-0.5">{{ __('Tippfehler, Kontaktdaten — ohne neuen Inhalt, sofort live.') }}</span>
</button>
<button type="button" wire:click="goToCorrection"
class="w-full text-left rounded-[6px] border border-[color:var(--color-bg-rule)] px-4 py-3 hover:bg-[color:var(--color-bg-elev)] transition-colors">
<span class="block text-[13px] font-bold text-[color:var(--color-ink)]">{{ __('Inhaltliche Änderung') }} <span class="font-normal text-[color:var(--color-ink-3)]">· {{ number_format((int) config('billing.change_price_cents') / 100, 2, ',', '.') }} € {{ __('zzgl. USt.') }}</span></span>
<span class="block text-[12.5px] text-[color:var(--color-ink-2)] mt-0.5">{{ __('Neuer oder umgeschriebener Text — mit einmaliger automatischer Prüfung vor der Zahlung.') }}</span>
</button>
<button type="button" wire:click="openDsgvoModal"
class="w-full text-left rounded-[6px] border border-[color:var(--color-bg-rule)] px-4 py-3 hover:bg-[color:var(--color-bg-elev)] transition-colors">
<span class="block text-[13px] font-bold text-[color:var(--color-ink)]">{{ __('Entfernung persönlicher Daten (DSGVO)') }} <span class="font-normal text-[color:var(--color-ink-3)]">· {{ __('kostenlos') }}</span></span>
<span class="block text-[12.5px] text-[color:var(--color-ink-2)] mt-0.5">{{ __('Namen und Kontaktdaten natürlicher Personen schwärzen lassen — Prüfung durch unser Team.') }}</span>
</button>
<button type="button" wire:click="goToDepublication"
class="w-full text-left rounded-[6px] border border-[color:var(--color-bg-rule)] px-4 py-3 hover:bg-[color:var(--color-bg-elev)] transition-colors">
<span class="block text-[13px] font-bold text-[color:var(--color-ink)]">{{ __('Depublizierung') }} <span class="font-normal text-[color:var(--color-ink-3)]">· {{ number_format((int) config('billing.depublication_price_cents') / 100, 2, ',', '.') }} € {{ __('zzgl. USt.') }}</span></span>
<span class="block text-[12.5px] text-[color:var(--color-ink-2)] mt-0.5">{{ __('Mitteilung vollständig aus der öffentlichen Ansicht nehmen (Tombstone bleibt).') }}</span>
</button>
</div>
<flux:modal.close>
<flux:button variant="ghost" size="sm">{{ __('Abbrechen') }}</flux:button>
</flux:modal.close>
</div>
</flux:modal>
{{-- ============== MODAL: DSGVO-SCHWÄRZUNG (Spur B, strukturiert) ==============
Wie das Web-Formular (Phase 5): Pflicht-Vorschau, Einreichung ≠
Ausführung — die Umsetzung bleibt beim Admin. --}}
<flux:modal name="pm-dsgvo" class="w-full max-w-lg">
<div class="space-y-5">
<div>
<flux:text class="eyebrow muted">{{ __('Datenschutz (DSGVO)') }}</flux:text>
<flux:heading size="lg">{{ __('Persönliche Daten schwärzen lassen') }}</flux:heading>
</div>
<p class="text-[12.5px] text-[color:var(--color-ink-2)] m-0">
{{ __('Geben Sie an, welche Angaben entfernt werden sollen. Die Umsetzung erfolgt nach Prüfung durch unser Team; die Mitteilung bleibt im Übrigen online.') }}
</p>
<flux:field>
<flux:textarea wire:model="dsgvoTerms" rows="4"
:label="__('Zu entfernende Angaben (ein Begriff pro Zeile, Varianten einzeln)')"
:placeholder="'Max Mustermann&#10;Mustermanns&#10;max@firma.example'" />
</flux:field>
<flux:checkbox wire:model="dsgvoDetachContacts"
:label="__('Hinterlegte Pressekontakt-Daten von dieser Mitteilung entfernen')" />
<flux:checkbox wire:model="dsgvoRemoveImages"
:label="__('Bilder mit Personenbezug entfernen (Auswahl trifft unser Team)')" />
<flux:field>
<flux:textarea wire:model="dsgvoMessage" rows="3"
:label="__('Begründung (Pflicht)')"
:placeholder="__('z. B. Ich bin die betroffene Person und wünsche die Entfernung meines Namens.')" />
</flux:field>
<flux:error name="dsgvo" />
@if ($dsgvoPreview !== null)
<div class="rounded-[6px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] px-3 py-2.5 text-[12.5px] space-y-2 max-h-64 overflow-y-auto">
<p class="font-semibold text-[color:var(--color-ink)] m-0">
{{ trans_choice('Vorschau: Eine Stelle wird ersetzt.|Vorschau: :hits Stellen werden ersetzt.', (int) $dsgvoPreview['total_hits'], ['hits' => (int) $dsgvoPreview['total_hits']]) }}
</p>
@php
$dsgvoFieldLabels = ['title' => __('Titel'), 'subtitle' => __('Untertitel'), 'text' => __('Text'), 'boilerplate_override' => __('Boilerplate'), 'keywords' => __('Keywords')];
@endphp
@forelse ($dsgvoPreview['fields'] as $field => $meta)
<div>
<p class="m-0 text-[12px] font-semibold text-[color:var(--color-ink)]">{{ $dsgvoFieldLabels[$field] ?? $field }} · {{ $meta['hits'] }}×</p>
<p class="m-0 text-[color:var(--color-ink-2)] whitespace-pre-line">{{ \Illuminate\Support\Str::limit(trim(strip_tags((string) $meta['after'])), 400) }}</p>
</div>
@empty
<p class="m-0 text-[color:var(--color-ink-2)]">{{ __('Keine Text-Ersetzungen — Ihre Anfrage betrifft nur Kontaktdaten bzw. Bilder.') }}</p>
@endforelse
</div>
@endif
<div class="flex flex-wrap gap-2">
<flux:button size="sm" variant="filled" icon="eye" wire:click="previewDsgvo">
{{ $dsgvoPreview === null ? __('Vorschau prüfen (Pflicht)') : __('Vorschau aktualisieren') }}
</flux:button>
@if ($dsgvoPreview !== null)
<flux:button size="sm" variant="primary" icon="check" wire:click="submitDsgvo">
{{ __('Verbindlich einreichen') }}
</flux:button>
@endif
<flux:modal.close>
<flux:button size="sm" variant="ghost">{{ __('Abbrechen') }}</flux:button>
</flux:modal.close>
</div>
</div>
</flux:modal>
{{-- ============== MODAL: KOSTENPFLICHTIGE ÄNDERUNG (Spur D, Phase 7) ==============
Zwischenschritt vor der Zahlung: einmalige KI-Vorprüfung der neuen
Fassung. Rot → Ablehnung ohne Zahlung; Gelb/Grün → Checkbox + Stripe. --}}
<flux:modal name="paid-change" class="w-full max-w-md">
@php
$paidChangeNet = number_format((int) config('billing.change_price_cents') / 100, 2, ',', '.');
$paidChangeGross = number_format((int) config('billing.change_price_cents') * (1 + (float) config('billing.vat_rate')) / 100, 2, ',', '.');
@endphp
<div class="space-y-5">
<div>
<flux:text class="eyebrow muted">{{ __('Kostenpflichtige Änderung') }}</flux:text>
<flux:heading size="lg">{{ __('Prüfung & Zahlung') }}</flux:heading>
</div>
@if ($paidChangePrecheck === null)
<div class="rounded-[6px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-4 text-[12.5px] leading-[1.6] text-[color:var(--color-ink-2)] space-y-2">
<p class="m-0">
{{ __('Ihre geänderte Fassung wird jetzt einmalig automatisch geprüft — wie beim Freigabelauf einer neuen Pressemitteilung.') }}
</p>
<ul class="m-0 pl-4 list-disc space-y-1">
<li>{{ __('Ergebnis Rot: Die Einreichung wird direkt abgelehnt, es entstehen keine Kosten.') }}</li>
<li>{{ __('Ergebnis Gelb oder Grün: Sie gelangen weiter zur Zahlung (:net € zzgl. USt., :gross € inkl. 19 % USt.). Nach Zahlungseingang geht die Änderung sofort live.', ['net' => $paidChangeNet, 'gross' => $paidChangeGross]) }}</li>
</ul>
<p class="m-0 font-semibold text-[color:var(--color-ink)]">
{{ __('Dieser Vorgang ist je Pressemitteilung nur einmal möglich.') }}
</p>
</div>
<div class="flex gap-2">
<flux:button variant="primary" icon="shield-check" wire:click="runPaidChangePrecheck">
<span wire:loading.remove wire:target="runPaidChangePrecheck">{{ __('Prüfung jetzt starten') }}</span>
<span wire:loading wire:target="runPaidChangePrecheck">{{ __('Prüfung läuft …') }}</span>
</flux:button>
<flux:modal.close>
<flux:button variant="ghost">{{ __('Abbrechen') }}</flux:button>
</flux:modal.close>
</div>
@elseif ($paidChangePrecheck['classification'] === 'red')
<div class="rounded-[6px] border-l-[3px] border border-[color:var(--color-err)] bg-[color:var(--color-err-soft)] p-4 text-[12.5px] leading-[1.6] text-[color:var(--color-ink)]">
<p class="m-0 font-bold">{{ __('Prüfung nicht bestanden — die Einreichung wurde abgelehnt.') }}</p>
<p class="m-0 mt-1 text-[color:var(--color-ink-2)]">
{{ __('Es entstehen keine Kosten. Die geänderte Fassung verstößt gegen unsere Inhalts-Richtlinien und kann nicht veröffentlicht werden.') }}
</p>
@if ($paidChangePrecheck['reasons'] !== [])
<ul class="m-0 mt-2 pl-4 list-disc space-y-1 text-[color:var(--color-ink-2)]">
@foreach ($paidChangePrecheck['reasons'] as $reason)
<li>{{ $reason }}</li>
@endforeach
</ul>
@endif
</div>
<flux:modal.close>
<flux:button variant="ghost">{{ __('Schließen') }}</flux:button>
</flux:modal.close>
@else
<div class="rounded-[6px] border-l-[3px] border border-[color:var(--color-ok)] bg-[color:var(--color-ok-soft)] p-4 text-[12.5px] leading-[1.6] text-[color:var(--color-ink)]">
<p class="m-0 font-bold">{{ __('Prüfung bestanden.') }}</p>
<p class="m-0 mt-1 text-[color:var(--color-ink-2)]">
{{ __('Nach Zahlungseingang (:net € zzgl. USt., :gross € inkl. 19 % USt.) wird die geprüfte Fassung sofort übernommen und ist öffentlich sichtbar.', ['net' => $paidChangeNet, 'gross' => $paidChangeGross]) }}
</p>
</div>
<div class="space-y-2">
<flux:switch
align="left"
wire:model.live="changeConfirmed"
:label="__('Ich bestelle in Ausübung meiner gewerblichen oder selbständigen beruflichen Tätigkeit (§ 14 BGB) und akzeptiere die AGB.')"
/>
<p class="text-[11.5px] text-[color:var(--color-ink-3)] m-0">
<a href="{{ route('agb', ['edition' => 'de']) }}" target="_blank" rel="noopener" class="underline underline-offset-2 hover:text-[color:var(--color-ink)]">
{{ __('AGB (Version :version)', ['version' => \App\Models\LegalDocument::currentAgbVersion()]) }}
</a>
</p>
</div>
@if ($changeConfirmed)
<flux:button variant="primary" icon="credit-card" href="{{ route('me.checkout.change', $pr->id) }}">
{{ __('Kostenpflichtig ändern') }}
</flux:button>
@else
<flux:button variant="primary" icon="credit-card" disabled>
{{ __('Kostenpflichtig ändern') }}
</flux:button>
@endif
@endif
</div>
</flux:modal>
{{-- ============== MODAL: ADD-ON-KAUF BESTÄTIGEN ==============
Vorgeschaltete Bestätigung (Entscheidung 10.07.): kein Add-on-Klick
bucht direkt Credits ab — erst „Kostenpflichtig buchen" kauft. --}}
<flux:modal name="confirm-addon-purchase" class="w-full max-w-md">
<div class="space-y-5">
<div>
<flux:text class="eyebrow muted">{{ __('Add-on') }}</flux:text>
<flux:heading size="lg">{{ __('Kauf bestätigen') }}</flux:heading>
</div>
@if ($pendingPurchase)
@if ($pendingPurchase['extendsUntil'])
{{-- Verlängerungs-Hinweis: für diese PM läuft bereits ein Boost. --}}
<div class="rounded-[6px] border-l-[3px] border border-[color:var(--color-warn)] bg-[color:var(--color-warn-soft)] p-4 text-[12.5px] leading-[1.6] text-[color:var(--color-ink)]">
<p class="m-0 font-bold">{{ __('Diese Buchung verlängert den laufenden Boost.') }}</p>
<p class="m-0 mt-1 text-[color:var(--color-ink-2)]">
{{ __('Aktuell aktiv bis :until — die neue Laufzeit schließt daran an und endet am :end.', [
'until' => $pendingPurchase['extendsUntil'],
'end' => $pendingPurchase['newEndsAt'],
]) }}
</p>
</div>
@endif
<div class="rounded-[6px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-4 text-[12.5px] leading-[1.6] text-[color:var(--color-ink-2)]">
<div class="flex items-center justify-between gap-3">
<span>{{ $pendingPurchase['label'] }}</span>
<span class="font-bold whitespace-nowrap text-[color:var(--color-ink)]">{{ __(':n Credits', ['n' => $pendingPurchase['credits']]) }}</span>
</div>
<p class="m-0 mt-3 pt-3 border-t border-[color:var(--color-bg-rule)] text-[11.5px] text-[color:var(--color-ink-3)]">
@if ($creditBalance >= $pendingPurchase['credits'])
{{ __('Der Betrag wird sofort von Ihrem Guthaben abgebucht: :available Credits → :rest Credits.', [
'available' => $creditBalance,
'rest' => $creditBalance - $pendingPurchase['credits'],
]) }}
@else
{{ __('Ihr Guthaben (:available Credits) reicht dafür nicht aus — es fehlen :missing Credits.', [
'available' => $creditBalance,
'missing' => $pendingPurchase['credits'] - $creditBalance,
]) }}
@endif
</p>
</div>
<flux:switch wire:model.live="purchaseConfirmed"
label="{{ __('Ich bestätige die kostenpflichtige Buchung über :n Credits.', ['n' => $pendingPurchase['credits']]) }}" />
@endif
<div class="flex justify-end gap-2">
<flux:modal.close>
<flux:button variant="filled">{{ __('Abbrechen') }}</flux:button>
</flux:modal.close>
<flux:button variant="primary" wire:click="buyPendingAddOn" wire:loading.attr="disabled"
:disabled="! $purchaseConfirmed">
{{ __('Kostenpflichtig buchen') }}
</flux:button>
</div>
</div>
</flux:modal>
{{-- ============== MODAL: NICHT GENÜGEND CREDITS ==============
Öffnet sich serverseitig, wenn ein Add-on-Kauf (Boost/Nachweis)
am Wallet-Guthaben scheitert — mit Direktlink zum Aufladen. --}}
<flux:modal name="insufficient-credits" class="w-full max-w-md">
<div class="space-y-5">
<div>
<flux:text class="eyebrow muted">{{ __('Credit-Wallet') }}</flux:text>
<flux:heading size="lg">{{ __('Nicht genügend Credits') }}</flux:heading>
</div>
@if ($creditShortfall)
<div class="rounded-[6px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-4 text-[12.5px] leading-[1.6] text-[color:var(--color-ink-2)]">
<p class="m-0">
{{ __(':label kostet :required Credits — Ihr Guthaben beträgt :available Credits. Es fehlen :missing Credits.', [
'label' => $creditShortfall['label'],
'required' => $creditShortfall['required'],
'available' => $creditShortfall['available'],
'missing' => max(0, $creditShortfall['required'] - $creditShortfall['available']),
]) }}
</p>
<p class="m-0 mt-3 pt-3 border-t border-[color:var(--color-bg-rule)] text-[11.5px] text-[color:var(--color-ink-3)]">
{{ __('Guthaben laden Sie unter „Buchungen & Add-ons" auf — es steht sofort zur Verfügung (1 Credit = 1 €, größere Pakete enthalten Bonus-Credits).') }}
</p>
</div>
@endif
<div class="flex justify-end gap-2">
<flux:modal.close>
<flux:button variant="filled">{{ __('Schließen') }}</flux:button>
</flux:modal.close>
<flux:button variant="primary" icon="wallet" href="{{ route('me.bookings.index') }}#credits" wire:navigate>
{{ __('Credits aufladen') }}
</flux:button>
</div>
</div>
</flux:modal>
</div>