Bearbeitbare Feld-Werte der Korrektur. */ public array $correctionValues = []; /** @var array{fields: array, 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}|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, 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; public function confirmImageRights(CustomerImageConfirmation $confirmation, ImageRightsTextRenderer $texts): void { $pr = $this->getMyPR(); $this->authorize('update', $pr); $image = $pr->images()->with('latestRightsConfirmation')->orderByDesc('is_preview')->orderBy('sort_order')->orderBy('id')->first(); if ($image === null || ! $image->awaitsCustomerConfirmation()) { return; } $this->validate( ['imageRightsConfirmed' => ['accepted']], ['imageRightsConfirmed.accepted' => __('Die Bestätigung ist erforderlich, damit das Bild veröffentlicht werden kann.')], ); try { $confirmation->confirm( $image, auth()->user(), $texts->current()?->key, request()->ip(), request()->userAgent(), route('agb', ['edition' => 'de']), ); } 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'); } 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(); $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

/
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 */ 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, 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(); $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 = $pr->images()->with('latestRightsConfirmation')->orderByDesc('is_preview')->orderBy('sort_order')->orderBy('id')->first(), 'awaitsImageConfirmation' => $titleImage?->awaitsCustomerConfirmation() === true && auth()->user()->can('update', $pr), 'imageLegalShort' => ($imageLegalText = app(ImageRightsTextRenderer::class)->current()) ? 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); } }; ?>

@php $statusClass = $pr->status->badgeClass(); @endphp {{-- Flash-Banner ersetzt durch im Layout. --}} {{-- ============== PAGE HEADER ============== --}} {{-- ============== SHARE-LINK ERFOLG ============== --}} @if ($shareUrl)
{{ __('Öffentlicher Vorschau-Link erstellt') }} {{ __('gültig bis :date', ['date' => $shareExpiresAt]) }}
@if ($pr->portal === \App\Enums\Portal::Both) {{-- PM erscheint auf beiden Portalen: Vorschau je Portal öffnen --}} {{ __('Vorschau presseecho') }} {{ __('Vorschau businessportal24') }} @else {{ __('Vorschau öffnen') }} @endif
@endif {{-- ============== REJECTION-HINWEIS ============== --}} @if ($pr->status === PressReleaseStatus::Rejected && $latestRejection)
{{ __('Diese Pressemitteilung wurde abgelehnt') }} {{ __('Handlung erforderlich') }}
@if ($latestRejection->reason) {{ __('Begründung') }}: {{ $latestRejection->reason }} @else {{ __('Bitte überarbeiten Sie den Inhalt und reichen Sie die Pressemitteilung erneut ein.') }} @endif {{ __('Abgelehnt am') }} {{ $latestRejection->created_at->format('d.m.Y H:i') }} @php $maxAttempts = max(1, (int) config('scoring.classification.max_automatic_attempts', 3)); @endphp @if ($pr->requiresManualReview()) {{ __('Für diese Mitteilung entscheidet ab jetzt ein Mensch: Nach der nächsten Freigabe prüft die Redaktion direkt, ohne automatische Vorprüfung.') }} @elseif ($pr->auto_check_attempts > 0) {{ __('Automatische Prüfung :used von :max genutzt. Bleibt der Text danach Rot, prüft ein Mensch.', ['used' => $pr->auto_check_attempts, 'max' => $maxAttempts]) }} {{ __('Was eine gute Pressemitteilung ausmacht') }} @endif
@endif {{-- ============== STATUS-WORKFLOW (oben, farblich abgehoben) ============== --}} @if ($pr->status === PressReleaseStatus::Draft || $pr->status === PressReleaseStatus::Rejected)
{{ __('Status-Workflow') }} status === PressReleaseStatus::Rejected ? 'err' : 'hub'])> {{ $pr->status === PressReleaseStatus::Rejected ? __('Überarbeiten') : __('Entwurf') }}
@unless ($hasActiveBooking) {{-- Veröffentlichungs-Gate: Einreichen zur Prüfung setzt eine bezahlte Option voraus. Speichern als Entwurf bleibt frei. --}}
{{ __('Noch keine Buchung.') }} {{ __('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).') }}
@endunless

