@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
@if ($categoryName) {{ $categoryName }} @endif {{ __('web.detail.audience') }}

{{ $release->title }}

@if (filled($lead))

{{ $lead }}

@endif
@if ($companyName) {{ $companyInitial }} {{ $companyName }} @endif @if ($publishedAt) @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
@if ($categoryName) {{ $categoryName }} @endif {{ __('web.detail.audience') }}

{{ $release->title }}

@if (filled($lead))

{{ $lead }}

@endif
@if ($companyName) {{ $companyInitial }} {{ $companyName }} @endif @if ($publishedAt) @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 --}}
{{ $imageCaption ?: $release->title }} @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())
{{ $aiTextLabel }}
@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') }}

{{ __('web.detail.manage_eyebrow') }}

{{ __('web.detail.manage_question') }}

@if ($claimRecipients->isNotEmpty())

{{ __('web.detail.manage_recipient') }}

    @foreach ($claimRecipients as $recipient)
  • {{ $recipient }}
  • @endforeach
{{ __('web.detail.manage_cta') }}

{{ __('web.detail.manage_hint') }} {{ __('web.detail.manage_other_address') }}

@else {{ __('web.detail.manage_link') }}

{{ __('web.detail.manage_hint') }}

@endif
{{-- Sidebar --}}
{{-- 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())

{{ __('web.detail.more_from', ['company' => $companyName]) }}

{{ __('web.detail.more_from_sub') }}

@foreach ($companyReleases as $companyRelease) @endforeach {{ __('web.detail.all_from', ['company' => $companyName]) }} →
@endif @if ($relatedReleases->isNotEmpty())

{{ __('web.detail.related') }}

{{ __('web.detail.related_sub') }}

@foreach ($relatedReleases as $relatedRelease) @endforeach @if ($categoryName) {{ __('web.detail.all_from_category', ['category' => $categoryName]) }} → @endif
@endif
@endif
@endsection