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');