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:
parent
0665553c88
commit
df6b24518c
10 changed files with 183 additions and 19 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ return [
|
|||
'read_time' => ':minutes Min. Lesezeit',
|
||||
'minutes_short' => ':minutes Min.',
|
||||
'recommendation' => 'Empfehlung',
|
||||
'boosted_placement' => 'Top-Platzierung',
|
||||
],
|
||||
|
||||
'countries' => [
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ return [
|
|||
'read_time' => ':minutes min read',
|
||||
'minutes_short' => ':minutes min',
|
||||
'recommendation' => 'Recommended',
|
||||
'boosted_placement' => 'Featured',
|
||||
],
|
||||
|
||||
'countries' => [
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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]">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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]">
|
||||
|
|
|
|||
100
tests/Feature/Web/BoostPlacementTest.php
Normal file
100
tests/Feature/Web/BoostPlacementTest.php
Normal 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',
|
||||
]);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue