- 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>
2345 lines
129 KiB
PHP
2345 lines
129 KiB
PHP
<?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 Mustermanns 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>
|