diff --git a/app/Services/Web/CategoryPageData.php b/app/Services/Web/CategoryPageData.php index 2d641832..3d45690a 100644 --- a/app/Services/Web/CategoryPageData.php +++ b/app/Services/Web/CategoryPageData.php @@ -95,18 +95,36 @@ class CategoryPageData 'images' => fn ($query) => $query->orderByDesc('is_preview')->orderBy('sort_order')->limit(1), ]; - $leadRelease = $base()->with($with)->orderByDesc('published_at')->first(); + // Gebuchte Boosts (Decision-Update §2.2): geboostete PMs der Branche + // besetzen Lead- und Top-Plätze vor der Chronologie; die Views + // markieren sie über `boostedIds`. + $boostedReleases = $base() + ->boosted() + ->with($with) + ->orderByDesc('published_at') + ->limit(3) + ->get(); + $boostedIds = $boostedReleases->modelKeys(); + + $leadRelease = $boostedReleases->first() + ?? $base()->with($with)->orderByDesc('published_at')->first(); // Ohne mediumText-Spalte `text`: Top-/Feed-Listen rendern nur // Titel/Meta, der Volltext wäre unnötiger I/O. $listColumns = ['id', 'portal', 'language', 'slug', 'title', 'category_id', 'company_id', 'published_at', 'content_score']; - $topReleases = $base() - ->with($with) - ->when($leadRelease, fn (Builder $query) => $query->whereKeyNot($leadRelease->getKey())) - ->orderByDesc('published_at') - ->limit(2) - ->get($listColumns); + $topBoosted = $boostedReleases->slice(1)->take(2)->values(); + $topChronoSlots = 2 - $topBoosted->count(); + $topReleases = $topChronoSlots > 0 + ? $base() + ->with($with) + ->when($leadRelease, fn (Builder $query) => $query->whereKeyNot($leadRelease->getKey())) + ->when($boostedIds !== [], fn (Builder $query) => $query->whereNotIn('id', $boostedIds)) + ->orderByDesc('published_at') + ->limit($topChronoSlots) + ->get($listColumns) + : collect(); + $topReleases = $topBoosted->concat($topReleases)->values(); $excludeIds = collect([$leadRelease?->getKey()])->merge($topReleases->modelKeys())->filter()->all(); @@ -249,6 +267,7 @@ class CategoryPageData 'leadRelease' => $leadRelease, 'topReleases' => $topReleases, 'feedReleases' => $feedReleases, + 'boostedIds' => $boostedIds, 'mostReadReleases' => $mostReadReleases, 'categoryStats' => $stats, 'topNewsrooms' => $topNewsrooms, diff --git a/app/Services/Web/HomePageData.php b/app/Services/Web/HomePageData.php index 24d76b54..ba9af307 100644 --- a/app/Services/Web/HomePageData.php +++ b/app/Services/Web/HomePageData.php @@ -72,19 +72,37 @@ class HomePageData ->oldest('published_at') ->value('published_at'); - $leadRelease = $publishedQuery() + // Gebuchte Boosts (Decision-Update §2.2): geboostete PMs besetzen die + // Top-Positionen vor der Chronologie — die jüngste als Lead, weitere + // in der Seitenleiste. Die Views markieren sie über `boostedIds`. + $boostedReleases = $publishedQuery() + ->boosted() ->with($with) ->orderByDesc('published_at') - ->first(); + ->limit(5) + ->get(); + $boostedIds = $boostedReleases->modelKeys(); + + $leadRelease = $boostedReleases->first() + ?? $publishedQuery() + ->with($with) + ->orderByDesc('published_at') + ->first(); // Ohne mediumText-Spalte `text`: die Seitenleisten-Teaser rendern nur // Titel/Meta, der Volltext wäre unnötiger I/O. - $sideReleases = $publishedQuery() - ->with($with) - ->when($leadRelease, fn (Builder $query) => $query->where('id', '!=', $leadRelease->id)) - ->orderByDesc('published_at') - ->limit(4) - ->get(['id', 'portal', 'language', 'slug', 'title', 'category_id', 'company_id', 'published_at', 'content_score']); + $sideBoosted = $boostedReleases->slice(1)->take(4)->values(); + $sideChronoSlots = 4 - $sideBoosted->count(); + $sideReleases = $sideChronoSlots > 0 + ? $publishedQuery() + ->with($with) + ->when($leadRelease, fn (Builder $query) => $query->where('id', '!=', $leadRelease->id)) + ->when($boostedIds !== [], fn (Builder $query) => $query->whereNotIn('id', $boostedIds)) + ->orderByDesc('published_at') + ->limit($sideChronoSlots) + ->get(['id', 'portal', 'language', 'slug', 'title', 'category_id', 'company_id', 'published_at', 'content_score']) + : collect(); + $sideReleases = $sideBoosted->concat($sideReleases)->values(); $mostReadReleases = $publishedQuery() ->orderByDesc('hits') @@ -244,6 +262,7 @@ class HomePageData return [ 'leadRelease' => $leadRelease, 'sideReleases' => $sideReleases, + 'boostedIds' => $boostedIds, 'mostReadReleases' => $mostReadReleases, 'activeNewsrooms' => $activeNewsrooms, 'industryIndex' => $industryIndex, diff --git a/lang/de/web.php b/lang/de/web.php index 503bcf07..05574812 100644 --- a/lang/de/web.php +++ b/lang/de/web.php @@ -23,6 +23,7 @@ return [ 'read_time' => ':minutes Min. Lesezeit', 'minutes_short' => ':minutes Min.', 'recommendation' => 'Empfehlung', + 'boosted_placement' => 'Top-Platzierung', ], 'countries' => [ diff --git a/lang/en/web.php b/lang/en/web.php index bf1c78a8..87305edd 100644 --- a/lang/en/web.php +++ b/lang/en/web.php @@ -18,6 +18,7 @@ return [ 'read_time' => ':minutes min read', 'minutes_short' => ':minutes min', 'recommendation' => 'Recommended', + 'boosted_placement' => 'Featured', ], 'countries' => [ diff --git a/resources/views/components/web/focus-hero.blade.php b/resources/views/components/web/focus-hero.blade.php index 6edd48b2..6e204c76 100644 --- a/resources/views/components/web/focus-hero.blade.php +++ b/resources/views/components/web/focus-hero.blade.php @@ -1,10 +1,12 @@ @props([ 'leadRelease' => null, 'sideReleases' => [], + 'boostedIds' => [], ]) @php $sideReleases = collect($sideReleases)->take(4)->values(); + $boostedIds = collect($boostedIds)->all(); if ($leadRelease) { $leadCategoryName = $leadRelease->localizedCategoryName(); @@ -50,6 +52,9 @@
{{ __('web.hero.top_story') }} + @if (in_array($leadRelease->id, $boostedIds, true)) + {{ __('web.shared.boosted_placement') }} + @endif
{{ __('web.hero.sidebar_title') }}
@foreach ($sideReleases as $index => $release) - + @endforeach diff --git a/resources/views/components/web/focus-side-item.blade.php b/resources/views/components/web/focus-side-item.blade.php index ade32fb8..68b0ac94 100644 --- a/resources/views/components/web/focus-side-item.blade.php +++ b/resources/views/components/web/focus-side-item.blade.php @@ -2,6 +2,7 @@ 'release' => null, 'index' => 1, 'first' => false, + 'boosted' => false, ]) @php @@ -44,6 +45,11 @@

