diff --git a/app/Services/Web/CategoryPageData.php b/app/Services/Web/CategoryPageData.php
index b1b93bfd..0c641f38 100644
--- a/app/Services/Web/CategoryPageData.php
+++ b/app/Services/Web/CategoryPageData.php
@@ -61,6 +61,13 @@ class CategoryPageData
abort(404);
}
+ // SEO/hreflang: lokalisierte Slugs beider Sprachen — die geladene
+ // translations-Relation ist auf die aktuelle Sprache gefiltert.
+ $alternateSlugs = $category->translations()
+ ->whereIn('locale', ['de', 'en'])
+ ->pluck('slug', 'locale')
+ ->all();
+
$childCategories = Category::query()
->where('parent_id', $category->id)
->where('is_active', true)
@@ -278,6 +285,7 @@ class CategoryPageData
return [
'category' => $category,
+ 'categoryAlternateSlugs' => $alternateSlugs,
'referenceNow' => $referenceNow,
'leadRelease' => $leadRelease,
'topReleases' => $topReleases,
diff --git a/resources/views/components/web/structured-data.blade.php b/resources/views/components/web/structured-data.blade.php
new file mode 100644
index 00000000..92dbaebf
--- /dev/null
+++ b/resources/views/components/web/structured-data.blade.php
@@ -0,0 +1,5 @@
+@props(['data'])
+
+{{-- JSON_HEX_TAG escapet "<"/">" (<…), damit ein "" im Inhalt
+ das Script-Tag nicht aufbrechen kann (XSS über Titel/Teaser). --}}
+
diff --git a/resources/views/web/businessportal24.blade.php b/resources/views/web/businessportal24.blade.php
index 0e211591..14e8313c 100644
--- a/resources/views/web/businessportal24.blade.php
+++ b/resources/views/web/businessportal24.blade.php
@@ -2,8 +2,41 @@
@php
$brand = config('domains.domains.businessportal24.brand', []);
+
+ // SEO: WebSite-Markup mit Sitelinks-Searchbox (SearchAction auf /suche).
+ $portalBase = rtrim($domainUrl ?? config('app.url'), '/');
+ $brandFullName = ($brand['name'] ?? 'businessportal').($brand['accent'] ?? '24');
+
+ $jsonLd = [
+ '@context' => 'https://schema.org',
+ '@graph' => [
+ [
+ '@type' => 'WebSite',
+ 'name' => $brandFullName,
+ 'url' => $portalBase.'/',
+ 'inLanguage' => app()->getLocale(),
+ 'potentialAction' => [
+ '@type' => 'SearchAction',
+ 'target' => [
+ '@type' => 'EntryPoint',
+ 'urlTemplate' => $portalBase.'/'.app()->getLocale().'/suche?q={search_term_string}',
+ ],
+ 'query-input' => 'required name=search_term_string',
+ ],
+ ],
+ [
+ '@type' => 'Organization',
+ 'name' => $brandFullName,
+ 'url' => $portalBase.'/',
+ ],
+ ],
+ ];
@endphp
+@section('structured_data')
+
+@endsection
+
{{-- DE-Meta kommt brand-spezifisch aus config/domains.php, EN aus den Übersetzungsdateien. --}}
@section('title', app()->getLocale() === 'de'
? ($brand['meta_title'] ?? 'Aktuelle Pressemitteilungen aus der deutschen Wirtschaft – businessportal24')
diff --git a/resources/views/web/kategorie.blade.php b/resources/views/web/kategorie.blade.php
index e80ab19c..20ae1554 100644
--- a/resources/views/web/kategorie.blade.php
+++ b/resources/views/web/kategorie.blade.php
@@ -30,11 +30,52 @@
? (filled($leadRelease->subtitle) ? $leadRelease->subtitle : $leadRelease->teaserText(220))
: null;
$leadReadTime = $leadRelease?->readTimeMinutes() ?? 1;
+
+ // SEO: hreflang über die lokalisierten Kategorie-Slugs (nur wenn beide
+ // Übersetzungen existieren — sonst würden wir auf 404s verweisen).
+ // Fallback [] deckt gecachte Page-Data von vor diesem Feld ab.
+ $alternateSlugs = $categoryAlternateSlugs ?? [];
+ $catSlugDe = $alternateSlugs['de'] ?? null;
+ $catSlugEn = $alternateSlugs['en'] ?? null;
+
+ // URLs aus der Portal-Basis (wie der Canonical im Layout) statt route(),
+ // damit Host und Canonical auch bei forceRootUrl-Abweichungen übereinstimmen.
+ $portalBase = rtrim($domainUrl ?? config('app.url'), '/');
+ $brandLabel = config('app.theme') === 'presseecho' ? 'presseecho' : 'businessportal24';
+
+ $breadcrumbs = collect([
+ ['name' => $brandLabel, 'item' => $portalBase.'/'.app()->getLocale()],
+ $parentName && $parentTranslation?->slug ? ['name' => $parentName, 'item' => $portalBase.'/'.app()->getLocale().'/category/'.$parentTranslation->slug] : null,
+ $catSlug ? ['name' => $catName, 'item' => $portalBase.'/'.app()->getLocale().'/category/'.$catSlug] : null,
+ ])->filter()->values();
+
+ $jsonLd = [
+ '@context' => 'https://schema.org',
+ '@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', __('web.category.meta_title', ['category' => $catName, 'brand' => config('app.name')]))
@section('meta_description', Str::limit(strip_tags($catDescription), 155))
+@if ($catSlugDe && $catSlugEn)
+ @section('hreflang')
+
+
+
+ @endsection
+@endif
+
+@section('structured_data')
+
+@endsection
+
@section('content')
diff --git a/resources/views/web/layouts/web-master.blade.php b/resources/views/web/layouts/web-master.blade.php
index 9873dd22..bd0a9e16 100644
--- a/resources/views/web/layouts/web-master.blade.php
+++ b/resources/views/web/layouts/web-master.blade.php
@@ -49,12 +49,22 @@
+
@if ($ogImage)
@endif
+ @if ($ogImage)
+
+ @endif
+
+ {{-- Seitenspezifische Zusatz-Metas (z. B. article:* auf PM-Detailseiten) --}}
+ @yield('meta_extra')
+
+ {{-- Strukturierte Daten (JSON-LD) – pro Seite optional gesetzt --}}
+ @yield('structured_data')
diff --git a/resources/views/web/newsroom.blade.php b/resources/views/web/newsroom.blade.php
index 96226785..e66756df 100644
--- a/resources/views/web/newsroom.blade.php
+++ b/resources/views/web/newsroom.blade.php
@@ -30,6 +30,20 @@
? (filled($leadRelease->subtitle) ? $leadRelease->subtitle : $leadRelease->teaserText(220))
: null;
$leadReadTime = $leadRelease?->readTimeMinutes() ?? 1;
+
+ // SEO: Der Newsroom ist die kanonische Firmenseite im Portal —
+ // Organization-Markup verknüpft PMs (author) mit diesem Profil.
+ // Portal-Basis wie beim Canonical im Layout (unabhängig von forceRootUrl).
+ $newsroomUrl = rtrim($domainUrl ?? config('app.url'), '/').'/'.app()->getLocale().'/newsroom/'.$company->slug;
+ $jsonLd = [
+ '@context' => 'https://schema.org',
+ '@type' => 'Organization',
+ 'name' => $company->name,
+ 'url' => $newsroomUrl,
+ ...(filled($company->website) ? ['sameAs' => [$company->website]] : []),
+ ...($logoUrl ? ['logo' => $logoUrl] : []),
+ ...($boilerplate ? ['description' => Str::limit($boilerplate, 300)] : []),
+ ];
@endphp
@section('title', $company->name.' – Newsroom · '.$brandName.$brandAccent)
@@ -42,6 +56,10 @@
@endsection
+@section('structured_data')
+
+@endsection
+
@section('content')
diff --git a/resources/views/web/presseecho.blade.php b/resources/views/web/presseecho.blade.php
index d617edc1..144ee8c8 100644
--- a/resources/views/web/presseecho.blade.php
+++ b/resources/views/web/presseecho.blade.php
@@ -2,8 +2,41 @@
@php
$brand = config('domains.domains.presseecho.brand', []);
+
+ // SEO: WebSite-Markup mit Sitelinks-Searchbox (SearchAction auf /suche).
+ $portalBase = rtrim($domainUrl ?? config('app.url'), '/');
+ $brandFullName = ($brand['name'] ?? 'presse').($brand['accent'] ?? 'echo');
+
+ $jsonLd = [
+ '@context' => 'https://schema.org',
+ '@graph' => [
+ [
+ '@type' => 'WebSite',
+ 'name' => $brandFullName,
+ 'url' => $portalBase.'/',
+ 'inLanguage' => app()->getLocale(),
+ 'potentialAction' => [
+ '@type' => 'SearchAction',
+ 'target' => [
+ '@type' => 'EntryPoint',
+ 'urlTemplate' => $portalBase.'/'.app()->getLocale().'/suche?q={search_term_string}',
+ ],
+ 'query-input' => 'required name=search_term_string',
+ ],
+ ],
+ [
+ '@type' => 'Organization',
+ 'name' => $brandFullName,
+ 'url' => $portalBase.'/',
+ ],
+ ],
+ ];
@endphp
+@section('structured_data')
+
+@endsection
+
{{-- DE-Meta kommt brand-spezifisch aus config/domains.php, EN aus den Übersetzungsdateien. --}}
@section('title', app()->getLocale() === 'de'
? ($brand['meta_title'] ?? 'Aktuelle Pressemitteilungen aus Wirtschaft & Industrie – presseecho')
diff --git a/resources/views/web/release-detail.blade.php b/resources/views/web/release-detail.blade.php
index c39128b2..394a1da9 100644
--- a/resources/views/web/release-detail.blade.php
+++ b/resources/views/web/release-detail.blade.php
@@ -83,6 +83,63 @@
// 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';
+
+ // 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 = rtrim($domainUrl ?? config('app.url'), '/');
+
+ // 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' => $shareUrl],
+ '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' => $companyName ? array_filter([
+ '@type' => 'Organization',
+ 'name' => $companyName,
+ 'url' => $company?->slug ? $portalBase.'/'.app()->getLocale().'/newsroom/'.$company->slug : null,
+ ]) : null,
+ 'publisher' => ['@type' => 'Organization', 'name' => $brandLabel, 'url' => $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)
@@ -95,6 +152,28 @@
@section('robots', 'noindex,nofollow')
@else
@section('canonical', $shareUrl)
+
+ @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')
diff --git a/tests/Feature/Web/StructuredDataTest.php b/tests/Feature/Web/StructuredDataTest.php
new file mode 100644
index 00000000..4c24daf3
--- /dev/null
+++ b/tests/Feature/Web/StructuredDataTest.php
@@ -0,0 +1,119 @@
+create(['portal' => Portal::Businessportal24]);
+ $category->translations()->create(['locale' => 'de', 'name' => 'Technologie', 'slug' => 'technologie']);
+
+ $company = Company::factory()->businessportal24()->create(['name' => 'Muster GmbH']);
+
+ return PressRelease::factory()
+ ->published()
+ ->forPortal(Portal::Businessportal24)
+ ->for($category)
+ ->for($company)
+ ->create([
+ 'slug' => 'strukturierte-daten-pm',
+ 'title' => 'Strukturierte Daten für Pressemitteilungen',
+ 'published_at' => now()->subDay(),
+ ]);
+}
+
+test('the release detail page emits NewsArticle and BreadcrumbList JSON-LD', function () {
+ /** @var TestCase $this */
+ makeStructuredDataRelease();
+
+ $this->get('https://businessportal24.test/de/press-release/strukturierte-daten-pm')
+ ->assertSuccessful()
+ ->assertSee('