@extends('web.layouts.web-master')
@php
use App\Enums\Portal;
use App\Services\PressRelease\CanonicalAssigner;
use App\Support\StructuredData;
use Illuminate\Support\Str;
// Private Vorschau über Magic-Link (/pm-vorschau/{token}): identisches
// Layout wie die spätere öffentliche Seite, plus Hinweis-Banner und noindex.
$isPreview = ($isPreview ?? false) === true;
$previewExpiresAt = $previewExpiresAt ?? null;
$previewPortalOptions = $previewPortalOptions ?? null;
$company = $release->company;
$categoryTranslation = $release->category?->translations?->firstWhere('locale', app()->getLocale())
?? $release->category?->translations?->first();
$categoryName = $categoryTranslation?->name;
$categorySlug = $categoryTranslation?->slug;
$categoryHref = $categorySlug ? route('kategorie', ['slug' => $categorySlug]) : route('web.home');
$parentTranslation = $release->category?->parent?->translations?->firstWhere('locale', app()->getLocale())
?? $release->category?->parent?->translations?->first();
$parentName = $parentTranslation?->name;
$parentSlug = $parentTranslation?->slug;
$parentHref = $parentSlug ? route('kategorie', ['slug' => $parentSlug]) : route('web.home');
// In der Vorschau ersatzweise der geplante Termin, damit die Meta-Zeile
// der späteren Veröffentlichung entspricht.
$publishedAt = $release->published_at ?? ($isPreview ? $release->scheduledAtLocal() : null);
$embargoAt = $release->embargoAtLocal();
$bodyHtml = $release->renderedText();
$wordCount = $release->wordCount();
$readTime = $release->readTimeMinutes();
$teaser = $release->teaserText(180);
// Subline nur bei explizit gesetztem Untertitel — kein Fallback auf den Textanfang,
// sonst erscheint der erste Absatz doppelt. Der Teaser dient nur der Meta-Description.
$lead = $release->subtitle;
$image = $release->images->first();
// In der anonymen Token-Vorschau ist die PM noch nicht öffentlich; die
// Media-Route braucht daher eine signierte URL, um die Bilder auszuliefern.
$signMedia = $isPreview ?? false;
$imageUrl = $image?->variantUrl('detail', $signMedia) ?? $image?->variantUrl('card', $signMedia) ?? $image?->url($signMedia);
$imageCaption = $image?->description ?: $image?->title;
$imageCredit = $image?->copyright ?: $image?->author;
// KI-Bildkennzeichnung (Art. 50 KI-VO): Badge direkt am Bild
// („KI-generiert" / „KI-bearbeitet"), null bei Originalfotos.
$imageAiBadge = $image?->ai_declaration?->publicLabel();
// Ohne Pressefoto: die im User-Panel gewählte Platzhalter-Variante.
// Null bei Altbeständen ohne Variante — dann generischer Hero-Fallback.
$placeholderUrl = $imageUrl ? null : $release->chosenPlaceholderUrl();
$companyName = $company?->name;
$companyInitial = $companyName ? mb_strtoupper(mb_substr($companyName, 0, 1)) : '·';
$companyHref = $company?->slug ? route('newsroom', ['slug' => $company->slug]) : route('newsrooms');
$countryNames = ['DE' => __('web.countries.de'), 'AT' => __('web.countries.at'), 'CH' => __('web.countries.ch')];
$companyCountry = $company?->country_code ? ($countryNames[strtoupper($company->country_code)] ?? null) : null;
$boilerplate = filled($release->boilerplate_override) ? $release->boilerplate_override : $company?->boilerplate;
$keywords = collect(preg_split('/[,;\n]+/', (string) $release->keywords))
->map(fn ($keyword) => trim($keyword))
->filter()
->unique()
->take(8);
$contact = $release->contacts->first();
$contactName = $contact ? trim(implode(' ', array_filter([$contact->title, $contact->first_name, $contact->last_name]))) : null;
// Legacy-Bestände ohne Kontakt-Datensatz: der Block erscheint trotzdem,
// solange die Firma erreichbare Angaben hat (wie im Altsystem).
$contactEmail = $contact?->email ?: $company?->email;
$hasContactBlock = $contact || filled($contactEmail) || filled($company?->address) || filled($company?->website);
// Attributions-Zeile wie im Legacy-System: Urheber (Firma der PM) und
// Übermittler (Profilfeld agency_name des einliefernden Accounts, z. B.
// "connektar.de Presseverteiler" bei API-Einlieferungen).
$agencyName = $release->user?->agency_name;
$publisherVerified = (bool) ($company?->is_active && filled($company?->website));
$isRecommended = $release->content_score !== null && $release->content_score >= 80;
// In der Vorschau wird der Vorschau-Link selbst geteilt — die öffentliche
// URL existiert vor der Veröffentlichung noch nicht.
$shareUrl = $isPreview ? request()->fullUrl() : route('release.detail', ['slug' => $release->slug]);
$brandLabel = config('app.theme') === 'presseecho' ? 'presseecho' : 'businessportal24';
// Doppelveröffentlichung (Anhang 2, 1c.8): rel=canonical zeigt auf die
// Erstquelle — die eigene Adresse, oder bei zweiter Fassung/portal=both
// das Portal, das laut Kategorie-Zuordnung führt. Geteilt wird weiterhin
// die Adresse, auf der der Leser gerade ist.
$canonicalUrl = $isPreview
? $shareUrl
: app(CanonicalAssigner::class)->canonicalUrlFor($release, $brandLabel === 'presseecho' ? Portal::Presseecho : Portal::Businessportal24);
// SEO: NewsArticle + BreadcrumbList als JSON-LD — nur für die öffentliche
// Seite (die Vorschau ist noindex und hat keine stabile URL).
$jsonLd = null;
if (! $isPreview) {
$portalBase = StructuredData::portalBase($domainUrl ?? null);
$authorNewsroomUrl = $company?->slug ? StructuredData::newsroomUrl($portalBase, $company->slug) : null;
// dateModified darf laut Google nicht vor datePublished liegen
// (updated_at kann bei terminierten PMs älter sein als published_at).
$modifiedAt = $release->updated_at?->greaterThan($publishedAt ?? $release->updated_at)
? $release->updated_at
: $publishedAt;
// URLs aus der Portal-Basis (wie der Canonical) statt route(), damit
// Host und Canonical auch bei forceRootUrl-Abweichungen übereinstimmen.
$breadcrumbs = collect([
['name' => $brandLabel, 'item' => $portalBase.'/'.app()->getLocale()],
$categoryName && $categorySlug ? ['name' => $categoryName, 'item' => $portalBase.'/'.app()->getLocale().'/category/'.$categorySlug] : null,
['name' => $release->title, 'item' => $portalBase.'/'.app()->getLocale().'/press-release/'.$release->slug],
])->filter()->values();
$jsonLd = [
'@context' => 'https://schema.org',
'@graph' => [
array_filter([
'@type' => 'NewsArticle',
'mainEntityOfPage' => ['@type' => 'WebPage', '@id' => $canonicalUrl],
'headline' => Str::limit($release->title, 110),
'description' => filled($lead) ? $lead : $teaser,
'image' => $imageUrl ? [$imageUrl] : null,
'datePublished' => $publishedAt?->toIso8601String(),
'dateModified' => $modifiedAt?->toIso8601String(),
'articleSection' => $categoryName,
'keywords' => $keywords->isNotEmpty() ? $keywords->implode(', ') : null,
'wordCount' => $wordCount ?: null,
'inLanguage' => app()->getLocale(),
'isAccessibleForFree' => true,
// author trägt die @id der Newsroom-Organization (2.1): Meldung
// und Newsroom-Seite beschreiben dieselbe Entität.
'author' => $companyName ? array_filter([
'@type' => 'Organization',
'@id' => $authorNewsroomUrl ? StructuredData::newsroomId($authorNewsroomUrl) : null,
'name' => $companyName,
'url' => $authorNewsroomUrl,
]) : null,
'publisher' => StructuredData::publisher($portalBase),
], fn ($value) => $value !== null),
[
'@type' => 'BreadcrumbList',
'itemListElement' => $breadcrumbs->map(fn (array $crumb, int $index) => [
'@type' => 'ListItem',
'position' => $index + 1,
'name' => $crumb['name'],
'item' => $crumb['item'],
])->all(),
],
],
];
}
@endphp
@section('title', $release->title.' – '.$brandLabel)
@section('meta_description', filled($lead) ? $lead : $teaser)
@section('og_type', 'article')
@if ($imageUrl)
@section('og_image', $imageUrl)
@endif
@if ($isPreview)
@section('robots', 'noindex,nofollow')
@else
@section('canonical', $canonicalUrl)
@section('meta_extra')
@if ($publishedAt)
@endif
@if ($release->updated_at?->greaterThan($publishedAt ?? $release->updated_at))
@endif
@if ($categoryName)
@endif
@foreach ($keywords as $keyword)
@endforeach
@if ($imageUrl && ($imageCaption || $release->title))
@endif
@endsection
@section('structured_data')
@endsection
@endif
@section('content')
{{-- Lese-Fortschrittsbalken --}}
@if ($isPreview)
{{-- Hinweis-Banner: private Vorschau, nicht öffentlich erreichbar --}}
● {{ __('web.detail.preview_label') }}
{{ __('web.detail.preview_text') }}
{{ __('web.detail.preview_status', ['status' => $release->status->label()]) }}
@if ($previewExpiresAt)
· {{ __('web.detail.preview_valid_until', ['date' => $previewExpiresAt->translatedFormat('j. F Y, H:i')]) }}
@endif
@if (! empty($previewPortalOptions))
{{-- PM erscheint auf beiden Portalen: Ansicht umschalten --}}
{{ __('web.detail.preview_portal_switch') }}
@foreach ($previewPortalOptions as $option)
$option['active'],
'hover:bg-amber-100 text-amber-900' => ! $option['active'],
])
@if ($option['active']) aria-current="true" @endif>
{{ $option['label'] }}
@endforeach
@endif
@endif
{{-- Breadcrumbs --}}
@if (! $imageUrl)
{{-- Ohne Pressefoto: Headline direkt in der Platzhalter-Fläche der PM
(gewählte Variante wie im User-Panel, sonst generischer Fallback). --}}
@if ($placeholderUrl)
@else
@endif
@if ($embargoAt)
● {{ __('web.detail.embargo_lifted') }}
{{ $embargoAt->translatedFormat('j. F Y, H:i') }}
@endif
{{ $release->title }}
@if (filled($lead))
{{ $lead }}
@endif
@if ($companyName)
{{ $companyInitial }}
{{ $companyName }}
@endif
@if ($publishedAt)
{{ $publishedAt->translatedFormat('j. F Y') }} · {{ __('web.detail.time_at', ['time' => $publishedAt->format('H:i')]) }}
@endif
{{ __('web.shared.read_time', ['minutes' => $readTime]) }} · {{ __('web.detail.words', ['count' => number_format($wordCount, 0, ',', '.')]) }}@unless ($isPreview) · {{ __('web.detail.views', ['count' => number_format((int) $release->hits, 0, ',', '.')]) }}@endunless
@if ($companyCountry)
{{ $companyCountry }}
@endif
@if ($isRecommended)
{{ __('web.detail.recommended') }}
@endif
@else
{{-- Hero Meta Block --}}
@if ($embargoAt)
● {{ __('web.detail.embargo_lifted') }}
{{ $embargoAt->translatedFormat('j. F Y, H:i') }}
@endif
{{ $release->title }}
@if (filled($lead))
{{ $lead }}
@endif
@if ($companyName)
{{ $companyInitial }}
{{ $companyName }}
@endif
@if ($publishedAt)
{{ $publishedAt->translatedFormat('j. F Y') }} · {{ __('web.detail.time_at', ['time' => $publishedAt->format('H:i')]) }}
@endif
{{ __('web.shared.read_time', ['minutes' => $readTime]) }} · {{ __('web.detail.words', ['count' => number_format($wordCount, 0, ',', '.')]) }}@unless ($isPreview) · {{ __('web.detail.views', ['count' => number_format((int) $release->hits, 0, ',', '.')]) }}@endunless
@if ($companyCountry)
{{ $companyCountry }}
@endif
@if ($isRecommended)
{{ __('web.detail.recommended') }}
@endif
{{-- Hero Image --}}
@if ($imageAiBadge)
{{-- KI-Badge am Bild (Art. 50 KI-VO): kräftig abgedunkelter
Hintergrund, damit es auch auf hellen Bildern lesbar bleibt. --}}
{{ $imageAiBadge }}
@endif
@if ($imageCaption || $imageCredit)
{{ $imageCaption }}@if ($imageCaption && $imageCredit) · @endif@if ($imageCredit){{ __('web.detail.photo') }}: {{ $imageCredit }} @endif
@endif
@endif
{{-- Body + Sidebar --}}
{{-- Body --}}
{{-- KI-Label (Art. 50 KI-VO) in Einreicher-Stimme am Anfang
des Inhalts; neutrales ℹ-Icon, kein Flux auf Web-Domains. --}}
@if ($aiTextLabel = $release->aiTextPublicLabel())
@endif
{!! $bodyHtml !!}
@if (filled($boilerplate))
{{ __('web.detail.about_company') }}
{{ $boilerplate }}
@endif
{{-- Inline-Werbeplatz (Position der früheren Inline-Ad); in der privaten Vorschau ausgeblendet --}}
@unless ($isPreview)
@endunless
{{-- Pressekontakt (Fallback ohne Kontakt-Datensatz: Firmendaten) --}}
@if ($hasContactBlock)
{{ __('web.detail.press_contact') }}
{{ $contactName ?: $companyName }}
@if ($publisherVerified)
@endif
@if ($contact?->responsibility || $companyName)
@if ($contact?->responsibility){{ $contact->responsibility }} @endif
{{ $companyName }}
@endif
@if ($company?->address)
{{ $company->address }}
@endif
@if ($contact?->phone)
{{ $contact->phone }}
@endif
@if ($contactEmail)
{{ $contactEmail }}
@endif
@if ($company?->website)
{{ Str::of($company->website)->after('//')->rtrim('/') }}
@endif
@endif
{{-- Attribution: Urheber & übermittelnder Verteiler (Legacy-Konvention) --}}
@if ($companyName)
@if ($agencyName && $agencyName !== $companyName)
{{ __('web.detail.attribution_via', ['company' => $companyName, 'agency' => $agencyName]) }}
@elseif ($agencyName)
{{ __('web.detail.attribution_agency_only', ['agency' => $agencyName]) }}
@else
{{ __('web.detail.attribution', ['company' => $companyName]) }}
@endif
@endif
{{-- Zwei Handlungsfelder, klar getrennt und mit Button-Charakter:
rechtliches Anliegen (T13) und Verwaltung über die hinterlegte
Adresse (Roadmap 1b.2/1b.4). Vorher eine Textzeile in 11,5 px,
die überlesen wurde. Die Empfängeradresse steht maskiert dabei,
damit klar ist, wohin der Link geht — ohne sie preiszugeben. --}}
@php
$portalUrl = rtrim(config('domains.domain_portal_url', config('app.url')), '/');
$claimRecipients = ($company && ! $company->isGone() && $company->slug)
? app(\App\Services\Company\NewsroomClaimService::class)->maskedRecipients($company)
: collect();
@endphp
{{ __('web.detail.legal_eyebrow') }}
{{ __('web.detail.legal_question') }} {{ __('web.detail.reports_free') }}
{{-- Sidebar --}}
{{-- Publisher --}}
@if ($companyName)
{{ __('web.detail.published_by') }}
{{ $companyInitial }}
{{ $companyName }}
@if ($companyCountry)
{{ $companyCountry }}
@endif
@if ($publisherVerified)
{{ __('web.detail.verified_publisher') }}
@endif
@endif
{{-- Teilen --}}
{{ __('web.detail.share') }}
{{-- Schlagwörter --}}
@if ($keywords->isNotEmpty())
{{ __('web.detail.keywords') }}
@endif
{{-- Newsletter-Box bewusst entfernt: kommt nachgelagert mit echtem
Double-Opt-in (siehe dev/release/entwicklungsplan-release.md). --}}
{{-- Weiterführend --}}
{{ __('web.detail.further') }}
{{-- Verwaltungszugang in der Seitenleiste (Roadmap 1b.4): derselbe
Hinweis wie in der Veröffentlichungsmail — wer nur die Leiste
liest, soll ihn nicht verpassen. --}}
{{-- Empfehlungen: zweispaltig nur, wenn beide Listen gefüllt sind —
eine einzelne Liste nutzt sonst die volle Layout-Breite. --}}
@if ($companyReleases->isNotEmpty() || $relatedReleases->isNotEmpty())
$companyReleases->isNotEmpty() && $relatedReleases->isNotEmpty(),
])>
@if ($companyReleases->isNotEmpty())
@endif
@if ($relatedReleases->isNotEmpty())
@endif
@endif
@endsection