presseportale/resources/views/livewire/customer/press-releases/show.blade.php
Kevin Adametz 28de92e18b PM-Detailseite: Direktlinks zur öffentlichen Seite je Portal
Veröffentlichte PMs zeigten im Backend nur den Status-Badge, ohne Weg
zur öffentlichen Seite. Im Seitenkopf stehen jetzt Direktlinks — bei
Portal::Both einer je Portal (QA-Befund 07.07.).

Die URL-Logik ist als PressRelease::publicPortalLinks() zentralisiert
(vorher doppelt in der Published-Mail und fehlerhaft im Nachweis-PDF:
dort fehlte das /de/press-release/-Segment, die dokumentierte URL lief
ins Leere; bei Both nennt der Nachweis jetzt beide URLs). Edition folgt
der PM-Sprache statt hart „de".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 15:00:40 +00:00

998 lines
52 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
use App\Enums\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\SinglePurchase;
use App\Services\Auth\MagicLinkGenerator;
use App\Services\Billing\CreditPricingService;
use App\Services\Billing\ProofPdfService;
use App\Services\PressRelease\BlacklistViolationException;
use App\Services\PressRelease\BookingRequiredException;
use App\Services\PressRelease\BoostService;
use App\Services\PressRelease\PressReleaseCoverImage;
use App\Services\PressRelease\PressReleaseService;
use App\Services\PressRelease\QuotaExceededException;
use Flux\Flux;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Title;
use Livewire\Volt\Component;
new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends Component
{
#[Locked]
public int $id;
public ?string $shareUrl = null;
public ?string $shareExpiresAt = null;
public bool $depublicationConfirmed = false;
/**
* 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;
public function mount(int $id): void
{
$this->id = $id;
$pr = $this->getMyPR();
$this->authorize('view', $pr);
}
public function submitForReview(): void
{
$pr = $this->getMyPR();
$this->authorize('submitForReview', $pr);
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 (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 Inhaltsprüfung ist typischerweise innerhalb von 24 h 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');
}
/**
* Boost (Platzierung) aus der Wallet buchen — nur veröffentlichte, grüne
* PMs sind boostbar (Gate im BoostService).
*/
public function buyBoost(int $days): void
{
$pr = $this->getMyPR();
$this->authorize('view', $pr);
try {
app(BoostService::class)->boost(auth()->user(), $pr, $days);
Flux::toast(
heading: __('Boost gebucht'),
text: __('Ihre Pressemitteilung wird jetzt auf Start- und Branchenseite 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();
}
/**
* 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(),
]);
}
public function with(): array
{
$pr = $this->getMyPR();
$this->authorize('view', $pr);
$categoryName = $pr->category?->translations->firstWhere('locale', 'de')?->name ?? '–';
$latestRejection = null;
if ($pr->status->value === 'rejected') {
$latestRejection = $pr->statusLogs
->firstWhere(fn ($log) => $log->to_status?->value === 'rejected');
}
$cover = app(PressReleaseCoverImage::class);
$user = auth()->user();
$company = $pr->company;
$boostService = app(BoostService::class);
$proofService = app(ProofPdfService::class);
return [
'pr' => $pr,
'publicLinks' => $pr->status === PressReleaseStatus::Published
? $pr->publicPortalLinks()
: [],
'creditBalance' => $user->creditBalance(),
'canBoost' => $boostService->canBoost($pr),
'isBoosted' => $boostService->isBoosted($pr),
'boostedUntil' => $boostService->activeUntil($pr),
'boostOptions' => app(CreditPricingService::class)->boostOptions(),
'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' => $pr->images()->orderByDesc('is_preview')->orderBy('sort_order')->orderBy('id')->first(),
'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'),
'depublicationRequested' => SinglePurchase::query()
->where('press_release_id', $pr->id)
->where('type', SinglePurchaseType::Depublication->value)
->whereIn('status', [SinglePurchaseStatus::Pending->value, 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
->withoutGlobalScopes()
->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>
<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>
</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 einreichen.')
: __('Reichen Sie den Entwurf ein, sobald er vollständig ist.') }}
</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 einreichen') : __('Zur Prüfung einreichen') }}
</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-2">
{{-- Boost --}}
<div class="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.rocket-launch class="size-[18px]" />
</div>
<div class="min-w-0">
<h3 class="text-[14px] font-bold text-[color:var(--color-ink)] m-0">{{ __('Boost — Platzierung') }}</h3>
<p class="text-[12.5px] text-[color:var(--color-ink-2)] mt-1 mb-0">
{{ __('Hervorhebung auf Start- und Branchenseite.') }}
</p>
</div>
</div>
@if ($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' => $boostedUntil?->format('d.m.Y H:i')]) }}
</div>
@endif
@if ($canBoost)
<div class="flex flex-wrap gap-2">
@foreach ($boostOptions as $days => $credits)
<flux:button size="sm" variant="filled"
wire:click="buyBoost({{ $days }})" wire:loading.attr="disabled">
{{ __(':days Tage · :credits Credits', ['days' => $days, 'credits' => $credits]) }}
</flux:button>
@endforeach
</div>
@if ($isBoosted)
<p class="text-[11.5px] text-[color:var(--color-ink-3)] m-0">{{ __('Ein weiterer Kauf verlängert die Laufzeit.') }}</p>
@endif
@else
<p class="text-[12px] text-[color:var(--color-ink-3)] m-0">
{{ __('Nur grün geprüfte Pressemitteilungen sind boostbar.') }}
</p>
@endif
</div>
{{-- 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="buyProof" 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>
<p class="flex-1 text-[13px] text-[color:var(--color-ink-2)] m-0">
{{ __('Ihre Pressemitteilung wird gerade geprüft. Sie werden benachrichtigt, sobald eine Entscheidung vorliegt.') }}
</p>
</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>
<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>
<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">
{{ number_format($pr->hits, 0, ',', '.') }}
</div>
</div>
@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>
</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 && ($titleImage->copyright || $titleImage->is_ai_generated))
{{-- Bildnachweis + KI-Kennzeichnung (Art. 50 EU AI Act) --}}
<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 ($titleImage->is_ai_generated)
<span class="badge hub">{{ __('KI-generiert') }}</span>
@endif
<span>{{ $titleImage->copyright ?? __('Bild: KI-generiert') }}</span>
</div>
@endif
</article>
{{-- ============== INHALT ============== --}}
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Inhalt') }}</span>
</div>
<div class="p-5">
<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="__('Veröffentlichung anfordern')"
:quota-total="$quotaTotal"
:quota-remaining="$quotaRemaining" />
@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
{{-- ============== 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">
<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: 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>