{{ $title }} + @if ($boosted) + + {{ __('web.shared.boosted_placement') }} + + @endif

diff --git a/resources/views/web/kategorie.blade.php b/resources/views/web/kategorie.blade.php index eacee7c0..21b5a8dd 100644 --- a/resources/views/web/kategorie.blade.php +++ b/resources/views/web/kategorie.blade.php @@ -130,6 +130,9 @@ @endif
{{ __('web.hero.top_story') }} + @if (in_array($leadRelease->id, $boostedIds ?? [], true)) + {{ __('web.shared.boosted_placement') }} + @endif
@if ($leadCatName) @@ -172,7 +175,14 @@ {{ $topCat?->name ?? $catName }} {{ $top->published_at?->translatedFormat('j. M') }}
-

{{ $top->title }}

+

+ {{ $top->title }} + @if (in_array($top->id, $boostedIds ?? [], true)) + + {{ __('web.shared.boosted_placement') }} + + @endif +

@if ($top->company?->name)
diff --git a/resources/views/web/presseecho.blade.php b/resources/views/web/presseecho.blade.php index 27f0afac..2746c288 100644 --- a/resources/views/web/presseecho.blade.php +++ b/resources/views/web/presseecho.blade.php @@ -24,7 +24,8 @@
+ :side-releases="$sideReleases ?? []" + :boosted-ids="$boostedIds ?? []" />
diff --git a/tests/Feature/Web/BoostPlacementTest.php b/tests/Feature/Web/BoostPlacementTest.php new file mode 100644 index 00000000..3f9162d5 --- /dev/null +++ b/tests/Feature/Web/BoostPlacementTest.php @@ -0,0 +1,100 @@ +create(['portal' => Portal::Presseecho]); + $category->translations()->create([ + 'locale' => 'de', + 'name' => 'Energie & Klima', + 'slug' => 'energie-klima', + ]); + + $company = Company::factory()->presseecho()->create(['name' => 'Boost Energie GmbH']); + + $newest = PressRelease::factory() + ->published() + ->forPortal(Portal::Presseecho) + ->for($category) + ->for($company) + ->create([ + 'title' => 'Neueste unbezahlte Meldung', + 'slug' => 'neueste-unbezahlte-meldung', + 'published_at' => now()->subHour(), + ]); + + $boosted = PressRelease::factory() + ->published() + ->forPortal(Portal::Presseecho) + ->for($category) + ->for($company) + ->create([ + 'title' => 'Geboostete aeltere Meldung', + 'slug' => 'geboostete-aeltere-meldung', + 'published_at' => now()->subDays(5), + ]); + + return compact('category', 'company', 'newest', 'boosted'); +} + +test('a boosted release takes the homepage lead ahead of newer releases and is labelled', function () { + /** @var TestCase $this */ + ['boosted' => $boosted, 'newest' => $newest] = boostPlacementFixture(); + + Boost::factory()->for($boosted)->create(); + + $data = app(HomePageData::class)->build(Portal::Presseecho); + expect($data['leadRelease']->id)->toBe($boosted->id) + ->and($data['boostedIds'])->toBe([$boosted->id]) + // Die verdrängte neueste Meldung rückt in die Seitenleiste nach. + ->and($data['sideReleases']->modelKeys())->toContain($newest->id); + + // Badge „Top-Platzierung" hängt am Hero-Lead (nach dem Ticker-Vorkommen). + $this->get('https://presseecho.test/de') + ->assertSuccessful() + ->assertSeeTextInOrder(['Top-Platzierung', 'Geboostete aeltere Meldung']); +}); + +test('an expired boost does not influence the homepage placement', function () { + /** @var TestCase $this */ + ['boosted' => $boosted, 'newest' => $newest] = boostPlacementFixture(); + + Boost::factory()->for($boosted)->expired()->create(); + + $data = app(HomePageData::class)->build(Portal::Presseecho); + expect($data['leadRelease']->id)->toBe($newest->id) + ->and($data['boostedIds'])->toBe([]); + + $this->get('https://presseecho.test/de') + ->assertSuccessful() + ->assertDontSeeText('Top-Platzierung'); +}); + +test('a boosted release leads its category page and is labelled', function () { + /** @var TestCase $this */ + ['boosted' => $boosted] = boostPlacementFixture(); + + Boost::factory()->for($boosted)->create(); + + // Lead-Slot der Branchenseite: Badge und geboosteter Titel im Hero, + // die neuere unbezahlte Meldung folgt dahinter in den Top-Meldungen. + $this->get('https://presseecho.test/de/category/energie-klima') + ->assertSuccessful() + ->assertSeeTextInOrder([ + 'Top-Platzierung', + 'Geboostete aeltere Meldung', + 'Neueste unbezahlte Meldung', + ]); +});