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); // --------------------------------------------------------------------------- // 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', app(HubPageData::class)->build()); } 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. // team/partner/karriere: Seiten wurden zurückgebaut (R3.2) -> Startseite. $staticAliases = [ 'pricelist' => 'preise', 'service' => 'veroeffentlichen', 'contact' => 'kontakt', 'imprint' => 'impressum', 'terms' => 'agb', 'privacypolicy' => 'datenschutz', 'team' => '', 'partner' => '', 'karriere' => '', ]; // Bereits saubere Zielpfade der neuen Struktur (Pfad bleibt erhalten). $staticPages = [ 'preise', 'faq', 'kontakt', 'ueber-uns', 'veroeffentlichen', 'suche', 'newsrooms', 'api', '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 () { // Startseite je Ausgabe Route::get('/', HomeController::class)->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::post('kontakt', ContactController::class) ->middleware('throttle:10,1') ->name('kontakt.store'); 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('hilfe', 'web.hilfe')->name('hilfe'); Route::view('impressum', 'web.impressum')->name('impressum'); // Gesetzlicher Kündigungsbutton (§ 312k BGB): öffentliche Bestätigungsseite // + Eingang der Kündigungserklärung, ohne Login erreichbar. Route::get('kuendigung', [TerminationController::class, 'show'])->name('kuendigung'); Route::post('kuendigung', [TerminationController::class, 'store']) ->middleware('throttle:10,1') ->name('kuendigung.store'); Route::view('datenschutz', 'web.datenschutz')->name('datenschutz'); Route::view('agb', 'web.agb')->name('agb'); Route::view('cookies', 'web.cookies')->name('cookies'); }); // --------------------------------------------------------------------------- // SEO: Sitemap + robots.txt, host-aware (Hub vs. Spokes). Die frühere // statische public/robots.txt wurde entfernt — sie hätte diese Routen // verschattet (Webserver liefert existierende Dateien direkt aus). // --------------------------------------------------------------------------- Route::get('/sitemap.xml', SitemapController::class)->name('sitemap'); Route::get('/robots.txt', function () { $base = rtrim(request()->getSchemeAndHttpHost(), '/'); $lines = [ 'User-agent: *', 'Disallow: /admin', 'Disallow: /login', 'Disallow: /register', 'Disallow: /de/suche', 'Disallow: /en/suche', '', 'Sitemap: '.$base.'/sitemap.xml', '', ]; return response(implode("\n", $lines), 200, ['Content-Type' => 'text/plain; charset=UTF-8']); })->name('robots'); // --------------------------------------------------------------------------- // Sprach-/Edition-unabhängige Routen (API-Doku). // --------------------------------------------------------------------------- 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');