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 $applyWebDomainConfig = static function (string $domainKey): array { $domainConfig = config("domains.domains.{$domainKey}", []); config([ 'app.theme' => $domainConfig['theme'] ?? $domainKey, 'app.view_prefix' => $domainConfig['view_prefix'] ?? 'web', 'app.domain_name' => $domainConfig['domain_name'] ?? request()->getHost(), 'app.url' => $domainConfig['url'] ?? config('app.url'), ]); View::share('theme', $domainConfig['theme'] ?? $domainKey); View::share('viewPrefix', $domainConfig['view_prefix'] ?? 'web'); View::share('domainName', $domainConfig['domain_name'] ?? request()->getHost()); View::share('domainConfig', $domainConfig); View::share('domainUrl', $domainConfig['url'] ?? config('app.url')); View::share('assetUrl', DomainAssetContext::staticAssetOrigin($domainConfig)); DomainAssetContext::configureVite($domainConfig); return $domainConfig; }; $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, ], ]; }; // Kategorie-/Branchenseite je Ausgabe. $categoryPage = static function (string $edition, string $slug) use ($applyWebDomainConfig) { $domain = request()->getHost(); $language = app()->getLocale(); [$domainKey, $portal] = match (true) { str_contains($domain, 'presseecho') => ['presseecho', Portal::Presseecho], default => ['businessportal24', Portal::Businessportal24], }; $applyWebDomainConfig($domainKey); $portalValues = [$portal->value, Portal::Both->value]; $category = Category::query() ->whereIn('portal', $portalValues) ->where('is_active', true) ->whereHas('translations', fn (Builder $query) => $query->where('locale', $language)->where('slug', $slug)) ->with([ 'translations' => fn ($query) => $query->where('locale', $language), 'parent.translations' => fn ($query) => $query->where('locale', $language), ]) ->first(); if (! $category) { abort(404); } $childCategories = Category::query() ->where('parent_id', $category->id) ->where('is_active', true) ->with(['translations' => fn ($query) => $query->where('locale', $language)]) ->get(); $categoryIds = collect([$category->id])->merge($childCategories->pluck('id'))->unique()->values()->all(); $base = static fn (): Builder => PressRelease::query() ->withoutGlobalScope(PortalScope::class) ->forPortalValues($portalValues) ->forLanguage($language) ->visibleInArchive() ->whereIn('category_id', $categoryIds); // Kategoriespezifischer Stichtag: jüngste Meldung dieser Branche. $latest = $base()->max('published_at'); $referenceNow = $latest ? Carbon::parse($latest) : now(); if ($referenceNow->gt(now())) { $referenceNow = now(); } $recentSince = $referenceNow->copy()->subDays(7); $previousSince = $referenceNow->copy()->subDays(14); $monthSince = $referenceNow->copy()->subDays(30); $with = [ 'company', 'category.translations' => fn ($query) => $query->where('locale', $language), 'images' => fn ($query) => $query->orderByDesc('is_preview')->orderBy('sort_order')->limit(1), ]; $leadRelease = $base()->with($with)->orderByDesc('published_at')->first(); $topReleases = $base() ->with($with) ->when($leadRelease, fn (Builder $query) => $query->whereKeyNot($leadRelease->getKey())) ->orderByDesc('published_at') ->limit(2) ->get(); $excludeIds = collect([$leadRelease?->getKey()])->merge($topReleases->modelKeys())->filter()->all(); $feedReleases = $base() ->with($with) ->when($excludeIds !== [], fn (Builder $query) => $query->whereNotIn('id', $excludeIds)) ->orderByDesc('published_at') ->limit(12) ->get(); $mostReadReleases = $base() ->orderByDesc('hits') ->orderByDesc('published_at') ->limit(5) ->get(['id', 'slug', 'title', 'hits', 'portal', 'language']); $stats = [ 'today' => $base()->whereDate('published_at', $referenceNow->toDateString())->count(), 'week' => $base()->where('published_at', '>=', $recentSince)->count(), 'previousWeek' => $base()->where('published_at', '>=', $previousSince)->where('published_at', '<', $recentSince)->count(), 'month' => $base()->where('published_at', '>=', $monthSince)->count(), 'total' => $base()->count(), 'activeCompanies' => $base()->where('published_at', '>=', $recentSince)->distinct()->count('company_id'), 'totalCompanies' => $base()->distinct()->count('company_id'), ]; $newsroomFilter = static function (Builder $query) use ($portalValues, $categoryIds, $language): void { $query ->forPortalValues($portalValues) ->forLanguage($language) ->published() ->whereIn('category_id', $categoryIds); }; $topNewsrooms = Company::query() ->withoutGlobalScope(PortalScope::class) ->whereIn('portal', $portalValues) ->where('is_active', true) ->whereHas('pressReleases', function (Builder $query) use ($newsroomFilter, $recentSince): void { $newsroomFilter($query); $query->where('published_at', '>=', $recentSince); }) ->withCount([ 'pressReleases as recent_releases_count' => function (Builder $query) use ($newsroomFilter, $recentSince): void { $newsroomFilter($query); $query->where('published_at', '>=', $recentSince); }, 'pressReleases as today_releases_count' => function (Builder $query) use ($newsroomFilter, $referenceNow): void { $newsroomFilter($query); $query->whereDate('published_at', $referenceNow->toDateString()); }, ]) ->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, ]); $publishedCount = static fn (Builder $query) => $query ->forPortalValues($portalValues) ->forLanguage($language) ->published(); $subCategories = $childCategories ->map(function (Category $child) use ($recentSince, $previousSince, $publishedCount): ?array { $translation = $child->translations->first(); if (! $translation) { return null; } $total = PressRelease::query()->withoutGlobalScope(PortalScope::class) ->where('category_id', $child->id)->tap($publishedCount)->count(); $recent = PressRelease::query()->withoutGlobalScope(PortalScope::class) ->where('category_id', $child->id)->tap($publishedCount)->where('published_at', '>=', $recentSince)->count(); $previous = PressRelease::query()->withoutGlobalScope(PortalScope::class) ->where('category_id', $child->id)->tap($publishedCount) ->where('published_at', '>=', $previousSince)->where('published_at', '<', $recentSince)->count(); return [ 'name' => $translation->name, 'href' => $translation->slug ? route('kategorie', ['slug' => $translation->slug]) : route('web.home'), 'total' => $total, 'recent' => $recent, 'delta' => $recent - $previous, ]; }) ->filter() ->sortByDesc('total') ->values(); $relatedCategories = Category::query() ->whereIn('portal', $portalValues) ->where('is_active', true) ->whereNull('parent_id') ->whereKeyNot($category->id) ->when($category->parent_id, fn (Builder $query) => $query->whereKeyNot($category->parent_id)) ->with(['translations' => fn ($query) => $query->where('locale', $language)]) ->withCount([ 'pressReleases as total_count' => fn (Builder $query) => $publishedCount($query), ]) ->orderByDesc('total_count') ->limit(8) ->get() ->map(function (Category $related): ?array { $translation = $related->translations->first(); if (! $translation) { return null; } return [ 'name' => $translation->name, 'href' => $translation->slug ? route('kategorie', ['slug' => $translation->slug]) : route('web.home'), 'count' => (int) ($related->total_count ?? 0), ]; }) ->filter() ->values(); return view('web.kategorie', [ 'category' => $category, 'referenceNow' => $referenceNow, 'leadRelease' => $leadRelease, 'topReleases' => $topReleases, 'feedReleases' => $feedReleases, 'mostReadReleases' => $mostReadReleases, 'categoryStats' => $stats, 'topNewsrooms' => $topNewsrooms, 'subCategories' => $subCategories, 'relatedCategories' => $relatedCategories, ]); }; // Detailseite einer Pressemitteilung je Ausgabe. $releaseDetailPage = static function (string $edition, string $slug) use ($applyWebDomainConfig) { $domain = request()->getHost(); $language = app()->getLocale(); [$domainKey, $portal] = match (true) { str_contains($domain, 'presseecho') => ['presseecho', Portal::Presseecho], default => ['businessportal24', Portal::Businessportal24], }; $applyWebDomainConfig($domainKey); $portalValues = [$portal->value, Portal::Both->value]; $publishedScope = static fn (Builder $query): Builder => $query ->forPortalValues($portalValues) ->forLanguage($language) ->visibleInArchive(); $release = PressRelease::query() ->withoutGlobalScope(PortalScope::class) ->tap($publishedScope) ->where('slug', $slug) ->with([ 'company', 'category.translations' => fn ($query) => $query->where('locale', $language), 'category.parent.translations' => fn ($query) => $query->where('locale', $language), 'images' => fn ($query) => $query->orderByDesc('is_preview')->orderBy('sort_order'), 'contacts', ]) ->first(); if (! $release) { abort(404); } PressRelease::withoutGlobalScope(PortalScope::class) ->whereKey($release->getKey()) ->update(['hits' => ($release->hits ?? 0) + 1]); $with = [ 'company', 'category.translations' => fn ($query) => $query->where('locale', $language), 'images' => fn ($query) => $query->orderByDesc('is_preview')->orderBy('sort_order')->limit(1), ]; $companyReleases = $release->company_id ? PressRelease::query() ->withoutGlobalScope(PortalScope::class) ->tap($publishedScope) ->where('company_id', $release->company_id) ->whereKeyNot($release->getKey()) ->with($with) ->orderByDesc('published_at') ->limit(3) ->get() : collect(); $relatedReleases = $release->category_id ? PressRelease::query() ->withoutGlobalScope(PortalScope::class) ->tap($publishedScope) ->where('category_id', $release->category_id) ->whereKeyNot($release->getKey()) ->when($companyReleases->isNotEmpty(), fn (Builder $query) => $query->whereNotIn('id', $companyReleases->modelKeys())) ->with($with) ->orderByDesc('published_at') ->limit(3) ->get() : collect(); return view('web.release-detail', [ 'release' => $release, 'companyReleases' => $companyReleases, 'relatedReleases' => $relatedReleases, ]); }; // Veröffentlichen-Marketingseite je Ausgabe (Einreichung läuft im Publisher-Hub). $veroeffentlichenPage = static function (string $edition) use ($applyWebDomainConfig) { $domain = request()->getHost(); $language = app()->getLocale(); [$domainKey, $portal] = match (true) { str_contains($domain, 'presseecho') => ['presseecho', Portal::Presseecho], default => ['businessportal24', Portal::Businessportal24], }; $applyWebDomainConfig($domainKey); $portalValues = [$portal->value, Portal::Both->value]; $referenceNow = EditorialClock::reference($portalValues, $language); $recentSince = $referenceNow->copy()->subDays(30); $publishedBase = static fn (): Builder => PressRelease::query() ->withoutGlobalScope(PortalScope::class) ->forPortalValues($portalValues) ->forLanguage($language) ->visibleInArchive(); $archiveTotal = $publishedBase()->count(); $publishedToday = $publishedBase()->whereDate('published_at', $referenceNow->toDateString())->count(); $oldest = $publishedBase()->min('published_at'); $oldestYear = $oldest ? (int) Carbon::parse($oldest)->format('Y') : null; $inReview = PressRelease::query() ->withoutGlobalScope(PortalScope::class) ->whereIn('portal', $portalValues) ->where('status', PressReleaseStatus::Review) ->where('language', $language) ->count(); $exampleRelease = $publishedBase() ->with([ 'company', 'category.translations' => fn ($query) => $query->where('locale', $language), ]) ->orderByDesc('published_at') ->first(); $newsroomFilter = static function (Builder $query) use ($portalValues, $language, $recentSince): void { $query ->forPortalValues($portalValues) ->forLanguage($language) ->published() ->where('published_at', '>=', $recentSince); }; $newsroomQuery = Company::query() ->withoutGlobalScope(PortalScope::class) ->whereIn('portal', $portalValues) ->where('is_active', true) ->whereHas('pressReleases', fn (Builder $query) => $newsroomFilter($query)); $activeNewsroomsTotal = (clone $newsroomQuery)->count(); $activeNewsroomNames = (clone $newsroomQuery) ->withCount(['pressReleases as recent_count' => fn (Builder $query) => $newsroomFilter($query)]) ->orderByDesc('recent_count') ->limit(10) ->pluck('name') ->all(); return view('web.veroeffentlichen', [ 'referenceNow' => $referenceNow, 'archiveTotal' => $archiveTotal, 'publishedToday' => $publishedToday, 'inReview' => $inReview, 'oldestYear' => $oldestYear, 'exampleRelease' => $exampleRelease, 'activeNewsroomNames' => $activeNewsroomNames, 'activeNewsroomsTotal' => $activeNewsroomsTotal, ]); }; // Preise-/Tarifseite je Ausgabe: Abos (Plan-Modell), Einzel-PM (config/billing) // und Add-on-Einzelkosten (config/credits). Inhalte liegen in lang/*/pricing.php. $preisePage = static function (string $edition) use ($applyWebDomainConfig) { $domain = request()->getHost(); $domainKey = str_contains($domain, 'presseecho') ? 'presseecho' : 'businessportal24'; $applyWebDomainConfig($domainKey); // Add-on-Einzelkosten aus der Credit-Ökonomie (1 Credit = 1 €). $extraPm = collect(config('credits.extra_pm', [])) ->map(static fn (int $credits, string $tier): array => [ 'tier' => Tier::tryFrom($tier)?->label() ?? ucfirst($tier), 'credits' => $credits, ]) ->values() ->all(); $boost = collect(config('credits.boost', [])) ->map(static fn (int $credits, int|string $days): array => [ 'days' => (int) $days, 'credits' => $credits, ]) ->values() ->all(); return view('web.preise', [ 'plans' => Plan::query()->active()->get(), 'singlePmPriceCents' => (int) config('billing.single_pm_price_cents'), 'extraPm' => $extraPm, 'boost' => $boost, 'proofPdf' => (int) config('credits.proof_pdf'), 'creditPacks' => config('credits.packs', []), // TODO: Lineup/Preise hängen am Bookings-/Magic-Link-Rework — final abstimmen. 'morePaths' => [ 'correction' => (int) config('credits.paths.correction'), 'update' => (int) config('credits.paths.update'), 'depublish' => (int) config('credits.depublish'), ], ]); }; // Archiv-Suchseite je Ausgabe (interaktive Suche läuft in der Volt-Komponente). $suchePage = static function (string $edition) use ($applyWebDomainConfig) { $domain = request()->getHost(); $language = app()->getLocale(); [$domainKey, $portal] = match (true) { str_contains($domain, 'presseecho') => ['presseecho', Portal::Presseecho], default => ['businessportal24', Portal::Businessportal24], }; $applyWebDomainConfig($domainKey); $portalValues = [$portal->value, Portal::Both->value]; $mostReadReleases = PressRelease::query() ->withoutGlobalScope(PortalScope::class) ->forPortalValues($portalValues) ->forLanguage($language) ->visibleInArchive() ->orderByDesc('hits') ->orderByDesc('published_at') ->limit(5) ->get(['id', 'slug', 'title', 'hits', 'portal', 'language']); return view('web.suche', [ 'portal' => $portal->value, 'language' => $language, 'mostReadReleases' => $mostReadReleases, ]); }; // Newsrooms-Verzeichnis je Ausgabe: alle aktiven Unternehmen mit Meldungen. $newsroomsIndex = static function (string $edition) use ($applyWebDomainConfig) { $domain = request()->getHost(); $language = app()->getLocale(); [$domainKey, $portal] = match (true) { str_contains($domain, 'presseecho') => ['presseecho', Portal::Presseecho], default => ['businessportal24', Portal::Businessportal24], }; $applyWebDomainConfig($domainKey); $portalValues = [$portal->value, Portal::Both->value]; $referenceNow = EditorialClock::reference($portalValues, $language); $recentSince = $referenceNow->copy()->subDays(30); $term = trim((string) request('q', '')); $published = static function (Builder $query) use ($portalValues, $language): void { $query ->forPortalValues($portalValues) ->forLanguage($language) ->visibleInArchive(); }; $activeCompanies = static fn (): Builder => Company::query() ->withoutGlobalScope(PortalScope::class) ->whereIn('portal', $portalValues) ->where('is_active', true) ->whereHas('pressReleases', fn (Builder $query) => $published($query)); $newsrooms = $activeCompanies() ->when($term !== '', fn (Builder $query) => $query->whereLike('name', '%'.$term.'%')) ->withCount([ 'pressReleases as releases_count' => fn (Builder $query) => $published($query), 'pressReleases as recent_count' => function (Builder $query) use ($published, $recentSince): void { $published($query); $query->where('published_at', '>=', $recentSince); }, ]) ->withMax(['pressReleases as latest_published_at' => fn (Builder $query) => $published($query)], 'published_at') ->orderByDesc('recent_count') ->orderByDesc('releases_count') ->orderBy('name') ->paginate(24) ->withQueryString(); $archiveTotal = PressRelease::query() ->withoutGlobalScope(PortalScope::class) ->tap(fn (Builder $query) => $published($query)) ->count(); return view('web.newsrooms', [ 'newsrooms' => $newsrooms, 'term' => $term, 'referenceNow' => $referenceNow, 'totalNewsrooms' => (clone $activeCompanies())->count(), 'activeNewsroomsCount' => $activeCompanies() ->whereHas('pressReleases', function (Builder $query) use ($published, $recentSince): void { $published($query); $query->where('published_at', '>=', $recentSince); }) ->count(), 'archiveTotal' => $archiveTotal, ]); }; // Newsroom-Markenseite eines Unternehmens je Ausgabe: alle Meldungen der Firma. $newsroomDetail = static function (string $edition, string $slug) use ($applyWebDomainConfig) { $domain = request()->getHost(); $language = app()->getLocale(); [$domainKey, $portal] = match (true) { str_contains($domain, 'presseecho') => ['presseecho', Portal::Presseecho], default => ['businessportal24', Portal::Businessportal24], }; $applyWebDomainConfig($domainKey); $portalValues = [$portal->value, Portal::Both->value]; $company = Company::query() ->withoutGlobalScope(PortalScope::class) ->whereIn('portal', $portalValues) ->where('is_active', true) ->where('slug', $slug) ->with(['contacts']) ->first(); if (! $company) { abort(404); } $base = static fn (): Builder => PressRelease::query() ->withoutGlobalScope(PortalScope::class) ->where('company_id', $company->id) ->forPortalValues($portalValues) ->forLanguage($language) ->visibleInArchive(); // Keine öffentliche Newsroom-Seite ohne veröffentlichte Meldung. if (! $base()->exists()) { abort(404); } $latest = $base()->max('published_at'); $referenceNow = $latest ? Carbon::parse($latest) : now(); if ($referenceNow->gt(now())) { $referenceNow = now(); } $recentSince = $referenceNow->copy()->subDays(30); $oldest = $base()->min('published_at'); $with = [ 'company', 'category.translations' => fn ($query) => $query->where('locale', $language), 'images' => fn ($query) => $query->orderByDesc('is_preview')->orderBy('sort_order')->limit(1), ]; $leadRelease = $base()->with($with)->orderByDesc('published_at')->first(); $releases = $base() ->with($with) ->when($leadRelease, fn (Builder $query) => $query->whereKeyNot($leadRelease->getKey())) ->orderByDesc('published_at') ->paginate(10) ->withQueryString(); $mostReadReleases = $base() ->orderByDesc('hits') ->orderByDesc('published_at') ->limit(5) ->get(['id', 'slug', 'title', 'hits', 'portal', 'language']); $companyFilter = static function (Builder $query) use ($company, $portalValues, $language): void { $query ->withoutGlobalScope(PortalScope::class) ->where('company_id', $company->id) ->forPortalValues($portalValues) ->forLanguage($language) ->published(); }; $categories = Category::query() ->whereIn('portal', $portalValues) ->whereHas('pressReleases', fn (Builder $query) => $companyFilter($query)) ->with(['translations' => fn ($query) => $query->where('locale', $language)]) ->withCount(['pressReleases as releases_count' => fn (Builder $query) => $companyFilter($query)]) ->orderByDesc('releases_count') ->limit(8) ->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->releases_count ?? 0), ]; }) ->filter() ->values(); return view('web.newsroom', [ 'company' => $company, 'referenceNow' => $referenceNow, 'leadRelease' => $leadRelease, 'releases' => $releases, 'mostReadReleases' => $mostReadReleases, 'categories' => $categories, 'newsroomStats' => [ 'total' => $base()->count(), 'recent' => $base()->where('published_at', '>=', $recentSince)->count(), 'today' => $base()->whereDate('published_at', $referenceNow->toDateString())->count(), 'totalHits' => (int) $base()->sum('hits'), 'sinceYear' => $oldest ? (int) Carbon::parse($oldest)->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, $categoryPage, $releaseDetailPage, $veroeffentlichenPage, $suchePage, $newsroomsIndex, $newsroomDetail, $preisePage) { // 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}', $categoryPage)->name('kategorie'); // Detailseite einer Pressemitteilung (z.B. /de/press-release/ki-revolution) Route::get('press-release/{slug}', $releaseDetailPage)->name('release.detail'); // Preise / Tarife (datengetrieben: Plan + config/billing + config/credits) Route::get('preise', $preisePage)->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', $veroeffentlichenPage)->name('veroeffentlichen'); Route::get('suche', $suchePage)->name('suche'); Route::get('newsrooms', $newsroomsIndex)->name('newsrooms'); Route::get('newsroom/{slug}', $newsroomDetail)->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');