Editorial-Frontend: oeffentliche Unterseiten, Preise & Linkpflege
Oeffentliche Strecke durchgaengig auf das Editorial-Design umgestellt und
datengetrieben gemacht:
- Newsrooms-Verzeichnis + Newsroom-Detailseiten (Suche, Pagination)
- Preise-Seite neu (Abos via Plan, Einzel-PM via config/billing, Add-ons via
config/credits); Texte ausgelagert nach lang/{de,en}/pricing.php
- Pricing-Teaser-Komponente auf den Startseiten + Preise-Link in Header/Footer
- Statische Seiten im Editorial-Design ueber neue Komponente x-web.static-page:
impressum, datenschutz, agb, cookies, faq (Akkordeon), hilfe, kontakt,
ueber-uns, api (altes Theme/gradient-hero entfernt)
- Header/Footer-Linkpflege: tote #-Anker raus, FAQ/Hilfe/Cookies verlinkt,
"Anmelden" fuehrt in den Hub
- Legacy-URL-Redirects (.html, presskit->newsroom, Regionen)
- Datums-Bugfix in Feed-Komponenten
Tests: NewsroomPage, PreisePage, PricingTeaser, StaticPages, NavigationLinks,
LegacyRedirect. Doku in docs/frontend aktualisiert.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
0618080e02
commit
326ff9b2a0
34 changed files with 2842 additions and 3102 deletions
341
routes/web.php
341
routes/web.php
|
|
@ -2,10 +2,12 @@
|
|||
|
||||
use App\Enums\Portal;
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use App\Enums\Tier;
|
||||
use App\Http\Controllers\PressReleasePreviewController;
|
||||
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;
|
||||
|
|
@ -643,6 +645,47 @@ $veroeffentlichenPage = static function (string $edition) use ($applyWebDomainCo
|
|||
]);
|
||||
};
|
||||
|
||||
// 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();
|
||||
|
|
@ -676,6 +719,195 @@ $suchePage = static function (string $edition) use ($applyWebDomainConfig) {
|
|||
]);
|
||||
};
|
||||
|
||||
// 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
|
||||
->whereIn('portal', $portalValues)
|
||||
->where('status', PressReleaseStatus::Published)
|
||||
->where('language', $language)
|
||||
->whereNotNull('published_at')
|
||||
->where('published_at', '<=', now());
|
||||
};
|
||||
|
||||
$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)
|
||||
->whereIn('portal', $portalValues)
|
||||
->where('status', PressReleaseStatus::Published)
|
||||
->where('language', $language)
|
||||
->whereNotNull('published_at')
|
||||
->where('published_at', '<=', now());
|
||||
|
||||
// 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)
|
||||
->whereIn('portal', $portalValues)
|
||||
->where('status', PressReleaseStatus::Published)
|
||||
->where('language', $language)
|
||||
->whereNotNull('published_at');
|
||||
};
|
||||
|
||||
$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.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -696,20 +928,91 @@ Route::get('/', function () use ($applyWebDomainConfig) {
|
|||
// `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), '/');
|
||||
|
||||
$staticPages = [
|
||||
'preise', 'faq', 'kontakt', 'ueber-uns', 'veroeffentlichen',
|
||||
'suche', 'newsrooms', 'api', 'team', 'partner', 'karriere', 'presse',
|
||||
'hilfe', 'impressum', 'datenschutz', 'agb', 'cookies',
|
||||
// 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(request()->getSchemeAndHttpHost().'/'.$edition, 301);
|
||||
return redirect($base.'/'.$edition, 301);
|
||||
}
|
||||
|
||||
if (str_starts_with($clean, 'category/') || str_starts_with($clean, 'press-release/')) {
|
||||
// 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)
|
||||
->where('status', PressReleaseStatus::Published)
|
||||
->whereNotNull('published_at')
|
||||
->where('published_at', '<=', now())
|
||||
->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;
|
||||
|
|
@ -720,9 +1023,24 @@ $htmlRedirect = static function (string $edition, string $path) {
|
|||
$target = 'press-release/'.$clean;
|
||||
}
|
||||
|
||||
return redirect(request()->getSchemeAndHttpHost().rtrim('/'.$edition.'/'.$target, '/'), 301);
|
||||
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);
|
||||
|
|
@ -735,7 +1053,7 @@ Route::get('{path}', function (string $path) use ($htmlRedirect) {
|
|||
// ---------------------------------------------------------------------------
|
||||
// Öffentliche Inhalte je Ausgabe (Sprache): /de/... und /en/...
|
||||
// ---------------------------------------------------------------------------
|
||||
Route::prefix('{edition}')->whereIn('edition', SetEdition::EDITIONS)->group(function () use ($applyWebDomainConfig, $webHomeData, $categoryPage, $releaseDetailPage, $veroeffentlichenPage, $suchePage) {
|
||||
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();
|
||||
|
|
@ -760,14 +1078,17 @@ Route::prefix('{edition}')->whereIn('edition', SetEdition::EDITIONS)->group(func
|
|||
// 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('preise', 'web.preise')->name('preise');
|
||||
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::view('newsrooms', 'web.newsrooms')->name('newsrooms');
|
||||
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');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue