From 4616bb7aad0acd2c81a796d470775645ef532c61 Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Thu, 17 Sep 2026 10:25:29 +0000 Subject: [PATCH] web: Jahresarchiv je Branche, Kategoriebeschreibungen, Portalname im Titel (Roadmap 2.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /de/category/{slug}/archiv/{jahr} (CategoryArchivePageData, CategoryArchiveController, View kategorie-archiv): 30 Meldungen je Seite, neueste zuerst, Jahresleiste mit Zahlen; ohne Jahr das juengste Jahr mit Meldungen. Canonical mit ?page=N auf Folgeseiten, hreflang nur auf Seite 1, CollectionPage + BreadcrumbList. Gecacht bis Seite 3. Leere Jahre, unbekannte Kategorien, leere Folgeseiten: 404. - Branchenseite: Jahresleiste unter dem Feed (archiveYears, eine gruppierte Query); Sitemap listet alle Jahresarchive. - categories:seed-descriptions {--dry-run} {--force}: fuellt leere DE/EN Beschreibungen aus database/seeders/data/category-descriptions.php (14 Kategorien, sachlich, ohne Werbeaussagen), leert den Seiten-Cache. - Branchenseite: Titel trug config('app.name') ("– pressekonto") statt des Portalnamens — jetzt presseecho/businessportal24. - Protokoll Teil 5 (2.3 mit Befund: 0 von 14 Beschreibungen, Sonstiges = 28 % des Bestands), Roadmap 2.3 abgehakt. Co-Authored-By: Claude Opus 5 (1M context) --- .../Commands/SeedCategoryDescriptions.php | 83 ++++++++ .../Web/CategoryArchiveController.php | 22 +++ .../Controllers/Web/SitemapController.php | 27 ++- app/Services/Web/CategoryArchivePageData.php | 150 ++++++++++++++ app/Services/Web/CategoryPageData.php | 2 + .../seeders/data/category-descriptions.php | 66 +++++++ dev/2026-09-01/roadmap-2026.md | 2 +- dev/2026-09-01/umsetzung-protokoll.md | 52 ++++- lang/de/web.php | 17 ++ lang/en/web.php | 17 ++ .../views/web/kategorie-archiv.blade.php | 186 ++++++++++++++++++ resources/views/web/kategorie.blade.php | 21 +- routes/web.php | 6 + tests/Feature/Web/CategoryArchiveTest.php | 111 +++++++++++ .../Web/SeedCategoryDescriptionsTest.php | 44 +++++ 15 files changed, 793 insertions(+), 13 deletions(-) create mode 100644 app/Console/Commands/SeedCategoryDescriptions.php create mode 100644 app/Http/Controllers/Web/CategoryArchiveController.php create mode 100644 app/Services/Web/CategoryArchivePageData.php create mode 100644 database/seeders/data/category-descriptions.php create mode 100644 resources/views/web/kategorie-archiv.blade.php create mode 100644 tests/Feature/Web/CategoryArchiveTest.php create mode 100644 tests/Feature/Web/SeedCategoryDescriptionsTest.php diff --git a/app/Console/Commands/SeedCategoryDescriptions.php b/app/Console/Commands/SeedCategoryDescriptions.php new file mode 100644 index 00000000..c4c1735f --- /dev/null +++ b/app/Console/Commands/SeedCategoryDescriptions.php @@ -0,0 +1,83 @@ +> $texts */ + $texts = require database_path('seeders/data/category-descriptions.php'); + + $rows = []; + $written = 0; + + foreach ($texts as $slug => $byLocale) { + $category = Category::query() + ->whereHas('translations', fn ($query) => $query->where('locale', 'de')->where('slug', $slug)) + ->with('translations') + ->first(); + + if (! $category) { + $rows[] = [$slug, '—', 'Kategorie nicht gefunden']; + + continue; + } + + foreach ($byLocale as $locale => $text) { + /** @var CategoryTranslation|null $translation */ + $translation = $category->translations->firstWhere('locale', $locale); + + if (! $translation) { + $rows[] = [$slug, $locale, 'keine Übersetzung']; + + continue; + } + + if (filled($translation->description) && ! $this->option('force')) { + $rows[] = [$slug, $locale, 'bereits vorhanden, übersprungen']; + + continue; + } + + $rows[] = [$slug, $locale, mb_strimwidth($text, 0, 60, '…')]; + + if (! $this->option('dry-run')) { + $translation->forceFill(['description' => $text])->save(); + $written++; + } + } + } + + $this->table(['Kategorie', 'Sprache', 'Ergebnis'], $rows); + + if ($this->option('dry-run')) { + $this->info('[Dry-Run] Nichts geschrieben.'); + + return self::SUCCESS; + } + + WebPageCache::flush(); + $this->info(sprintf('%d Beschreibungen geschrieben, Seiten-Cache geleert.', $written)); + + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/Web/CategoryArchiveController.php b/app/Http/Controllers/Web/CategoryArchiveController.php new file mode 100644 index 00000000..6b0baac1 --- /dev/null +++ b/app/Http/Controllers/Web/CategoryArchiveController.php @@ -0,0 +1,22 @@ +build( + WebPortalContext::fromRequest(), + $slug, + $year === null ? null : (int) $year, + )); + } +} diff --git a/app/Http/Controllers/Web/SitemapController.php b/app/Http/Controllers/Web/SitemapController.php index ed893e1d..95cceed3 100644 --- a/app/Http/Controllers/Web/SitemapController.php +++ b/app/Http/Controllers/Web/SitemapController.php @@ -7,6 +7,7 @@ use App\Models\Category; use App\Models\Company; use App\Models\PressRelease; use App\Scopes\PortalScope; +use App\Services\Web\CategoryArchivePageData; use App\Support\WebPortalContext; use Illuminate\Http\Request; use Illuminate\Http\Response; @@ -76,18 +77,28 @@ class SitemapController extends Controller $urls[] = ['loc' => $base.'/de/'.$page, 'lastmod' => null]; } - // Kategorien über die lokalisierten Slugs (CategoryTranslation). - $categorySlugs = Category::query() + // Kategorien über die lokalisierten Slugs (CategoryTranslation), + // dazu je Kategorie die Jahresarchive (2.3). + $categories = Category::query() ->where('is_active', true) ->whereIn('portal', $context->portalValues) - ->with('translations') - ->get() - ->map(fn (Category $category) => $category->translations->firstWhere('locale', 'de')?->slug) - ->filter() - ->unique(); + ->with(['translations', 'children' => fn ($query) => $query->where('is_active', true)]) + ->get(); + + foreach ($categories as $category) { + $slug = $category->translations->firstWhere('locale', 'de')?->slug; + + if (! $slug) { + continue; + } - foreach ($categorySlugs as $slug) { $urls[] = ['loc' => $base.'/de/category/'.$slug, 'lastmod' => null]; + + $categoryIds = $category->children->pluck('id')->prepend($category->id)->unique()->values()->all(); + + foreach (array_keys(CategoryArchivePageData::yearsWithCounts($categoryIds, $context->portalValues, 'de')) as $year) { + $urls[] = ['loc' => $base.'/de/category/'.$slug.'/archiv/'.$year, 'lastmod' => null]; + } } // Öffentliche Newsrooms: aktive Firmen mit mindestens einer im Archiv diff --git a/app/Services/Web/CategoryArchivePageData.php b/app/Services/Web/CategoryArchivePageData.php new file mode 100644 index 00000000..b042b71e --- /dev/null +++ b/app/Services/Web/CategoryArchivePageData.php @@ -0,0 +1,150 @@ + + */ + public function build(WebPortalContext $context, string $slug, ?int $year): array + { + $page = Paginator::resolveCurrentPage(); + + $build = fn (): array => $this->buildFresh($context, $slug, $year, $page); + + if ($page > self::MaxCachedPage) { + return $build(); + } + + return WebPageCache::remember( + WebPageCache::key('category-archive', [$context->portal->value, $context->language, $slug, (string) $year, 'page', $page]), + $build + ); + } + + /** + * Jahre mit Meldungen einer Branche (inkl. Unterkategorien), absteigend + * — auch für die Jahresleiste auf der Branchenseite. + * + * @param list $categoryIds + * @param list $portalValues + * @return array Jahr => Anzahl + */ + public static function yearsWithCounts(array $categoryIds, array $portalValues, string $language): array + { + $yearExpression = DB::connection()->getDriverName() === 'sqlite' + ? "CAST(strftime('%Y', published_at) AS INTEGER)" + : 'YEAR(published_at)'; + + return PressRelease::query() + ->withoutGlobalScope(PortalScope::class) + ->forPortalValues($portalValues) + ->forLanguage($language) + ->visibleInArchive() + ->whereIn('category_id', $categoryIds) + ->selectRaw($yearExpression.' as year, COUNT(*) as releases_count') + ->groupBy('year') + ->orderByDesc('year') + ->pluck('releases_count', 'year') + ->map(fn ($count): int => (int) $count) + ->all(); + } + + /** + * @return array + */ + private function buildFresh(WebPortalContext $context, string $slug, ?int $year, int $page): array + { + $portalValues = $context->portalValues; + $language = $context->language; + + $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); + } + + $categoryIds = Category::query() + ->where('parent_id', $category->id) + ->where('is_active', true) + ->pluck('id') + ->prepend($category->id) + ->unique() + ->values() + ->all(); + + $years = self::yearsWithCounts($categoryIds, $portalValues, $language); + + if ($years === []) { + abort(404); + } + + // Ohne Jahr: das jüngste Jahr mit Meldungen (nicht das Kalenderjahr — + // eine Branche ohne Meldung im laufenden Jahr hätte sonst eine leere Seite). + $year ??= (int) array_key_first($years); + + if (! isset($years[$year])) { + abort(404); + } + + $releases = PressRelease::query() + ->withoutGlobalScope(PortalScope::class) + ->forPortalValues($portalValues) + ->forLanguage($language) + ->visibleInArchive() + ->whereIn('category_id', $categoryIds) + ->whereBetween('published_at', [ + now()->setDate($year, 1, 1)->startOfDay(), + now()->setDate($year, 12, 31)->endOfDay(), + ]) + ->with([ + 'company:id,name,slug', + 'category.translations' => fn ($query) => $query->where('locale', $language), + ]) + ->orderByDesc('published_at') + ->paginate(self::PerPage, ['id', 'portal', 'language', 'slug', 'title', 'category_id', 'company_id', 'published_at', 'content_score'], 'page', $page); + + if ($page > 1 && $releases->isEmpty()) { + abort(404); + } + + return [ + 'category' => $category, + 'categoryAlternateSlugs' => $category->translations()->whereIn('locale', ['de', 'en'])->pluck('slug', 'locale')->all(), + 'year' => $year, + 'years' => $years, + 'releases' => $releases, + 'totalReleases' => array_sum($years), + ]; + } +} diff --git a/app/Services/Web/CategoryPageData.php b/app/Services/Web/CategoryPageData.php index a593b886..7a0512f9 100644 --- a/app/Services/Web/CategoryPageData.php +++ b/app/Services/Web/CategoryPageData.php @@ -293,6 +293,8 @@ class CategoryPageData 'topNewsrooms' => $topNewsrooms, 'subCategories' => $subCategories, 'relatedCategories' => $relatedCategories, + // Jahresleiste ins Archiv (2.3) — eine gruppierte Query. + 'archiveYears' => CategoryArchivePageData::yearsWithCounts($categoryIds, $portalValues, $language), ]; } } diff --git a/database/seeders/data/category-descriptions.php b/database/seeders/data/category-descriptions.php new file mode 100644 index 00000000..62168222 --- /dev/null +++ b/database/seeders/data/category-descriptions.php @@ -0,0 +1,66 @@ + [ + 'de' => 'Pressemitteilungen aus dem Handel: Einzelhandel und Großhandel, E-Commerce und Versandhandel, Filialnetze, Sortimente, Handelsmarken und Logistik. Unternehmen veröffentlichen hier Neueröffnungen, Produkteinführungen, Kooperationen und Geschäftszahlen; Verbände und Dienstleister des Handels ihre Branchenmeldungen.', + 'en' => 'Press releases from retail and wholesale: e-commerce and mail order, store networks, product ranges, private labels and logistics. Companies publish store openings, product launches, partnerships and results here; trade associations and service providers their industry news.', + ], + 'it' => [ + 'de' => 'Pressemitteilungen aus IT und Software: Anwendungen und Plattformen, Cloud, IT-Sicherheit, Netzwerke, Hardware, künstliche Intelligenz und Digitalisierung von Unternehmen. Softwarehäuser, Systemhäuser, Dienstleister und Hersteller veröffentlichen hier Produktmeldungen, Versionen, Partnerschaften und Kundenprojekte.', + 'en' => 'Press releases from IT and software: applications and platforms, cloud, IT security, networks, hardware, artificial intelligence and digitalisation. Software vendors, system integrators, service providers and manufacturers publish product news, releases, partnerships and customer projects here.', + ], + 'finanzen' => [ + 'de' => 'Pressemitteilungen aus der Finanzbranche: Banken und Sparkassen, Versicherungen, Vermögensverwaltung, Fonds, Kredite und Zahlungsverkehr, Finanzberatung und Fintech. Unternehmen veröffentlichen hier Produkte, Konditionen, Studien, Personalien und Geschäftsergebnisse.', + 'en' => 'Press releases from the financial sector: banks, insurers, asset management, funds, lending and payments, financial advice and fintech. Companies publish products, terms, studies, appointments and results here.', + ], + 'reisen' => [ + 'de' => 'Pressemitteilungen aus Tourismus und Reisen: Reiseveranstalter und Reisebüros, Hotels und Ferienunterkünfte, Fluggesellschaften, Bahn und Busreisen, Kreuzfahrten, Destinationen und Freizeitangebote. Hier erscheinen neue Angebote, Saisonstarts, Auszeichnungen und Zahlen aus der Branche.', + 'en' => 'Press releases from tourism and travel: tour operators and travel agencies, hotels and holiday accommodation, airlines, rail and coach travel, cruises, destinations and leisure. New offers, season openings, awards and industry figures appear here.', + ], + 'kultur' => [ + 'de' => 'Pressemitteilungen aus Kultur, Medien und Unterhaltung: Verlage und Buchveröffentlichungen, Musik, Film, Theater, Ausstellungen, Festivals, Veranstaltungen und Bildungsangebote. Veranstalter, Verlage, Künstler und Institutionen kündigen hier Termine, Neuerscheinungen und Programme an.', + 'en' => 'Press releases from culture, media and entertainment: publishers and book releases, music, film, theatre, exhibitions, festivals, events and education. Organisers, publishers, artists and institutions announce dates, new releases and programmes here.', + ], + 'politik' => [ + 'de' => 'Pressemitteilungen aus Politik, Verbänden und Gesellschaft: Stellungnahmen zu Gesetzen und Verordnungen, Verbandsarbeit, Kampagnen, Umfragen, Initiativen und Veranstaltungen. Verbände, Vereine, Stiftungen und Organisationen veröffentlichen hier ihre Positionen und Termine.', + 'en' => 'Press releases from politics, associations and society: statements on legislation, association work, campaigns, surveys, initiatives and events. Associations, clubs, foundations and organisations publish their positions and dates here.', + ], + 'medizin' => [ + 'de' => 'Pressemitteilungen aus Medizin und Gesundheit: Kliniken und Praxen, Medizintechnik, Pharma, Pflege, Therapieangebote, Prävention und Gesundheitsdienstleistungen. Hier erscheinen neue Behandlungsangebote, Produkte, Studien, Zertifizierungen und Veranstaltungen.', + 'en' => 'Press releases from medicine and healthcare: hospitals and practices, medical technology, pharma, care, therapies, prevention and health services. New treatments, products, studies, certifications and events appear here.', + ], + 'sport' => [ + 'de' => 'Pressemitteilungen aus Sport und Fitness: Vereine und Verbände, Sportveranstaltungen, Fitnessstudios, Sportartikel und Ausrüstung, Sponsoring und Trainingsangebote. Veranstalter, Hersteller und Anbieter veröffentlichen hier Termine, Ergebnisse, Produkte und Kooperationen.', + 'en' => 'Press releases from sport and fitness: clubs and federations, sporting events, gyms, sporting goods and equipment, sponsorship and training. Organisers, manufacturers and providers publish dates, results, products and partnerships here.', + ], + 'motor-verkehr' => [ + 'de' => 'Pressemitteilungen aus Automobil, Mobilität und Verkehr: Fahrzeughersteller und Händler, Zulieferer, Werkstätten, Elektromobilität, Nutzfahrzeuge, Zweiräder, Logistik und Verkehrsdienstleistungen. Hier erscheinen Modellvorstellungen, Angebote, Technik, Zahlen und Branchentermine.', + 'en' => 'Press releases from automotive, mobility and transport: vehicle manufacturers and dealers, suppliers, workshops, electric mobility, commercial vehicles, two-wheelers, logistics and transport services. Model launches, offers, technology, figures and industry events appear here.', + ], + 'haus' => [ + 'de' => 'Pressemitteilungen rund um Haus, Bauen und Wohnen: Bauunternehmen und Handwerk, Immobilien, Einrichtung, Garten, Energie und Haustechnik, Sanierung und Baustoffe. Hersteller, Handwerksbetriebe, Makler und Dienstleister veröffentlichen hier Produkte, Projekte, Ratgeber und Termine.', + 'en' => 'Press releases about home, building and living: construction and trades, real estate, interiors, garden, energy and building technology, renovation and building materials. Manufacturers, tradespeople, agents and service providers publish products, projects, guides and dates here.', + ], + 'mode' => [ + 'de' => 'Pressemitteilungen aus Mode, Beauty und Lifestyle: Bekleidung und Schuhe, Accessoires, Schmuck, Kosmetik, Designer und Labels, Kollektionen und Modehandel. Hier erscheinen Kollektionsvorstellungen, Kooperationen, Ladeneröffnungen und Trends aus der Branche.', + 'en' => 'Press releases from fashion, beauty and lifestyle: apparel and footwear, accessories, jewellery, cosmetics, designers and labels, collections and fashion retail. Collection launches, collaborations, store openings and trends appear here.', + ], + 'sonstiges' => [ + 'de' => 'Pressemitteilungen, die keiner der anderen Branchen zugeordnet sind: Dienstleistungen aller Art, Handwerk, Bildung und Weiterbildung, Beratung, regionale Meldungen und Themen, für die es keine eigene Rubrik gibt.', + 'en' => 'Press releases that do not fit any of the other industries: services of all kinds, trades, education and training, consulting, regional news and topics without a category of their own.', + ], + 'wissenschaft-forschung' => [ + 'de' => 'Pressemitteilungen aus Wissenschaft und Forschung: Hochschulen und Forschungseinrichtungen, Studien und Studienergebnisse, Technologietransfer, Forschungsprojekte, Förderungen, Tagungen und Publikationen. Institute, Universitäten und forschende Unternehmen veröffentlichen hier ihre Ergebnisse und Termine.', + 'en' => 'Press releases from science and research: universities and research institutions, studies and findings, technology transfer, research projects, funding, conferences and publications. Institutes, universities and research-driven companies publish their results and dates here.', + ], + 'recht' => [ + 'de' => 'Pressemitteilungen aus Recht und Steuern: Kanzleien, Steuerberatung, Rechtsdienstleister, Urteile und Gesetzesänderungen mit Praxisbezug, Verbraucherrecht, Arbeitsrecht, Unternehmensrecht und Compliance. Hier erscheinen Einordnungen aktueller Entscheidungen, Ratgeber und Kanzleimeldungen.', + 'en' => 'Press releases from law and tax: law firms, tax advisors, legal service providers, rulings and legislative changes with practical relevance, consumer law, employment law, corporate law and compliance. Assessments of current decisions, guides and firm news appear here.', + ], +]; diff --git a/dev/2026-09-01/roadmap-2026.md b/dev/2026-09-01/roadmap-2026.md index 903d1b46..0ffca15c 100644 --- a/dev/2026-09-01/roadmap-2026.md +++ b/dev/2026-09-01/roadmap-2026.md @@ -186,7 +186,7 @@ Verbreitert den Trichter oben. Sinnvoll erst, wenn Block 1 unten geschlossen ist |---|---|---|---| | ~~2.1~~ | NewsArticle-Schema auf allen Meldungen, Organization-Schema auf Newsrooms — **Grundgerüst seit Juli (379688f), 17.09.2026 ergänzt:** gemeinsame `@id` zwischen `NewsArticle.author` und Newsroom-Organization, Publisher mit `@id` und Logo (`App\Support\StructuredData`) | M | 1b.1 | | 2.2 | Aufnahme in Google News und Publisher Center prüfen und beantragen — **technischer Teil 17.09.2026:** News-Sitemap `/sitemap-news.xml` je Spoke (48 h, ≤ 1.000), in `robots.txt`; Publisher-Center-Anmeldung liegt beim Betreiber (Protokoll Teil 5) | M | 2.1 | -| 2.3 | Branchen- und Kategorie-Landingpages aus dem Archivbestand | M | keine | +| ~~2.3~~ | Branchen- und Kategorie-Landingpages aus dem Archivbestand — **17.09.2026:** Jahresarchiv je Branche `/category/{slug}/archiv/{jahr}` (34.600 Meldungen intern verlinkt, in der Sitemap), Jahresleiste auf der Branchenseite, Beschreibungen für alle 14 Kategorien (`categories:seed-descriptions`), Portalname im Titel | M | keine | | 2.4 | Transparenzseiten: Preisvergleich, ohne Abo veröffentlichen, ohne Mindestlaufzeit | S | keine | | 2.5 | Konsolidierung der bestehenden Ratgeberseiten unter `/de/presse/` gegen 1d.1 und 2.4 prüfen | S | 1d.1 | | 2.6 | Outreach an Betreiber von Presseportal-Listen und Vergleichsartikeln | M | 2.4 | diff --git a/dev/2026-09-01/umsetzung-protokoll.md b/dev/2026-09-01/umsetzung-protokoll.md index 3f99ccfd..c63ed364 100644 --- a/dev/2026-09-01/umsetzung-protokoll.md +++ b/dev/2026-09-01/umsetzung-protokoll.md @@ -1616,6 +1616,50 @@ zwischen Meldung und Newsroom) ist der zweite Teil von 2.7. Tests: `LlmsTxtTest` (2). +### 2.3 — Branchen-Landingpages aus dem Archivbestand (17.09.2026) + +**Befund (Dev-DB = Live-Kopie):** 34.597 sichtbare Meldungen seit 2008 +(presseecho 12.587, businessportal24 22.010), 14 Hauptkategorien ohne +Unterkategorien. Die Branchenseiten (`/de/category/{slug}`) existieren +seit Juli mit Lead/Top/Feed, Statistik, Newsrooms — zeigen aber nur die +jüngsten ~15 Meldungen. Der Bestand hing nur an der Sitemap, nicht an +internen Links. Dazu: **0 von 14 Kategorien haben eine Beschreibung**, +alle Branchenseiten zeigten denselben Standardsatz (14 gleich aussehende +Seiten); und der Seitentitel hieß auf beiden Spokes „– pressekonto" +(`config('app.name')` statt Portalname) — behoben. +„Sonstiges" trägt 9.607 Meldungen (28 %), ist also keine Branche, sondern +der Sammelposten; das ist ein Einlieferungs-Thema, kein Seitenthema. + +**Umsetzung:** +1. **Jahresarchiv je Branche** — `/de/category/{slug}/archiv/{jahr}` + (`CategoryArchivePageData`, `CategoryArchiveController`, View + `kategorie-archiv`): 30 Meldungen je Seite, neueste zuerst, Datum mit + Jahr, Firma, Rubrik; Jahresleiste mit Zahlen; ohne Jahr das jüngste + Jahr mit Meldungen. Jahr statt fortlaufender Seitennummer, weil eine + Jahresseite ihren Inhalt behält, während `?page=2` bei jeder neuen + Meldung verrutscht. Canonical mit `?page=N` auf Folgeseiten (der + Layout-Default hätte auf Seite 1 kanonisiert), hreflang nur auf Seite 1, + CollectionPage + BreadcrumbList als JSON-LD. Gecacht bis Seite 3 je + Jahr (Schutz gegen Cache-Aufblähung wie bei den Newsrooms). Leere + Jahre, unbekannte Kategorien und leere Folgeseiten sind 404. +2. **Jahresleiste auf der Branchenseite** unter dem Feed + (`archiveYears`, eine gruppierte Query), und alle Jahresarchive in der + Sitemap (14 × ~18 Jahre ≈ 250 URLs). +3. **Beschreibungen:** `categories:seed-descriptions {--dry-run} + {--force}` füllt leere DE/EN-Beschreibungen aus + `database/seeders/data/category-descriptions.php` (je 2–3 Sätze: + Themenumfang und wer dort veröffentlicht, ohne Werbeaussagen). Nur + leere Felder, danach in Admin ▸ Kategorien änderbar. Dry-Run auf der + Dev-DB: alle 14 Kategorien gefunden. + +Tests: `CategoryArchiveTest` (5), `SeedCategoryDescriptionsTest` (2); +`tests/Feature/Web` komplett grün (305). + +**Live:** `php artisan categories:seed-descriptions --dry-run`, dann ohne +Flag. Bewusst nicht gebaut: Landingpages unterhalb der Kategorien +(„Branchen" im engeren Sinn) — dafür gibt es keine Unterkategorien und +keine Daten; die Meldungen tragen nur die 14 Hauptkategorien. + ### Befund zu 2.5 / E5 Die alte Ratgeber-Strecke `/de/presse/*.html` leitet seit dem Rückbau @@ -1627,6 +1671,8 @@ Empfehlung: ja für Slugs, die erkennbar das Schreiben betreffen, sonst FAQ. Braucht die Liste der Alt-Slugs aus dem Webserver-Log oder der Search Console; ohne die bleibt es bei FAQ. -**Deploy:** kein `migrate`, nur `view:cache`/`route:cache` wie üblich. -Danach `/sitemap-news.xml`, `/llms.txt` und eine Meldung im -Rich-Results-Test (search.google.com/test/rich-results) prüfen. +**Deploy (2.1/2.2/2.3/2.7):** kein `migrate`, `view:cache`/`route:cache` +wie üblich, dann `categories:seed-descriptions`. Danach +`/sitemap-news.xml`, `/llms.txt`, `/de/category/handel/archiv/2019` und +eine Meldung im Rich-Results-Test (search.google.com/test/rich-results) +prüfen. diff --git a/lang/de/web.php b/lang/de/web.php index 254f79f6..017c4b3e 100644 --- a/lang/de/web.php +++ b/lang/de/web.php @@ -321,6 +321,23 @@ return [ 'cta_pricing' => 'Tarife & Branchen-Pakete ansehen', ], + 'category_archive' => [ + 'eyebrow' => 'Archiv', + 'breadcrumb' => 'Archiv :year', + 'title' => 'Pressemitteilungen :category :year', + 'meta_title' => 'Pressemitteilungen :category :year – Archiv – :brand', + 'meta_description' => 'Alle :count Pressemitteilungen aus dem Bereich :category, die :year auf :brand veröffentlicht wurden – chronologisch, mit Unternehmen und Datum.', + 'intro' => ':count Pressemitteilungen aus dem Bereich :category aus dem Jahr :year. Das Archiv dieser Branche umfasst :total Meldungen seit :first.', + 'nav_label' => 'Archiv nach Jahr', + 'section_title' => 'Archiv :category', + 'section_total' => ':count Meldungen insgesamt', + 'back_to_category' => 'Aktuelle Meldungen :category', + 'list_title' => 'Meldungen :year', + 'page' => 'Seite :page', + 'page_of' => 'Seite :page von :last', + 'sorted' => 'Neueste zuerst', + ], + 'detail' => [ 'breadcrumbs' => 'Brotkrumen', 'press_release' => 'Pressemitteilung', diff --git a/lang/en/web.php b/lang/en/web.php index e28d7381..0ffe48ef 100644 --- a/lang/en/web.php +++ b/lang/en/web.php @@ -316,6 +316,23 @@ return [ 'cta_pricing' => 'View plans & industry packages', ], + 'category_archive' => [ + 'eyebrow' => 'Archive', + 'breadcrumb' => 'Archive :year', + 'title' => ':category press releases :year', + 'meta_title' => ':category press releases :year – archive – :brand', + 'meta_description' => 'All :count press releases in :category published on :brand in :year – chronological, with company and date.', + 'intro' => ':count press releases in :category from :year. The archive of this industry holds :total releases since :first.', + 'nav_label' => 'Archive by year', + 'section_title' => ':category archive', + 'section_total' => ':count releases in total', + 'back_to_category' => 'Latest :category releases', + 'list_title' => 'Releases :year', + 'page' => 'Page :page', + 'page_of' => 'Page :page of :last', + 'sorted' => 'Newest first', + ], + 'detail' => [ 'breadcrumbs' => 'Breadcrumbs', 'press_release' => 'Press release', diff --git a/resources/views/web/kategorie-archiv.blade.php b/resources/views/web/kategorie-archiv.blade.php new file mode 100644 index 00000000..c7231a1a --- /dev/null +++ b/resources/views/web/kategorie-archiv.blade.php @@ -0,0 +1,186 @@ +@extends('web.layouts.web-master') + +@php + use App\Support\StructuredData; + use Illuminate\Support\Str; + + \Carbon\Carbon::setLocale(app()->getLocale()); + + $catTranslation = $category->translations->firstWhere('locale', app()->getLocale()) ?? $category->translations->first(); + $catName = $catTranslation?->name ?? __('web.category.fallback_name'); + $catSlug = $catTranslation?->slug; + + $parentTranslation = $category->parent?->translations->firstWhere('locale', app()->getLocale()) ?? $category->parent?->translations->first(); + $parentName = $parentTranslation?->name; + + $fmt = fn ($value) => number_format((int) $value, 0, ',', '.'); + $currentPage = $releases->currentPage(); + $lastPage = $releases->lastPage(); + + $portalBase = StructuredData::portalBase($domainUrl ?? null); + $brandLabel = config('app.theme') === 'presseecho' ? 'presseecho' : 'businessportal24'; + $categoryUrl = $portalBase.'/'.app()->getLocale().'/category/'.$catSlug; + $archiveUrl = $categoryUrl.'/archiv/'.$year; + // Canonical mit Seitenzahl: der Layout-Default (Pfad ohne Query) würde + // jede Folgeseite auf Seite 1 kanonisieren. + $canonicalUrl = $currentPage > 1 ? $archiveUrl.'?page='.$currentPage : $archiveUrl; + + $pageSuffix = $currentPage > 1 ? ' – '.__('web.category_archive.page', ['page' => $currentPage]) : ''; + $metaTitle = __('web.category_archive.meta_title', ['category' => $catName, 'year' => $year, 'brand' => $brandLabel]).$pageSuffix; + $metaDescription = __('web.category_archive.meta_description', ['category' => $catName, 'year' => $year, 'count' => $fmt($years[$year] ?? 0), 'brand' => $brandLabel]); + + $alternateSlugs = $categoryAlternateSlugs ?? []; + $catSlugDe = $alternateSlugs['de'] ?? null; + $catSlugEn = $alternateSlugs['en'] ?? null; + + $breadcrumbs = collect([ + ['name' => $brandLabel, 'item' => $portalBase.'/'.app()->getLocale()], + $parentName && $parentTranslation?->slug ? ['name' => $parentName, 'item' => $portalBase.'/'.app()->getLocale().'/category/'.$parentTranslation->slug] : null, + ['name' => $catName, 'item' => $categoryUrl], + ['name' => __('web.category_archive.breadcrumb', ['year' => $year]), 'item' => $archiveUrl], + ])->filter()->values(); + + $jsonLd = [ + '@context' => 'https://schema.org', + '@graph' => [ + [ + '@type' => 'CollectionPage', + 'name' => __('web.category_archive.title', ['category' => $catName, 'year' => $year]), + 'url' => $canonicalUrl, + 'inLanguage' => app()->getLocale(), + 'isPartOf' => ['@type' => 'WebSite', 'url' => $portalBase.'/'], + ], + [ + '@type' => 'BreadcrumbList', + 'itemListElement' => $breadcrumbs->map(fn (array $crumb, int $index) => [ + '@type' => 'ListItem', + 'position' => $index + 1, + 'name' => $crumb['name'], + 'item' => $crumb['item'], + ])->all(), + ], + ], + ]; +@endphp + +@section('title', $metaTitle) +@section('meta_description', Str::limit($metaDescription, 155)) +@section('canonical', $canonicalUrl) + +@if ($catSlugDe && $catSlugEn && $currentPage === 1) + @section('hreflang') + + + + @endsection +@endif + +@section('structured_data') + +@endsection + +@section('content') + + +
+ {{-- Breadcrumb --}} +
+
+ {{ __('web.nav.home') }} + + @if ($parentName && $parentTranslation?->slug) + {{ $parentName }} + + @endif + {{ $catName }} + + {{ __('web.category_archive.breadcrumb', ['year' => $year]) }} +
+
+ +
+ +