{{ $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.') }}

@if ($canEdit) {{ __('Bearbeiten') }} @endif @if ($hasActiveBooking) {{ $pr->status === PressReleaseStatus::Rejected ? __('Erneut zur Prüfung freigeben') : __('Zur Prüfung freigeben') }} @else {{ __('Buchung wählen') }} @endif
@endif @if ($pr->status === PressReleaseStatus::Published)
{{ __('Status-Workflow') }} {{ __('Live') }}

{{ __('Diese Pressemitteilung ist veröffentlicht (seit :date).', ['date' => $pr->published_at?->format('d.m.Y H:i') ?? '–']) }}

@endif {{-- ============== ADD-ONS (Boost + Veröffentlichungsnachweis) ============== --}} @if ($pr->status === PressReleaseStatus::Published)
{{ __('Add-ons') }} {{ __(':n Credits Guthaben', ['n' => $creditBalance]) }}
{{-- Boost-Stufen: Startseiten-Boost + Kategorie-Boost (13.07.) --}} @foreach ($boostAddOns as $placementValue => $addOn)
@if ($placementValue === 'home') @else @endif

{{ $addOn['label'] }}

{{ $addOn['description'] }}

@if ($addOn['isBoosted'])
{{ __('Aktiv bis :date', ['date' => $addOn['activeUntil']?->format('d.m.Y H:i')]) }}
@endif @if ($canBoost && $addOn['available'])
@foreach ($addOn['options'] as $days => $credits) {{ __(':days Tage für :credits Credits buchen', ['days' => $days, 'credits' => $credits]) }} @endforeach
@if ($addOn['isBoosted'])

{{ __('Ein weiterer Kauf verlängert die Laufzeit.') }}

@else {{-- Slot-Transparenz (13.07.): knappe Plätze sichtbar machen. --}}

{{ __('Noch :free von :limit Plätzen frei.', ['free' => $addOn['free'], 'limit' => $addOn['limit']]) }}

@endif @elseif ($canBoost) {{-- Slot-Inventar erschöpft: ehrlich ausweisen statt verkaufen (13.07.). --}}
@if ($addOn['freeAt']) {{ __('Derzeit ausgebucht — der nächste Platz wird am :date frei.', ['date' => $addOn['freeAt']->format('d.m.Y')]) }} @else {{ __('Derzeit ausgebucht.') }} @endif
@else

{{ __('Nur grün geprüfte Pressemitteilungen sind boostbar.') }}

@endif
@endforeach {{-- Veröffentlichungsnachweis --}}

{{ __('Veröffentlichungsnachweis') }}

{{ __('PDF mit Datum, Portal und URL — :credits Credits.', ['credits' => $proofPrice]) }}

@if ($proofPurchased) {{ __('Nachweis herunterladen') }} @else {{ __('Für :credits Credits kaufen', ['credits' => $proofPrice]) }} @endif
@endif @if ($pr->status === PressReleaseStatus::Review)
{{ __('Status-Workflow') }} {{ __('Geduld bitte') }}
@if ($pr->isAwaitingManualReview()) {{-- E3: Automatik erschöpft — ein Mensch prüft. Kein Termin versprechen, den wir nicht halten. --}}

{{ __('Ihre Pressemitteilung wird von einem Menschen geprüft.') }}

{{ __('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.') }}

@else

{{ __('Ihre Pressemitteilung wird gerade geprüft. Sie werden benachrichtigt, sobald eine Entscheidung vorliegt.') }}

@endif
@endif {{-- ============== KONTAKTE + STATUS/VERLAUF ============== --}}
{{ __('Firma & Pressekontakt') }}
{{-- Firma der Pressemitteilung — gleiche Kachel wie in der Firmenübersicht --}} @if ($pr->company)
@if ($pr->company->is_active) {{ __('Aktiv') }} @else {{ __('Inaktiv') }} @endif

{{ $pr->company->name }}

@if (filled($companyMetaLine))
{{ $companyMetaLine }}
@endif
@if ($pr->company->email) {{ $pr->company->email }} @endif @if ($pr->company->phone) {{ $pr->company->phone }} @endif
@if ($pr->company->portal === \App\Enums\Portal::Both) presseecho businessportal24 @elseif ($pr->company->portal === \App\Enums\Portal::Presseecho) presseecho @elseif ($pr->company->portal === \App\Enums\Portal::Businessportal24) businessportal24 @endif
{{ number_format($pr->company->press_releases_count ?? 0, 0, ',', '.') }} {{ __('PMs') }}
{{ number_format($pr->company->contacts_count ?? 0, 0, ',', '.') }} {{ __('Kontakte') }}
@endif {{-- Pressekontakt(e) direkt darunter --}}
@forelse ($contacts as $contact)
{{ $contact->displayName() }}
{{ $contact->responsibility ?: __('Keine Rolle hinterlegt') }}
@if ($contact->email) {{ $contact->email }} @endif @if ($contact->phone) {{ $contact->phone }} @endif
@empty
{{ __('Dieser Pressemitteilung ist noch kein Pressekontakt zugeordnet.') }} @if ($pr->company) {{ __('Kontakte in der Firma prüfen.') }} @endif
@endforelse
{{ __('Status & Verlauf') }} {{ $pr->status->label() }}
{{ __('Aktueller Status') }}
{{ $pr->status->label() }}
{{-- Ergebnis der KI-Inhaltsprüfung (13.07.): erklärt sichtbar, warum die Boost-Platzierung (nur Grün) verfügbar ist oder nicht. --}}
{{ __('Inhaltsprüfung') }}
@if ($pr->classification) {{ $pr->classification->label() }} @else {{ __('Ausstehend') }} @endif
@if ($pr->classification === \App\Enums\PressReleaseClassification::Yellow)
{{ __('Die Boost-Platzierung ist grün geprüften Meldungen vorbehalten.') }}
@endif
{{ __('Erstellt') }}
{{ $pr->created_at?->format('d.m.Y H:i') ?? '–' }}
{{ __('Veröffentlicht') }}
{{ $pr->published_at?->format('d.m.Y H:i') ?? '–' }}
@if ($visibility === null)
{{ __('Aufrufe') }}
{{ __('noch nicht veröffentlicht') }}
@endif @if ($pr->scheduled_at)
{{ __('Geplante Veröffentlichung') }}
{{ $pr->scheduledAtLocal()->format('d.m.Y H:i') }}
@endif @if ($pr->embargo_at)
{{ __('Sperrfrist bis') }}
{{ $pr->embargoAtLocal()->format('d.m.Y H:i') }}
@endif
{{ __('Portal') }}
{{ $pr->portal?->label() ?? '–' }} {{ strtoupper($pr->language) }}
{{ __('Kategorie') }}
{{ $categoryName }}
@if (filled($pr->keywords))
{{ __('Themen') }}
@foreach (array_filter(array_map('trim', explode(',', $pr->keywords))) as $keyword) {{ $keyword }} @endforeach
@endif @if ($pr->backlink_url)
{{ __('Backlink') }}
{{ $pr->backlink_url }}
@endif @if ($pr->no_export)
{{ __('Kein Export aktiv (PM wird nicht über Feeds verteilt).') }}
@endif
@if ($statusLogs->isNotEmpty())
    @foreach ($statusLogs as $log)
  1. @php $logClass = $log->to_status?->badgeClass() ?? 'muted'; @endphp {{ $log->to_status?->label() }} {{ $log->created_at->format('d.m.Y H:i') }} @if ($log->changedBy) {{ __('durch :name', ['name' => $log->changedBy->name]) }} @endif
    @if ($log->reason)

    {{ $log->reason }}

    @endif
  2. @endforeach
@else

{{ __('Noch keine Statusänderungen protokolliert.') }}

@endif
{{-- ============== 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
{{ __('Auslieferung') }}
@foreach ($viewRanges as $days) @endforeach
{{ __('Aufrufe gesamt') }}
{{ number_format($visibility['hits'], 0, ',', '.') }}
{{ __('seit Veröffentlichung') }}
{{ __('Aufrufe :days Tage', ['days' => $viewRange]) }}
{{ number_format($rangeSum, 0, ',', '.') }}
@if ($visibility['last_view_at']) {{ __('zuletzt :date', ['date' => $visibility['last_view_at']->format('d.m.Y')]) }} @else {{ __('noch kein gemessener Aufruf') }} @endif
{{ __('Geteilt') }}
{{ number_format($visibility['shares_total'], 0, ',', '.') }}
@if ($visibility['shares'] !== [])
{{ collect($visibility['shares'])->map(fn ($n, $label) => $label.' '.$n)->implode(' · ') }}
@endif
{{ __('Aufrufe je Tag') }} {{ __('Spitze :n', ['n' => number_format($peak, 0, ',', '.')]) }}
@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 $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])> {{ $day['date']->format('d.m.Y') }}: {{ $day['views'] === null ? __('keine Messung') : trans_choice(':n Aufruf|:n Aufrufe', $day['views'], ['n' => $day['views']]) }} @endforeach
{{ $dailyViews[0]['date']->format('d.m.') }} {{ __('heute') }}
@if (collect($dailyViews)->contains(fn ($day) => $day['views'] === null))

{{ __('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')]) }}

