Web: Boost-Platzierung auf Start- und Branchenseite ausspielen

Geboostete PMs besetzen Lead- und Top-Slots vor der Chronologie
(HomePageData/CategoryPageData ueber boosted()-Scope) und tragen das
Badge "Top-Platzierung". Abgelaufene Boosts wirken nicht.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-07-10 17:05:10 +00:00
parent 0665553c88
commit df6b24518c
10 changed files with 183 additions and 19 deletions

View file

@ -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,

View file

@ -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,

View file

@ -23,6 +23,7 @@ return [
'read_time' => ':minutes Min. Lesezeit',
'minutes_short' => ':minutes Min.',
'recommendation' => 'Empfehlung',
'boosted_placement' => 'Top-Platzierung',
],
'countries' => [

View file

@ -18,6 +18,7 @@ return [
'read_time' => ':minutes min read',
'minutes_short' => ':minutes min',
'recommendation' => 'Recommended',
'boosted_placement' => 'Featured',
],
'countries' => [

View file

@ -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 @@
<div class="absolute top-6 left-6 flex gap-2">
<span class="bp-tag">{{ __('web.hero.top_story') }}</span>
@if (in_array($leadRelease->id, $boostedIds, true))
<span class="bp-tag">{{ __('web.shared.boosted_placement') }}</span>
@endif
</div>
<div class="absolute left-0 right-0 bottom-0 px-9 pb-8 pt-[60px] text-white"
@ -86,7 +91,8 @@
<div class="eyebrow muted mb-3.5">{{ __('web.hero.sidebar_title') }}</div>
@foreach ($sideReleases as $index => $release)
<x-web.focus-side-item :release="$release" :index="$index + 1" :first="$loop->first" />
<x-web.focus-side-item :release="$release" :index="$index + 1" :first="$loop->first"
:boosted="in_array($release->id, $boostedIds, true)" />
@endforeach
<x-web.ad-slot variant="sidebar" />

View file

@ -2,6 +2,7 @@
'release' => null,
'index' => 1,
'first' => false,
'boosted' => false,
])
@php
@ -44,6 +45,11 @@
<div>
<h3 class="font-serif text-[15.5px] leading-[1.3] m-0 font-medium tracking-[-0.1px] text-ink group-hover:text-brand transition-colors">
{{ $title }}
@if ($boosted)
<span class="inline-flex items-center whitespace-nowrap align-middle border border-brand/30 bg-brand/[0.04] px-1.5 py-px font-mono text-[9.5px] font-bold uppercase tracking-[0.1em] text-brand">
{{ __('web.shared.boosted_placement') }}
</span>
@endif
</h3>
<div class="text-[11.5px] text-ink-3 mt-1.5 flex items-center gap-1.5">
<svg width="10" height="10" viewBox="0 0 11 11" class="text-ok flex-shrink-0" aria-hidden="true">

View file

@ -24,7 +24,8 @@
<main class="bg-bg text-ink">
<x-web.focus-hero
:lead-release="$leadRelease ?? null"
:side-releases="$sideReleases ?? []" />
:side-releases="$sideReleases ?? []"
:boosted-ids="$boostedIds ?? []" />
<section class="max-w-layout mx-auto px-8 mt-16">
<div class="grid gap-9 grid-cols-1 lg:grid-cols-[1.7fr_1fr]">

View file

@ -130,6 +130,9 @@
@endif
<div class="absolute top-5 left-5 flex gap-2">
<span class="bp-tag">{{ __('web.hero.top_story') }}</span>
@if (in_array($leadRelease->id, $boostedIds ?? [], true))
<span class="bp-tag">{{ __('web.shared.boosted_placement') }}</span>
@endif
</div>
<div class="absolute left-0 right-0 bottom-0 px-6 sm:px-9 pb-8 pt-[70px] text-white" style="background:linear-gradient(180deg,transparent,rgba(0,0,0,0.92));">
@if ($leadCatName)
@ -172,7 +175,14 @@
<span class="bp-cat">{{ $topCat?->name ?? $catName }}</span>
<span class="font-mono text-[11px] text-ink-3 whitespace-nowrap">{{ $top->published_at?->translatedFormat('j. M') }}</span>
</div>
<h3 class="font-serif text-[17px] leading-[1.28] m-0 font-semibold tracking-[-0.2px] text-ink group-hover:text-brand transition-colors">{{ $top->title }}</h3>
<h3 class="font-serif text-[17px] leading-[1.28] m-0 font-semibold tracking-[-0.2px] text-ink group-hover:text-brand transition-colors">
{{ $top->title }}
@if (in_array($top->id, $boostedIds ?? [], true))
<span class="inline-flex items-center whitespace-nowrap align-middle border border-brand/30 bg-brand/[0.04] px-1.5 py-px font-mono text-[9.5px] font-bold uppercase tracking-[0.1em] text-brand">
{{ __('web.shared.boosted_placement') }}
</span>
@endif
</h3>
@if ($top->company?->name)
<div class="mt-2.5 text-[11.5px] text-ink-3 flex items-center gap-2">
<svg width="11" height="11" viewBox="0 0 11 11" class="text-ok" aria-hidden="true"><circle cx="5.5" cy="5.5" r="5" fill="currentColor"/><path d="M3 5.5l1.8 1.8L8 4" stroke="white" stroke-width="1.4" fill="none" stroke-linecap="round"/></svg>

View file

@ -24,7 +24,8 @@
<main class="bg-bg text-ink">
<x-web.focus-hero
:lead-release="$leadRelease ?? null"
:side-releases="$sideReleases ?? []" />
:side-releases="$sideReleases ?? []"
:boosted-ids="$boostedIds ?? []" />
<section class="max-w-layout mx-auto px-8 mt-16">
<div class="grid gap-9 grid-cols-1 lg:grid-cols-[1.7fr_1fr]">

View file

@ -0,0 +1,100 @@
<?php
use App\Enums\Portal;
use App\Models\Boost;
use App\Models\Category;
use App\Models\Company;
use App\Models\PressRelease;
use App\Services\Web\HomePageData;
use Tests\TestCase;
/**
* Boost-Platzierung (Decision-Update §2.2): geboostete PMs besetzen die
* Top-Positionen auf Start- und Branchenseite vor der Chronologie und werden
* als „Top-Platzierung" markiert.
*/
function boostPlacementFixture(): array
{
$category = Category::factory()->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',
]);
});