Detailseite einer Pressemitteilung (inkl. Hit-Zaehler) in ReleaseDetailPageData + Web\ReleaseDetailController. Verhalten unveraendert, 92 Web-Tests gruen. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
473 lines
20 KiB
PHP
473 lines
20 KiB
PHP
<?php
|
|
|
|
use App\Enums\Portal;
|
|
use App\Http\Controllers\PressReleasePreviewController;
|
|
use App\Http\Controllers\Web\CategoryController;
|
|
use App\Http\Controllers\Web\NewsroomDetailController;
|
|
use App\Http\Controllers\Web\NewsroomsController;
|
|
use App\Http\Controllers\Web\PreiseController;
|
|
use App\Http\Controllers\Web\ReleaseDetailController;
|
|
use App\Http\Controllers\Web\SucheController;
|
|
use App\Http\Controllers\Web\VeroeffentlichenController;
|
|
use App\Http\Middleware\SetEdition;
|
|
use App\Models\Category;
|
|
use App\Models\Company;
|
|
use App\Models\Plan;
|
|
use App\Models\PressRelease;
|
|
use App\Scopes\PortalScope;
|
|
use App\Support\DomainAssetContext;
|
|
use App\Support\EditorialClock;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Illuminate\Support\Carbon;
|
|
use Illuminate\Support\Facades\Route;
|
|
use Illuminate\Support\Str;
|
|
|
|
Route::get('/pm-vorschau/{token}', PressReleasePreviewController::class)
|
|
->where('token', '[A-Za-z0-9]{40,}')
|
|
->name('press-releases.preview');
|
|
|
|
// Gemeinsame Web-Routes für alle Landingpages
|
|
// Jede Landing-Page hat das gleiche Gerüst, aber unterschiedliches Styling
|
|
|
|
// Dünner Delegations-Wrapper auf die Single Source of Truth (DomainAssetContext),
|
|
// damit Bare-Root- und Edition-Home-Route unverändert per Closure aufrufen können.
|
|
$applyWebDomainConfig = static fn (string $domainKey): array => DomainAssetContext::applyWebDomainConfig($domainKey);
|
|
|
|
$webHomeData = static function (Portal $primaryPortal, string $language = 'de'): array {
|
|
$portalValues = [$primaryPortal->value, Portal::Both->value];
|
|
|
|
// Aktualitäts-Stichtag an den jüngsten Datensatz koppeln (historisches Archiv).
|
|
$referenceNow = EditorialClock::reference($portalValues, $language);
|
|
$recentSince = $referenceNow->copy()->subDays(7);
|
|
$previousSince = $referenceNow->copy()->subDays(14);
|
|
|
|
$publishedQuery = static fn (): Builder => PressRelease::query()
|
|
->forPortalValues($portalValues)
|
|
->forLanguage($language)
|
|
->visibleInArchive();
|
|
|
|
$with = [
|
|
'company',
|
|
'category.translations' => fn ($query) => $query->where('locale', $language),
|
|
'images' => fn ($query) => $query
|
|
->orderByDesc('is_preview')
|
|
->orderBy('sort_order')
|
|
->limit(1),
|
|
];
|
|
|
|
$oldestPublishedAt = $publishedQuery()
|
|
->oldest('published_at')
|
|
->value('published_at');
|
|
|
|
$leadRelease = $publishedQuery()
|
|
->with($with)
|
|
->orderByDesc('published_at')
|
|
->first();
|
|
|
|
$sideReleases = $publishedQuery()
|
|
->with($with)
|
|
->when($leadRelease, fn (Builder $query) => $query->where('id', '!=', $leadRelease->id))
|
|
->orderByDesc('published_at')
|
|
->limit(4)
|
|
->get();
|
|
|
|
$mostReadReleases = $publishedQuery()
|
|
->orderByDesc('hits')
|
|
->orderByDesc('published_at')
|
|
->limit(4)
|
|
->get(['id', 'slug', 'title', 'hits', 'portal', 'language']);
|
|
|
|
$activeNewsrooms = Company::query()
|
|
->whereIn('portal', $portalValues)
|
|
->where('is_active', true)
|
|
->whereHas('pressReleases', function (Builder $query) use ($portalValues, $recentSince, $language): void {
|
|
$query
|
|
->forPortalValues($portalValues)
|
|
->forLanguage($language)
|
|
->published()
|
|
->where('published_at', '>=', $recentSince);
|
|
})
|
|
->withCount([
|
|
'pressReleases as recent_releases_count' => function (Builder $query) use ($portalValues, $recentSince, $language): void {
|
|
$query
|
|
->forPortalValues($portalValues)
|
|
->forLanguage($language)
|
|
->published()
|
|
->where('published_at', '>=', $recentSince);
|
|
},
|
|
'pressReleases as today_releases_count' => function (Builder $query) use ($portalValues, $referenceNow, $language): void {
|
|
$query
|
|
->forPortalValues($portalValues)
|
|
->forLanguage($language)
|
|
->published()
|
|
->whereDate('published_at', $referenceNow->toDateString());
|
|
},
|
|
])
|
|
->orderByDesc('today_releases_count')
|
|
->orderByDesc('recent_releases_count')
|
|
->limit(6)
|
|
->get()
|
|
->map(fn (Company $company): array => [
|
|
'name' => $company->name,
|
|
'slug' => $company->slug,
|
|
'initial' => mb_strtoupper(mb_substr((string) $company->name, 0, 1)),
|
|
'count' => (int) $company->recent_releases_count,
|
|
'today' => (int) $company->today_releases_count > 0,
|
|
]);
|
|
|
|
$industryIndex = Category::query()
|
|
->with(['translations' => fn ($query) => $query->where('locale', $language)])
|
|
->withCount([
|
|
'pressReleases as recent_count' => function (Builder $query) use ($portalValues, $recentSince, $language): void {
|
|
$query
|
|
->forPortalValues($portalValues)
|
|
->forLanguage($language)
|
|
->published()
|
|
->where('published_at', '>=', $recentSince);
|
|
},
|
|
'pressReleases as previous_count' => function (Builder $query) use ($portalValues, $recentSince, $previousSince, $language): void {
|
|
$query
|
|
->forPortalValues($portalValues)
|
|
->forLanguage($language)
|
|
->published()
|
|
->where('published_at', '>=', $previousSince)
|
|
->where('published_at', '<', $recentSince);
|
|
},
|
|
])
|
|
->whereIn('portal', $portalValues)
|
|
->where('is_active', true)
|
|
->whereNull('parent_id')
|
|
->orderByDesc('recent_count')
|
|
->limit(7)
|
|
->get()
|
|
->map(function (Category $category): ?array {
|
|
$translation = $category->translations->first();
|
|
|
|
if (! $translation) {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
'name' => $translation->name,
|
|
'href' => $translation->slug ? route('kategorie', ['slug' => $translation->slug]) : route('web.home'),
|
|
'count' => (int) ($category->recent_count ?? 0),
|
|
'delta' => (int) ($category->recent_count ?? 0) - (int) ($category->previous_count ?? 0),
|
|
];
|
|
})
|
|
->filter()
|
|
->values();
|
|
|
|
// "Heute im Fokus · Branche": aktivste Top-Rubrik im Stichtag-Fenster mit echten Meldungen.
|
|
$spotlightCategory = Category::query()
|
|
->with(['translations' => fn ($query) => $query->where('locale', $language)])
|
|
->withCount([
|
|
'pressReleases as window_count' => function (Builder $query) use ($portalValues, $recentSince, $referenceNow, $language): void {
|
|
$query
|
|
->forPortalValues($portalValues)
|
|
->forLanguage($language)
|
|
->published()
|
|
->where('published_at', '>=', $recentSince)
|
|
->where('published_at', '<=', $referenceNow);
|
|
},
|
|
])
|
|
->whereIn('portal', $portalValues)
|
|
->where('is_active', true)
|
|
->whereNull('parent_id')
|
|
->orderByDesc('window_count')
|
|
->first();
|
|
|
|
$spotlight = null;
|
|
|
|
if ($spotlightCategory && $spotlightTranslation = $spotlightCategory->translations->first()) {
|
|
$spotlightBase = static fn (): Builder => PressRelease::query()
|
|
->forPortalValues($portalValues)
|
|
->forLanguage($language)
|
|
->visibleInArchive()
|
|
->where('category_id', $spotlightCategory->id);
|
|
|
|
$spotlightReleases = $spotlightBase()
|
|
->with(['company'])
|
|
->orderByDesc('published_at')
|
|
->limit(3)
|
|
->get()
|
|
->map(static function (PressRelease $release) use ($spotlightTranslation): array {
|
|
$words = str_word_count(strip_tags((string) $release->text));
|
|
$publishedAt = $release->published_at;
|
|
|
|
return [
|
|
'time' => $publishedAt?->format('H:i') ?? '',
|
|
'date' => $publishedAt?->translatedFormat('j. M') ?? '',
|
|
'category' => $spotlightTranslation->name,
|
|
'title' => $release->title,
|
|
'company' => $release->company?->name ?? '',
|
|
'city' => null,
|
|
'minutes' => max(1, (int) ceil($words / 200)),
|
|
'href' => $release->slug ? route('release.detail', ['slug' => $release->slug]) : '#',
|
|
];
|
|
});
|
|
|
|
if ($spotlightReleases->isNotEmpty()) {
|
|
$windowCount = (int) ($spotlightCategory->window_count ?? 0);
|
|
$archiveCount = $spotlightBase()->count();
|
|
$activeCompanies = (clone $spotlightBase())
|
|
->where('published_at', '>=', $recentSince)
|
|
->where('published_at', '<=', $referenceNow)
|
|
->distinct()
|
|
->count('company_id');
|
|
|
|
$spotlight = [
|
|
'industry' => $spotlightTranslation->name,
|
|
'stats' => [
|
|
['label' => 'Meldungen (7 Tage)', 'value' => number_format($windowCount, 0, ',', '.'), 'sub' => 'Stand '.$referenceNow->translatedFormat('j. M Y')],
|
|
['label' => 'Aktive Unternehmen', 'value' => number_format($activeCompanies, 0, ',', '.'), 'sub' => 'in dieser Branche'],
|
|
['label' => 'Im Archiv', 'value' => number_format($archiveCount, 0, ',', '.'), 'sub' => 'Meldungen gesamt'],
|
|
],
|
|
'releases' => $spotlightReleases->all(),
|
|
];
|
|
}
|
|
}
|
|
|
|
// Ad-Hoc-Ticker aus den jüngsten echten Pressemitteilungen.
|
|
$tickerItems = $publishedQuery()
|
|
->orderByDesc('published_at')
|
|
->limit(8)
|
|
->get(['id', 'title', 'published_at'])
|
|
->map(static fn (PressRelease $release): array => [
|
|
'time' => $release->published_at?->format('H:i') ?? '',
|
|
'text' => Str::limit((string) $release->title, 70),
|
|
])
|
|
->all();
|
|
|
|
return [
|
|
'leadRelease' => $leadRelease,
|
|
'sideReleases' => $sideReleases,
|
|
'mostReadReleases' => $mostReadReleases,
|
|
'activeNewsrooms' => $activeNewsrooms,
|
|
'industryIndex' => $industryIndex,
|
|
'spotlight' => $spotlight,
|
|
'tickerItems' => $tickerItems,
|
|
'homeStats' => [
|
|
'publishedCount' => $publishedQuery()->count(),
|
|
'publishedToday' => $publishedQuery()->whereDate('published_at', $referenceNow->toDateString())->count(),
|
|
'archiveSince' => $oldestPublishedAt ? (int) Carbon::parse($oldestPublishedAt)->format('Y') : null,
|
|
],
|
|
];
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Bare Root: Publisher-Hub (pressekonto) oder Redirect auf die Default-Ausgabe.
|
|
// ---------------------------------------------------------------------------
|
|
Route::get('/', function () use ($applyWebDomainConfig) {
|
|
$domain = request()->getHost();
|
|
|
|
if (str_contains($domain, 'pressekonto')) {
|
|
$applyWebDomainConfig('pressekonto');
|
|
|
|
return view('web.pressekonto');
|
|
}
|
|
|
|
return redirect(request()->getSchemeAndHttpHost().'/'.SetEdition::DEFAULT_EDITION, 301);
|
|
})->name('home');
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Legacy .html-URLs -> saubere 301-Weiterleitung (vor der Edition-Gruppe, damit
|
|
// `category/x.html` nicht als Slug `x.html` in die Kategorie-Route läuft).
|
|
// ---------------------------------------------------------------------------
|
|
$htmlRedirect = static function (string $edition, string $path) {
|
|
$base = request()->getSchemeAndHttpHost();
|
|
$clean = trim((string) preg_replace('/\.html$/', '', $path), '/');
|
|
|
|
// Account/Anmeldung lebt im Publisher-Hub (pressekonto), nicht am Portal.
|
|
// Die öffentlichen Spokes haben kein eigenes Login-System (Hub-and-Spoke).
|
|
$hubAliases = [
|
|
'registration' => '/register',
|
|
'login' => '/login',
|
|
];
|
|
|
|
if (array_key_exists($clean, $hubAliases)) {
|
|
$hubUrl = rtrim((string) config('domains.domain_portal_url'), '/');
|
|
|
|
return redirect($hubUrl.$hubAliases[$clean], 301);
|
|
}
|
|
|
|
// Entfallene Rubriken-Übersicht -> Startseite der Ausgabe.
|
|
if ($clean === 'kategorien') {
|
|
return redirect($base.'/'.$edition, 301);
|
|
}
|
|
|
|
// Ratgeber-Strecke der Altseite (/de/presse/*.html) -> konsolidierte FAQ.
|
|
// Hier steckt der SEO-Wert; alle Einzelseiten bündeln wir 301 auf /faq.
|
|
if ($clean === 'presse' || str_starts_with($clean, 'presse/')) {
|
|
return redirect($base.'/'.$edition.'/faq', 301);
|
|
}
|
|
|
|
// Alte Firmen-Pressemappe (presskit/{id}/{slug}) entspricht dem Newsroom.
|
|
// Feinmapping über die Legacy-ID: companies.(legacy_portal, legacy_id) ist
|
|
// unique-indiziert, ein Lookup liefert den heutigen Newsroom-Slug. Bei
|
|
// gültigem öffentlichem Newsroom 301 direkt dorthin (ein Hop), sonst sauber
|
|
// aufs Verzeichnis statt in einen 404.
|
|
if (preg_match('#^presskit/(\d+)#', $clean, $presskitMatch) === 1) {
|
|
$legacyPortal = str_contains(request()->getHost(), 'presseecho')
|
|
? Portal::Presseecho->value
|
|
: Portal::Businessportal24->value;
|
|
|
|
$company = Company::query()
|
|
->withoutGlobalScope(PortalScope::class)
|
|
->where('legacy_portal', $legacyPortal)
|
|
->where('legacy_id', (int) $presskitMatch[1])
|
|
->first();
|
|
|
|
$hasPublicNewsroom = $company
|
|
&& $company->is_active
|
|
&& $company->pressReleases()
|
|
->withoutGlobalScope(PortalScope::class)
|
|
->visibleInArchive()
|
|
->exists();
|
|
|
|
return redirect(
|
|
$hasPublicNewsroom
|
|
? $base.'/'.$edition.'/newsroom/'.$company->slug
|
|
: $base.'/'.$edition.'/newsrooms',
|
|
301
|
|
);
|
|
}
|
|
|
|
// Pressemappen-Pfade ohne numerische ID -> Newsrooms-Verzeichnis.
|
|
if ($clean === 'presskit' || str_starts_with($clean, 'presskit/')) {
|
|
return redirect($base.'/'.$edition.'/newsrooms', 301);
|
|
}
|
|
|
|
// Legacy-Slugs des Altsystems (meist englisch) -> neue deutsche Pfade.
|
|
$staticAliases = [
|
|
'pricelist' => 'preise',
|
|
'service' => 'veroeffentlichen',
|
|
'contact' => 'kontakt',
|
|
'imprint' => 'impressum',
|
|
'terms' => 'agb',
|
|
'privacypolicy' => 'datenschutz',
|
|
];
|
|
|
|
// Bereits saubere Zielpfade der neuen Struktur (Pfad bleibt erhalten).
|
|
$staticPages = [
|
|
'preise', 'faq', 'kontakt', 'ueber-uns', 'veroeffentlichen',
|
|
'suche', 'newsrooms', 'api', 'team', 'partner', 'karriere',
|
|
'hilfe', 'impressum', 'datenschutz', 'agb', 'cookies',
|
|
];
|
|
|
|
if (isset($staticAliases[$clean])) {
|
|
$target = $staticAliases[$clean];
|
|
} elseif (str_starts_with($clean, 'category/') || str_starts_with($clean, 'press-release/')) {
|
|
$target = $clean;
|
|
} elseif ($clean === '' || str_contains($clean, '/')) {
|
|
$target = $clean;
|
|
} elseif (in_array($clean, $staticPages, true)) {
|
|
$target = $clean;
|
|
} else {
|
|
// Flache Detail-URL der Altseite: /{edition}/{slug}.html
|
|
$target = 'press-release/'.$clean;
|
|
}
|
|
|
|
return redirect(rtrim($base.'/'.$edition.'/'.$target, '/'), 301);
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Entfallene Sprach-/Regionsvarianten (de-ch, de-at) -> deutsche Ausgabe.
|
|
// Die echten Regionen-Übersichten (/region/{slug}) sind für einen späteren
|
|
// Schritt geplant; bis dahin verhindern diese 301 tote Bestands-Links.
|
|
// ---------------------------------------------------------------------------
|
|
$legacyRegions = ['de-ch', 'de-at'];
|
|
|
|
Route::get('{region}', function (string $region) {
|
|
return redirect(request()->getSchemeAndHttpHost().'/'.SetEdition::DEFAULT_EDITION, 301);
|
|
})->whereIn('region', $legacyRegions);
|
|
|
|
Route::get('{region}/{path}', function (string $region, string $path) {
|
|
return redirect(request()->getSchemeAndHttpHost().'/'.SetEdition::DEFAULT_EDITION, 301);
|
|
})->whereIn('region', $legacyRegions)->where('path', '.*');
|
|
|
|
Route::get('{edition}/{path}', function (string $edition, string $path) use ($htmlRedirect) {
|
|
return $htmlRedirect($edition, $path);
|
|
})->where('path', '.*\.html')->whereIn('edition', SetEdition::EDITIONS);
|
|
|
|
// Top-Level Legacy ohne Ausgabe-Präfix -> Default-Ausgabe.
|
|
Route::get('{path}', function (string $path) use ($htmlRedirect) {
|
|
return $htmlRedirect(SetEdition::DEFAULT_EDITION, $path);
|
|
})->where('path', '.*\.html');
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Öffentliche Inhalte je Ausgabe (Sprache): /de/... und /en/...
|
|
// ---------------------------------------------------------------------------
|
|
Route::prefix('{edition}')->whereIn('edition', SetEdition::EDITIONS)->group(function () use ($applyWebDomainConfig, $webHomeData) {
|
|
// Startseite je Ausgabe
|
|
Route::get('/', function (string $edition) use ($applyWebDomainConfig, $webHomeData) {
|
|
$domain = request()->getHost();
|
|
|
|
if (str_contains($domain, 'pressekonto')) {
|
|
return redirect(request()->getSchemeAndHttpHost().'/', 301);
|
|
}
|
|
|
|
[$domainKey, $portal, $view] = match (true) {
|
|
str_contains($domain, 'presseecho') => ['presseecho', Portal::Presseecho, 'web.presseecho'],
|
|
default => ['businessportal24', Portal::Businessportal24, 'web.businessportal24'],
|
|
};
|
|
|
|
$applyWebDomainConfig($domainKey);
|
|
|
|
return view($view, $webHomeData($portal, app()->getLocale()));
|
|
})->name('web.home');
|
|
|
|
// Kategorie / Branchenseite (z.B. /de/category/energie-klima)
|
|
Route::get('category/{slug}', CategoryController::class)->name('kategorie');
|
|
|
|
// Detailseite einer Pressemitteilung (z.B. /de/press-release/ki-revolution)
|
|
Route::get('press-release/{slug}', ReleaseDetailController::class)->name('release.detail');
|
|
|
|
// Preise / Tarife (datengetrieben: Plan + config/billing + config/credits)
|
|
Route::get('preise', PreiseController::class)->name('preise');
|
|
|
|
// Statische Seiten
|
|
Route::view('faq', 'web.faq')->name('faq');
|
|
Route::view('kontakt', 'web.kontakt')->name('kontakt');
|
|
Route::view('ueber-uns', 'web.ueber-uns')->name('ueber-uns');
|
|
Route::get('veroeffentlichen', VeroeffentlichenController::class)->name('veroeffentlichen');
|
|
Route::get('suche', SucheController::class)->name('suche');
|
|
Route::get('newsrooms', NewsroomsController::class)->name('newsrooms');
|
|
Route::get('newsroom/{slug}', NewsroomDetailController::class)->name('newsroom');
|
|
Route::view('api', 'web.api')->name('api');
|
|
Route::view('team', 'web.team')->name('team');
|
|
Route::view('partner', 'web.partner')->name('partner');
|
|
Route::view('karriere', 'web.karriere')->name('karriere');
|
|
Route::view('presse', 'web.presse')->name('presse');
|
|
Route::view('hilfe', 'web.hilfe')->name('hilfe');
|
|
Route::view('impressum', 'web.impressum')->name('impressum');
|
|
Route::view('datenschutz', 'web.datenschutz')->name('datenschutz');
|
|
Route::view('agb', 'web.agb')->name('agb');
|
|
Route::view('cookies', 'web.cookies')->name('cookies');
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Sprach-/Edition-unabhängige Routen (Dev-Varianten, API-Doku).
|
|
// ---------------------------------------------------------------------------
|
|
Route::get('/variant-1', function () {
|
|
$domain = request()->getHost();
|
|
if (str_contains($domain, 'presseecho')) {
|
|
return view('web.presseecho');
|
|
}
|
|
|
|
return view('web.businessportal24-variant-float-glow');
|
|
})->name('variant-1');
|
|
|
|
Route::get('/variant-2', function () {
|
|
$domain = request()->getHost();
|
|
if (str_contains($domain, 'presseecho')) {
|
|
return view('web.presseecho');
|
|
}
|
|
|
|
return view('web.businessportal24-variant-glass-gradient');
|
|
});
|
|
|
|
Route::get('/docs/api/v1', function () {
|
|
return response((string) file_get_contents(base_path('docs/api/v1.yml')), 200, [
|
|
'Content-Type' => 'application/yaml; charset=UTF-8',
|
|
]);
|
|
})->name('docs.api.v1');
|