@endif
{{ __('Sitemap für Suchmaschinen') }}
@if ($visibility['in_sitemap_since']) {{ __('Gelistet seit :date — Veröffentlichung und Sitemap-Eintrag fallen zusammen.', ['date' => $visibility['in_sitemap_since']->format('d.m.Y')]) }} @else {{ __('Derzeit nicht gelistet — die Meldung ist nicht öffentlich.') }} @endif
{{-- RSS-Feeds (Roadmap 3.1): Branchen- und Portal-Feed je Portal. --}}
{{ __('RSS-Feeds') }}
@if (($visibility['feeds'] ?? []) !== [])
    @foreach ($visibility['feeds'] as $feed)
  • {{ $feed['label'] }} {{ trans_choice(':n Abruf|:n Abrufe', $feed['fetches_30d'] ?? 0, ['n' => number_format($feed['fetches_30d'] ?? 0, 0, ',', '.')]) }} · {{ __('30 Tage') }}
  • @endforeach

{{ __('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]) }}

@else

{{ __('Derzeit in keinem Feed — die Meldung ist nicht öffentlich.') }}

@endif
{{-- 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. --}}
{{ __('Abrufe durch Such- und KI-Dienste') }}
@if ($visibility['crawlers'] !== [])
    @foreach ($visibility['crawlers'] as $crawler)
  • {{ $crawler['label'] }} {{ $crawler['kind'] === 'search' ? __('Suche') : __('KI') }} {{ __('zuletzt :date', ['date' => $crawler['last_at']->format('d.m.Y')]) }} · {{ trans_choice(':n Abruf|:n Abrufe', $crawler['fetches'], ['n' => number_format($crawler['fetches'], 0, ',', '.')]) }}
  • @endforeach

{{ __('Ein Abruf belegt, dass der Crawler des Dienstes die Seite geholt hat — nicht, dass sie dort angezeigt oder zitiert wird.') }}

@else

{{ __('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

@endif
{{-- Indexierungsstatus (Roadmap 1c.4) — Kundenansicht per config search_console.customer_visible (E8), sonst leer. --}} @if ($indexChecks !== [])
{{ __('Google-Index') }}
    @foreach ($indexChecks as $check) @php $source = app(IndexStatusChecker::class)->sourceVerdict($check); @endphp
  • {{ $check->portal->label() }}: {{ $check->state->label() }} {{ __('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
    {{-- Quellenauskunft (Anhang 2, 1c.9): welche Adresse Google als maßgeblich führt — ohne Aussage darüber, warum. --}} @if ($source['kind'] === 'self')
    {{ __('Google führt diese Adresse als maßgebliche Quelle.') }}
    @elseif ($source['kind'] === 'own_other')
    {{ __('Google führt die Fassung auf :host als maßgebliche Quelle; diese Adresse gilt als Kopie.', ['host' => $source['host']]) }}
    @elseif ($source['kind'] === 'foreign')
    {{ __('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']]) }}
    @endif
  • @endforeach

{{ __('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.') }}

@endif
@endif {{-- Content-Score wie im Editor (gemeinsame Komponente, 13.07.). --}} @if (! is_null($pr->content_score)) @endif
{{-- ============== 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. --}}
{{ $pr->title }}
@if ($coverIsPlaceholder)
{{ __('Platzhalter-Titelbild — laden Sie im Editor ein eigenes Bild hoch.') }}
@elseif ($titleImage && ($titleImage->copyright || $titleImage->is_ai_generated || $titleImage->ai_declaration?->publicLabel())) {{-- Bildnachweis + KI-Kennzeichnung (Art. 50 EU AI Act): kurzes Badge („KI-generiert" / „KI-bearbeitet"), kein Satz-Wortlaut. --}}
@if ($aiImageBadge = $titleImage->ai_declaration?->publicLabel() ?? ($titleImage->is_ai_generated ? __('KI-generiert') : null)) {{ $aiImageBadge }} @endif @if ($titleImage->copyright) {{ $titleImage->copyright }} @endif
@endif
{{-- ============== BILD BESTÄTIGEN (Admin-Upload, E10) ============== --}} @if ($awaitsImageConfirmation)
{{ __('Titelbild bestätigen') }}

{{ __('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.') }}

{{ __('Bildnachweis') }}
{{ $titleImage->copyright ?: '—' }}
{{ __('KI-Kennzeichnung') }}
{{ $titleImage->ai_declaration?->label() ?? '—' }}
@if ($imageLegalLong)
{{ __('Vollständiger Wortlaut') }}
{{ $imageLegalLong }}
@endif
{{ __('Bildrechte bestätigen') }}
@endif {{-- ============== INHALT ============== --}}
{{ __('Inhalt') }}
{{-- KI-Label (Art. 50 KI-VO): erscheint so auch in der Veröffentlichung — am Anfang des Inhalts, in Einreicher-Stimme. --}}
{!! $pr->renderedText() !!}
{{-- ============== VERÖFFENTLICHUNGS-MODAL ============== --}} @if ($pr->status === PressReleaseStatus::Draft || $pr->status === PressReleaseStatus::Rejected) @endif {{-- ============== BOILERPLATE-OVERRIDE ============== --}} @if ($pr->boilerplate_override)
{{ __('Eigener Abbinder (Boilerplate)') }} {{ __('Override') }}

{{ __('Dieser Text wird für diese Pressemitteilung anstelle des Standard-Abbinders der Firma verwendet.') }}

{{ $pr->boilerplate_override }}
@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)
{{ __('Änderung & Korrektur') }}

{{ __('Veröffentlichte Mitteilung korrigieren oder ändern') }}

{{ __('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')]) }}

@if (! $correctionOpen) {{ __('Korrektur/Änderung starten') }} @else
{{ __('Text') }}
{{ $correctionPreview === null ? __('Vorschau prüfen (Pflicht)') : __('Vorschau aktualisieren') }} @if ($correctionPreview !== null && $correctionPreview['is_minor']) {{ __('Kostenlos speichern — sofort live') }} @elseif ($correctionPreview !== null && ! ($correctionPreview['exceeds_paid_limit'] ?? false)) {{ __('Als kostenpflichtige Änderung einreichen (:price € zzgl. USt.)', ['price' => number_format((int) config('billing.change_price_cents') / 100, 2, ',', '.')]) }} @endif {{ __('Abbrechen') }}
@if ($correctionPreview !== null)

@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

@foreach ($correctionPreview['fields'] as $field => $meta) @continue(! $meta['changed'])

{{ $field }}: {{ __('Änderungsdistanz :percent %', ['percent' => $meta['distance_percent']]) }}

@endforeach
@endif
@endif
@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
{{ __('Depublikation (Service)') }}

{{ __('Aus der öffentlichen Ansicht entfernen') }}

{{ __('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]) }}

@if ($depublicationRequested)
{{ __('Für diese Pressemitteilung liegt bereits ein Depublikations-Vorgang vor.') }}
@else

{{ __('AGB (Version :version)', ['version' => \App\Models\LegalDocument::currentAgbVersion()]) }} · {{ __('Gesetzliche Ansprüche (z. B. DSGVO) bleiben davon unberührt und sind immer kostenlos.') }}

@if ($depublicationConfirmed) {{ __('Kostenpflichtig depublizieren') }} @else {{ __('Kostenpflichtig depublizieren') }} @endif @endif
@endif {{-- ============== MODAL: ANLIEGEN-AUSWAHL (Ändern/Melden) ============== Vier Wege: Korrektur & Änderung springen zum Panel-Abschnitt, Depublizierung ebenso, DSGVO öffnet das Schwärzungsformular. --}}
{{ __('Ändern/Melden') }} {{ __('Was möchten Sie tun?') }}
{{ __('Abbrechen') }}
{{-- ============== MODAL: DSGVO-SCHWÄRZUNG (Spur B, strukturiert) ============== Wie das Web-Formular (Phase 5): Pflicht-Vorschau, Einreichung ≠ Ausführung — die Umsetzung bleibt beim Admin. --}}
{{ __('Datenschutz (DSGVO)') }} {{ __('Persönliche Daten schwärzen lassen') }}

{{ __('Geben Sie an, welche Angaben entfernt werden sollen. Die Umsetzung erfolgt nach Prüfung durch unser Team; die Mitteilung bleibt im Übrigen online.') }}

@if ($dsgvoPreview !== null)

{{ trans_choice('Vorschau: Eine Stelle wird ersetzt.|Vorschau: :hits Stellen werden ersetzt.', (int) $dsgvoPreview['total_hits'], ['hits' => (int) $dsgvoPreview['total_hits']]) }}

@php $dsgvoFieldLabels = ['title' => __('Titel'), 'subtitle' => __('Untertitel'), 'text' => __('Text'), 'boilerplate_override' => __('Boilerplate'), 'keywords' => __('Keywords')]; @endphp @forelse ($dsgvoPreview['fields'] as $field => $meta)

{{ $dsgvoFieldLabels[$field] ?? $field }} · {{ $meta['hits'] }}×

{{ \Illuminate\Support\Str::limit(trim(strip_tags((string) $meta['after'])), 400) }}

@empty

{{ __('Keine Text-Ersetzungen — Ihre Anfrage betrifft nur Kontaktdaten bzw. Bilder.') }}

@endforelse
@endif
{{ $dsgvoPreview === null ? __('Vorschau prüfen (Pflicht)') : __('Vorschau aktualisieren') }} @if ($dsgvoPreview !== null) {{ __('Verbindlich einreichen') }} @endif {{ __('Abbrechen') }}
{{-- ============== 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. --}} @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
{{ __('Kostenpflichtige Änderung') }} {{ __('Prüfung & Zahlung') }}
@if ($paidChangePrecheck === null)

{{ __('Ihre geänderte Fassung wird jetzt einmalig automatisch geprüft — wie beim Freigabelauf einer neuen Pressemitteilung.') }}

  • {{ __('Ergebnis Rot: Die Einreichung wird direkt abgelehnt, es entstehen keine Kosten.') }}
  • {{ __('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]) }}

{{ __('Dieser Vorgang ist je Pressemitteilung nur einmal möglich.') }}

{{ __('Prüfung jetzt starten') }} {{ __('Prüfung läuft …') }} {{ __('Abbrechen') }}
@elseif ($paidChangePrecheck['classification'] === 'red')

{{ __('Prüfung nicht bestanden — die Einreichung wurde abgelehnt.') }}

{{ __('Es entstehen keine Kosten. Die geänderte Fassung verstößt gegen unsere Inhalts-Richtlinien und kann nicht veröffentlicht werden.') }}

@if ($paidChangePrecheck['reasons'] !== [])
    @foreach ($paidChangePrecheck['reasons'] as $reason)
  • {{ $reason }}
  • @endforeach
@endif
{{ __('Schließen') }} @else

{{ __('Prüfung bestanden.') }}

{{ __('Nach Zahlungseingang (:net € zzgl. USt., :gross € inkl. 19 % USt.) wird die geprüfte Fassung sofort übernommen und ist öffentlich sichtbar.', ['net' => $paidChangeNet, 'gross' => $paidChangeGross]) }}

@if ($changeConfirmed) {{ __('Kostenpflichtig ändern') }} @else {{ __('Kostenpflichtig ändern') }} @endif @endif
{{-- ============== MODAL: ADD-ON-KAUF BESTÄTIGEN ============== Vorgeschaltete Bestätigung (Entscheidung 10.07.): kein Add-on-Klick bucht direkt Credits ab — erst „Kostenpflichtig buchen" kauft. --}}
{{ __('Add-on') }} {{ __('Kauf bestätigen') }}
@if ($pendingPurchase) @if ($pendingPurchase['extendsUntil']) {{-- Verlängerungs-Hinweis: für diese PM läuft bereits ein Boost. --}}

{{ __('Diese Buchung verlängert den laufenden Boost.') }}

{{ __('Aktuell aktiv bis :until — die neue Laufzeit schließt daran an und endet am :end.', [ 'until' => $pendingPurchase['extendsUntil'], 'end' => $pendingPurchase['newEndsAt'], ]) }}

@endif
{{ $pendingPurchase['label'] }} {{ __(':n Credits', ['n' => $pendingPurchase['credits']]) }}

@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

@endif
{{ __('Abbrechen') }} {{ __('Kostenpflichtig buchen') }}
{{-- ============== MODAL: NICHT GENÜGEND CREDITS ============== Öffnet sich serverseitig, wenn ein Add-on-Kauf (Boost/Nachweis) am Wallet-Guthaben scheitert — mit Direktlink zum Aufladen. --}}
{{ __('Credit-Wallet') }} {{ __('Nicht genügend Credits') }}
@if ($creditShortfall)

{{ __(':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']), ]) }}

{{ __('Guthaben laden Sie unter „Buchungen & Add-ons" auf — es steht sofort zur Verfügung (1 Credit = 1 €, größere Pakete enthalten Bonus-Credits).') }}

@endif
{{ __('Schließen') }} {{ __('Credits aufladen') }}