+ {{ __('web.category_archive.title', ['category' => $catName, 'year' => $year]) }} +

+

+ {{ __('web.category_archive.intro', ['count' => $fmt($years[$year] ?? 0), 'category' => $catName, 'year' => $year, 'total' => $fmt($totalReleases), 'first' => array_key_last($years)]) }} +

+ + {{-- Jahresleiste --}} + +
+ +
+
+

+ {{ __('web.category_archive.list_title', ['year' => $year]) }} + @if ($lastPage > 1) + · {{ __('web.category_archive.page_of', ['page' => $currentPage, 'last' => $lastPage]) }} + @endif +

+ {{ __('web.category_archive.sorted') }} +
+
+ +
    + @foreach ($releases as $release) + @php + $rowCategory = $release->localizedCategoryName(); + $isRecommended = $release->content_score !== null && $release->content_score >= 80; + @endphp +
  1. + + +
    +
    + {{ $release->title }} +
    +
    + @if ($release->company?->name) + {{ $release->company->name }} + @endif + @if ($rowCategory && $rowCategory !== $catName) + + {{ $rowCategory }} + @endif + @if ($isRecommended) + + {{ __('web.detail.recommended') }} + @endif +
    +
    +
    +
  2. + @endforeach +
+ + +
+
+ + +@endsection diff --git a/resources/views/web/kategorie.blade.php b/resources/views/web/kategorie.blade.php index 20ae1554..f04d6740 100644 --- a/resources/views/web/kategorie.blade.php +++ b/resources/views/web/kategorie.blade.php @@ -61,7 +61,7 @@ ]; @endphp -@section('title', __('web.category.meta_title', ['category' => $catName, 'brand' => config('app.name')])) +@section('title', __('web.category.meta_title', ['category' => $catName, 'brand' => $brandLabel])) @section('meta_description', Str::limit(strip_tags($catDescription), 155)) @if ($catSlugDe && $catSlugEn) @@ -264,6 +264,25 @@ {{ __('web.category.week_stat', ['count' => $fmt($categoryStats['week'] ?? 0)]) }} {{ __('web.category.search_archive') }} → + + {{-- Jahresarchiv (2.3): der Bestand als stabile, verlinkte Seiten. --}} + @if (! empty($archiveYears ?? []) && $catSlug) + + @endif