Boost-Slot-Grenzen (5/3) + Kategorie-Boost zum halben Satz

Entscheidung 13.07.: verkauft wird nur, was sichtbar ist — max. 5
gleichzeitige Boosts je Startseite, 3 je Branchenseite (= die sichtbaren
Plaetze; Hero-Slider damit vertagt). Neue Stufe Kategorie-Boost (nur
Branchenseite, 6/10/18 Credits = 50 % aufgerundet); der Startseiten-Boost
(12/20/35) erscheint weiter auf Start- UND Branchenseite.

- boosts.placement (home/category, Bestand = home) + BoostPlacement-Enum;
  Preise/Slots in config/credits.php (boost_category, boost_slots)
- BoostService::slotAvailability() je Ausgabe (Portal x Sprache, Kategorie
  zusaetzlich je Rubrik); ausgebucht -> soldOut-Exception mit fruehestem
  freien Termin; Verlaengerungen halten ihren Slot, Stufen verlaengern
  unabhaengig; Kategorie-Boost invalidiert nur Kategorie-Caches
- Startseite zeigt nur Home-Boosts; Branchenseite: Kategorie-Boosts mit
  Vorrang (ihre 3 Slots sind die Garantie), Home-Boosts fuellen den Rest
- Add-on-Panel der PM-Seite: zwei Boost-Angebote, bei vollem Inventar
  "Derzeit ausgebucht — frei ab" statt Kauf-Buttons (serverseitig gegated);
  Buchungsseiten-Preisliste um die Kategorie-Staffel ergaenzt

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-07-13 12:28:03 +00:00
parent b1be14e1b3
commit 4853d48b83
19 changed files with 702 additions and 98 deletions

View file

@ -0,0 +1,34 @@
<?php
namespace App\Enums;
/**
* Platzierungs-Stufe eines Boosts (Entscheidung 13.07.2026): der
* Startseiten-Boost (voller Preis) erscheint auf Startseite UND Branchenseite,
* der Kategorie-Boost (halber Preis) nur auf der Branchenseite. Beide Stufen
* haben ein eigenes Slot-Inventar je Ausgabe (config credits.boost_slots).
*/
enum BoostPlacement: string
{
case Home = 'home';
case Category = 'category';
public function label(): string
{
return match ($this) {
self::Home => 'Startseiten-Boost',
self::Category => 'Kategorie-Boost',
};
}
/**
* Wo die Platzierung sichtbar ist (für Add-on-UI und Kauf-Mails).
*/
public function placementDescription(): string
{
return match ($this) {
self::Home => 'Startseite und Branchenseite',
self::Category => 'Branchenseite der Rubrik',
};
}
}

View file

@ -2,11 +2,14 @@
namespace App\Exceptions;
use App\Enums\BoostPlacement;
use Illuminate\Support\Carbon;
use RuntimeException;
/**
* Wird geworfen, wenn eine PM nicht geboostet werden darf — nur
* veröffentlichte, grün klassifizierte Meldungen sind boostbar (§2.2).
* veröffentlichte, grün klassifizierte Meldungen sind boostbar (§2.2) und
* je Ausgabe gibt es nur begrenzte Slots (Entscheidung 13.07.2026).
*/
class BoostNotAllowedException extends RuntimeException
{
@ -14,4 +17,11 @@ class BoostNotAllowedException extends RuntimeException
{
return new self('Nur veröffentlichte, grün klassifizierte Pressemitteilungen sind boostbar.');
}
public static function soldOut(BoostPlacement $placement, ?Carbon $freeAt): self
{
return new self($freeAt
? "{$placement->label()} derzeit ausgebucht — der nächste Platz wird am {$freeAt->format('d.m.Y')} frei."
: "{$placement->label()} derzeit ausgebucht.");
}
}

View file

@ -2,6 +2,7 @@
namespace App\Models;
use App\Enums\BoostPlacement;
use App\Services\PressRelease\BoostService;
use Database\Factories\BoostFactory;
use Illuminate\Database\Eloquent\Builder;
@ -21,6 +22,7 @@ class Boost extends Model
protected $fillable = [
'press_release_id',
'user_id',
'placement',
'days',
'credits_charged',
'starts_at',
@ -30,6 +32,7 @@ class Boost extends Model
protected function casts(): array
{
return [
'placement' => BoostPlacement::class,
'days' => 'integer',
'credits_charged' => 'integer',
'starts_at' => 'datetime',
@ -54,4 +57,9 @@ class Boost extends Model
{
return $query->where('ends_at', '>', now());
}
public function scopeForPlacement(Builder $query, BoostPlacement $placement): Builder
{
return $query->where('placement', $placement);
}
}

View file

@ -3,6 +3,7 @@
namespace App\Models;
use App\Enums\AiTextDeclaration;
use App\Enums\BoostPlacement;
use App\Enums\Portal;
use App\Enums\PressReleaseClassification;
use App\Enums\PressReleaseContentTier;
@ -340,11 +341,14 @@ class PressRelease extends Model
/**
* Beschränkt auf PMs mit aktuell laufendem Boost — Basis für die
* Featured-Platzierung auf Start-/Branchenseite.
* Featured-Platzierung auf Start-/Branchenseite. Optional auf eine
* Boost-Stufe eingeschränkt (Startseite zeigt nur Startseiten-Boosts).
*/
public function scopeBoosted(Builder $query): Builder
public function scopeBoosted(Builder $query, ?BoostPlacement $placement = null): Builder
{
return $query->whereHas('boosts', fn (Builder $q) => $q->active());
return $query->whereHas('boosts', fn (Builder $q) => $q
->active()
->when($placement, fn (Builder $q) => $q->forPlacement($placement)));
}
/**

View file

@ -2,6 +2,7 @@
namespace App\Services\Billing;
use App\Enums\BoostPlacement;
use App\Enums\Tier;
use App\Models\User;
@ -29,11 +30,12 @@ class CreditPricingService
}
/**
* Boost-Preis (Credits) für eine Laufzeit in Tagen.
* Boost-Preis (Credits) für eine Laufzeit in Tagen, je Boost-Stufe
* (Startseiten-Boost bzw. Kategorie-Boost zum halben Satz).
*/
public function boostCredits(int $days): int
public function boostCredits(int $days, BoostPlacement $placement = BoostPlacement::Home): int
{
$table = config('credits.boost', []);
$table = $this->boostTable($placement);
if (! isset($table[$days])) {
throw new \InvalidArgumentException("Unbekannte Boost-Laufzeit: {$days} Tage.");
@ -47,14 +49,22 @@ class CreditPricingService
*
* @return array<int, int>
*/
public function boostOptions(): array
public function boostOptions(BoostPlacement $placement = BoostPlacement::Home): array
{
$table = config('credits.boost', []);
$table = $this->boostTable($placement);
ksort($table);
return $table;
}
/**
* @return array<int, int>
*/
private function boostTable(BoostPlacement $placement): array
{
return config($placement === BoostPlacement::Home ? 'credits.boost' : 'credits.boost_category', []);
}
public function proofPdfCredits(): int
{
return (int) config('credits.proof_pdf');

View file

@ -2,6 +2,7 @@
namespace App\Services\PressRelease;
use App\Enums\BoostPlacement;
use App\Enums\Portal;
use App\Enums\PressReleaseClassification;
use App\Enums\PressReleaseStatus;
@ -9,16 +10,24 @@ use App\Exceptions\BoostNotAllowedException;
use App\Models\Boost;
use App\Models\PressRelease;
use App\Models\User;
use App\Scopes\PortalScope;
use App\Services\Billing\CreditPricingService;
use App\Services\Billing\CreditWalletService;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
/**
* Boost-Kauf laut Decision-Update §2.2. Gate: nur veröffentlichte, grüne PMs.
* Bezahlung über die Credit-Wallet, Preis nach Laufzeit (7/14/30 Tage).
* Mehrfachkauf verlängert: der neue Zeitraum schließt an ein laufendes Ende an.
* Boost-Kauf laut Decision-Update §2.2 + Platzierungs-Entscheidung 13.07.2026.
* Gate: nur veröffentlichte, grüne PMs. Bezahlung über die Credit-Wallet,
* Preis nach Laufzeit (7/14/30 Tage) und Stufe: Startseiten-Boost (Startseite
* + Branchenseite) bzw. Kategorie-Boost (nur Branchenseite, halber Satz).
* Slot-Inventar je Ausgabe (config credits.boost_slots): mehr gleichzeitige
* Boosts als sichtbare Plätze werden nicht verkauft — der Kauf wirft dann
* BoostNotAllowedException::soldOut() mit dem frühesten freien Termin.
* Mehrfachkauf derselben Stufe verlängert: der neue Zeitraum schließt an ein
* laufendes Ende an und braucht keinen freien Slot (die PM hält ihren).
*/
class BoostService
{
@ -36,40 +45,111 @@ class BoostService
&& $pressRelease->classification === PressReleaseClassification::Green;
}
public function isBoosted(PressRelease $pressRelease): bool
public function isBoosted(PressRelease $pressRelease, ?BoostPlacement $placement = null): bool
{
return $pressRelease->boosts()->active()->exists();
return $pressRelease->boosts()
->active()
->when($placement, fn (Builder $query) => $query->forPlacement($placement))
->exists();
}
/**
* Ende des aktuell laufenden Boosts (das späteste zukünftige `ends_at`)
* oder null, wenn die PM gerade nicht geboostet ist.
* Ende des aktuell laufenden Boosts (das späteste zukünftige `ends_at`),
* optional je Stufe, oder null, wenn die PM gerade nicht geboostet ist.
*/
public function activeUntil(PressRelease $pressRelease): ?Carbon
public function activeUntil(PressRelease $pressRelease, ?BoostPlacement $placement = null): ?Carbon
{
$endsAt = $pressRelease->boosts()->active()->max('ends_at');
$endsAt = $pressRelease->boosts()
->active()
->when($placement, fn (Builder $query) => $query->forPlacement($placement))
->max('ends_at');
return $endsAt ? Carbon::parse($endsAt) : null;
}
/**
* Bucht einen Boost. Wirft BoostNotAllowedException am Gate und
* Slot-Verfügbarkeit der Stufe für diese PM. Eine Verlängerung ist immer
* verfügbar (die PM hält ihren Slot); sonst muss auf jeder Ausgabe, auf
* der die PM erscheint (Portal × Sprache, Kategorie-Boost zusätzlich je
* Rubrik), ein Slot frei sein. `freeAt` nennt bei „ausgebucht" den
* frühesten Zeitpunkt, zu dem wieder ein Platz frei wird.
*
* @return array{available: bool, freeAt: Carbon|null}
*/
public function slotAvailability(PressRelease $pressRelease, BoostPlacement $placement): array
{
if ($this->isBoosted($pressRelease, $placement)) {
return ['available' => true, 'freeAt' => null];
}
$limit = $this->slotLimit($placement);
$freeAt = null;
foreach ($this->affectedPortals($pressRelease) as $portal) {
// Je Konkurrent zählt seine PM einen Slot, gehalten bis zum
// spätesten Boost-Ende (Verlängerungen schließen lückenlos an).
$heldUntil = Boost::query()
->forPlacement($placement)
->active()
->where('press_release_id', '!=', $pressRelease->id)
->whereHas('pressRelease', function (Builder $query) use ($portal, $pressRelease, $placement): void {
$query
->withoutGlobalScope(PortalScope::class)
->whereIn('portal', [$portal, Portal::Both->value])
->where('language', $pressRelease->language)
->when(
$placement === BoostPlacement::Category,
fn (Builder $query) => $query->where('category_id', $pressRelease->category_id),
);
})
->groupBy('press_release_id')
->selectRaw('press_release_id, MAX(ends_at) AS held_until')
->pluck('held_until');
if ($heldUntil->count() < $limit) {
continue;
}
// Diese Ausgabe ist voll: frei, sobald der zuerst endende
// Konkurrent seinen Slot räumt. Bei mehreren vollen Ausgaben
// zählt die späteste (die PM braucht überall einen Platz).
$portalFreeAt = Carbon::parse($heldUntil->min());
$freeAt = $freeAt === null ? $portalFreeAt : $freeAt->max($portalFreeAt);
}
return ['available' => $freeAt === null, 'freeAt' => $freeAt];
}
public function slotLimit(BoostPlacement $placement): int
{
return (int) config("credits.boost_slots.{$placement->value}");
}
/**
* Bucht einen Boost der angegebenen Stufe. Wirft BoostNotAllowedException
* am Gate (nicht boostbar bzw. Slots ausgebucht) und
* InvalidArgumentException bei unbekannter Laufzeit; die Wallet-Belastung
* wirft InsufficientCreditsException, falls das Guthaben nicht reicht.
*/
public function boost(User $user, PressRelease $pressRelease, int $days): Boost
public function boost(User $user, PressRelease $pressRelease, int $days, BoostPlacement $placement = BoostPlacement::Home): Boost
{
if (! $this->canBoost($pressRelease)) {
throw BoostNotAllowedException::notBoostable();
}
$credits = $this->pricing->boostCredits($days);
$credits = $this->pricing->boostCredits($days, $placement);
$boost = DB::transaction(function () use ($user, $pressRelease, $days, $credits): Boost {
$startsAt = $this->activeUntil($pressRelease) ?? now();
$boost = DB::transaction(function () use ($user, $pressRelease, $days, $credits, $placement): Boost {
$availability = $this->slotAvailability($pressRelease, $placement);
if (! $availability['available']) {
throw BoostNotAllowedException::soldOut($placement, $availability['freeAt']);
}
$startsAt = $this->activeUntil($pressRelease, $placement) ?? now();
$boost = $pressRelease->boosts()->create([
'user_id' => $user->id,
'placement' => $placement,
'days' => $days,
'credits_charged' => $credits,
'starts_at' => $startsAt,
@ -79,14 +159,14 @@ class BoostService
$this->wallet->debit(
$user,
$credits,
"Boost {$days} Tage – PM #{$pressRelease->id}",
"{$placement->label()} {$days} Tage – PM #{$pressRelease->id}",
$boost,
);
return $boost;
});
$this->forgetPlacementCaches($pressRelease);
$this->forgetPlacementCaches($pressRelease, $placement);
return $boost;
}
@ -94,13 +174,12 @@ class BoostService
/**
* Gecachte Start-/Branchenseiten der betroffenen Ausgaben verwerfen,
* damit die bezahlte Platzierung sofort sichtbar wird statt erst nach
* Ablauf der Cache-TTL (HomePageData/CategoryPageData, 300 s).
* Ablauf der Cache-TTL (HomePageData/CategoryPageData, 300 s). Der
* Kategorie-Boost berührt die Startseite nicht.
*/
private function forgetPlacementCaches(PressRelease $pressRelease): void
private function forgetPlacementCaches(PressRelease $pressRelease, BoostPlacement $placement): void
{
$portals = $pressRelease->portal === Portal::Both
? [Portal::Presseecho->value, Portal::Businessportal24->value]
: [$pressRelease->portal->value];
$portals = $this->affectedPortals($pressRelease);
// Branchenseite der eigenen Rubrik plus Eltern-Rubrik (deren Seite
// schließt Unterkategorien mit ein), je hinterlegter Sprache.
@ -111,7 +190,9 @@ class BoostService
->merge($category?->parent?->translations ?? []);
foreach ($portals as $portal) {
Cache::forget(sprintf('web.home.%s.%s', $portal, $pressRelease->language));
if ($placement === BoostPlacement::Home) {
Cache::forget(sprintf('web.home.%s.%s', $portal, $pressRelease->language));
}
foreach ($translations as $translation) {
if (filled($translation->slug)) {
@ -120,4 +201,16 @@ class BoostService
}
}
}
/**
* Portale, auf deren Ausgaben die PM erscheint.
*
* @return list<string>
*/
private function affectedPortals(PressRelease $pressRelease): array
{
return $pressRelease->portal === Portal::Both
? [Portal::Presseecho->value, Portal::Businessportal24->value]
: [$pressRelease->portal->value];
}
}

View file

@ -2,6 +2,7 @@
namespace App\Services\Web;
use App\Enums\BoostPlacement;
use App\Models\Category;
use App\Models\Company;
use App\Models\PressRelease;
@ -97,13 +98,27 @@ class CategoryPageData
// 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()
// markieren sie über `boostedIds`. Kategorie-Boosts haben Vorrang —
// ihr Slot-Inventar (max. 3 je Branchenseite) ist die verkaufte
// Garantie; Startseiten-Boosts erscheinen hier zusätzlich, solange
// Plätze übrig sind (Entscheidung 13.07.2026).
$categoryBoosted = $base()
->boosted(BoostPlacement::Category)
->with($with)
->orderByDesc('published_at')
->limit(3)
->get();
$homeBoostSlots = 3 - $categoryBoosted->count();
$homeBoosted = $homeBoostSlots > 0
? $base()
->boosted(BoostPlacement::Home)
->when($categoryBoosted->isNotEmpty(), fn (Builder $query) => $query->whereNotIn('id', $categoryBoosted->modelKeys()))
->with($with)
->orderByDesc('published_at')
->limit($homeBoostSlots)
->get()
: collect();
$boostedReleases = $categoryBoosted->concat($homeBoosted)->values();
$boostedIds = $boostedReleases->modelKeys();
$leadRelease = $boostedReleases->first()

View file

@ -2,6 +2,7 @@
namespace App\Services\Web;
use App\Enums\BoostPlacement;
use App\Enums\Portal;
use App\Models\Category;
use App\Models\Company;
@ -75,8 +76,10 @@ class HomePageData
// 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`.
// Nur Startseiten-Boosts; der günstigere Kategorie-Boost kauft
// ausschließlich die Branchenseite (Entscheidung 13.07.2026).
$boostedReleases = $publishedQuery()
->boosted()
->boosted(BoostPlacement::Home)
->with($with)
->orderByDesc('published_at')
->limit(5)

View file

@ -26,7 +26,7 @@ class PreisePageData
->values()
->all();
$boost = collect(config('credits.boost', []))
$staffel = static fn (array $table): array => collect($table)
->map(static fn (int $credits, int|string $days): array => [
'days' => (int) $days,
'credits' => $credits,
@ -38,7 +38,9 @@ class PreisePageData
'plans' => Plan::query()->active()->get(),
'singlePmPriceCents' => (int) config('billing.single_pm_price_cents'),
'extraPm' => $extraPm,
'boost' => $boost,
'boost' => $staffel(config('credits.boost', [])),
// Kategorie-Boost (nur Branchenseite, halber Satz — 13.07.2026).
'boostCategory' => $staffel(config('credits.boost_category', [])),
'proofPdf' => (int) config('credits.proof_pdf'),
'creditPacks' => config('credits.packs', []),
// TODO: Lineup/Preise hängen am Bookings-/Magic-Link-Rework — final abstimmen.

View file

@ -28,8 +28,9 @@ return [
],
/*
| Boost (Platzierung Startseite + Branchenseite) — nach Dauer in Tagen.
| Pro-Tag-Preis sinkt mit der Dauer; Einstieg bleibt unter dem PM-Preis.
| Startseiten-Boost (Platzierung Startseite + Branchenseite) — nach Dauer
| in Tagen. Pro-Tag-Preis sinkt mit der Dauer; Einstieg bleibt unter dem
| PM-Preis.
*/
'boost' => [
7 => 12,
@ -37,6 +38,27 @@ return [
30 => 35,
],
/*
| Kategorie-Boost (nur Branchenseite) — 50 % vom Startseiten-Boost,
| aufgerundet (Entscheidung 13.07.2026).
*/
'boost_category' => [
7 => 6,
14 => 10,
30 => 18,
],
/*
| Slot-Inventar je Ausgabe (Portal × Sprache): mehr gleichzeitige Boosts
| als sichtbare Plätze werden nicht verkauft — das Kauf-Modal zeigt
| stattdessen „Derzeit ausgebucht" mit dem frühesten freien Termin.
| `home` gilt je Startseite, `category` je Branchenseite (Rubrik).
*/
'boost_slots' => [
'home' => 5,
'category' => 3,
],
/*
| Veröffentlichungsnachweis-PDF — pauschal pro PM (Impulskauf).
*/

View file

@ -2,6 +2,7 @@
namespace Database\Factories;
use App\Enums\BoostPlacement;
use App\Models\Boost;
use App\Models\PressRelease;
use App\Models\User;
@ -20,6 +21,7 @@ class BoostFactory extends Factory
return [
'press_release_id' => PressRelease::factory(),
'user_id' => User::factory(),
'placement' => BoostPlacement::Home,
'days' => 7,
'credits_charged' => 12,
'starts_at' => now(),
@ -34,4 +36,15 @@ class BoostFactory extends Factory
'ends_at' => now()->subDay(),
]);
}
/**
* Kategorie-Boost (nur Branchenseite, halber Satz).
*/
public function category(): static
{
return $this->state(fn (): array => [
'placement' => BoostPlacement::Category,
'credits_charged' => 6,
]);
}
}

View file

@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Boost-Stufen (Entscheidung 13.07.2026): `home` = Startseiten-Boost
* (Startseite + Branchenseite, voller Preis), `category` = Kategorie-Boost
* (nur Branchenseite, halber Preis). Bestehende Boosts sind Startseiten-Boosts
* (bisheriges Verhalten). Der Index trägt die Slot-Zählung je Stufe.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('boosts', function (Blueprint $table): void {
$table->string('placement', 20)->default('home')->after('user_id');
$table->index(['placement', 'ends_at']);
});
}
public function down(): void
{
Schema::table('boosts', function (Blueprint $table): void {
$table->dropIndex(['placement', 'ends_at']);
$table->dropColumn('placement');
});
}
};

View file

@ -1,5 +1,6 @@
<?php
use App\Enums\BoostPlacement;
use App\Enums\Portal;
use App\Enums\SinglePurchaseStatus;
use App\Enums\Tier;
@ -323,6 +324,7 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte
'isCurrent' => $t === $currentTier,
])->all(),
'boostStaffel' => $pricing->boostOptions(),
'boostCategoryStaffel' => $pricing->boostOptions(BoostPlacement::Category),
'proofPrice' => $pricing->proofPdfCredits(),
];
}
@ -953,12 +955,13 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte
</table>
</div>
{{-- Boost nach Laufzeit --}}
{{-- Boost nach Laufzeit (zwei Stufen — 13.07.2026) --}}
<div class="md:px-6">
<div class="flex items-center gap-2 mb-3">
<flux:icon.rocket-launch class="size-4 text-[color:var(--color-hub)]" />
<h3 class="text-[13.5px] font-bold text-[color:var(--color-ink)] m-0">{{ __('Boost — Platzierung') }}</h3>
</div>
<div class="text-[11px] font-semibold uppercase tracking-wide text-[color:var(--color-ink-3)]">{{ __('Startseiten-Boost') }}</div>
<table class="w-full text-[12.5px]">
<tbody class="divide-y divide-[color:var(--color-bg-rule)]">
@foreach ($boostStaffel as $days => $credits)
@ -969,6 +972,17 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte
@endforeach
</tbody>
</table>
<div class="text-[11px] font-semibold uppercase tracking-wide text-[color:var(--color-ink-3)] mt-3">{{ __('Kategorie-Boost (nur Branchenseite)') }}</div>
<table class="w-full text-[12.5px]">
<tbody class="divide-y divide-[color:var(--color-bg-rule)]">
@foreach ($boostCategoryStaffel as $days => $credits)
<tr>
<td class="py-1.5 text-[color:var(--color-ink-2)]">{{ __(':days Tage', ['days' => $days]) }}</td>
<td class="py-1.5 text-right text-[color:var(--color-ink)]">{{ $credits }} {{ __('Credits') }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
{{-- Veröffentlichungsnachweis --}}

View file

@ -487,7 +487,8 @@ new #[Layout('components.layouts.app'), Title('Meine Pressemitteilungen')] class
@if ($boostEndsAt = $boostEndsById->get($pr->id))
{{-- Laufende Boost-Platzierung (Add-on) direkt unter dem Status. --}}
<div class="mt-1">
<span class="badge hub dot" title="{{ __('Boost — Platzierung auf Start- und Branchenseite') }}">
{{-- Deckt beide Stufen ab (Startseiten-/Kategorie-Boost), daher neutral formuliert. --}}
<span class="badge hub dot" title="{{ __('Boost — bezahlte Platzierung (Details auf der PM-Seite)') }}">
{{ __('Boost bis :date', ['date' => $boostEndsAt->format('d.m.Y')]) }}
</span>
</div>

View file

@ -1,5 +1,6 @@
<?php
use App\Enums\BoostPlacement;
use App\Enums\PressReleaseEventType;
use App\Enums\PressReleaseShareChannel;
use App\Enums\PressReleaseStatus;
@ -52,9 +53,10 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
* Kontext fürs Kauf-Bestätigungs-Modal (Entscheidung 10.07.): Add-ons
* buchen erst nach explizitem „Kostenpflichtig buchen", damit ein Klick
* auf eine Laufzeit nicht sofort Credits abbucht. Läuft bereits ein
* Boost, tragen extendsUntil/newEndsAt den Verlängerungs-Hinweis.
* Boost derselben Stufe, tragen extendsUntil/newEndsAt den
* Verlängerungs-Hinweis.
*
* @var array{type: 'boost'|'proof', label: string, credits: int, days: int|null, extendsUntil: string|null, newEndsAt: string|null}|null
* @var array{type: 'boost'|'proof', placement: string|null, label: string, credits: int, days: int|null, extendsUntil: string|null, newEndsAt: string|null}|null
*/
public ?array $pendingPurchase = null;
@ -136,24 +138,31 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
/**
* Kauf-Bestätigung für einen Boost öffnen (Abbuchung erst in
* buyPendingAddOn).
* buyPendingAddOn). Stufe: Startseiten-Boost oder Kategorie-Boost.
*/
public function confirmBoost(int $days): void
public function confirmBoost(int $days, string $placement = 'home'): void
{
$options = app(CreditPricingService::class)->boostOptions();
$placement = BoostPlacement::tryFrom($placement);
if (! $placement) {
return;
}
$options = app(CreditPricingService::class)->boostOptions($placement);
if (! isset($options[$days])) {
return;
}
// Läuft bereits ein Boost, verlängert die Buchung dessen Laufzeit —
// das Modal weist ausdrücklich darauf hin (Entscheidung 10.07.).
$activeUntil = app(BoostService::class)->activeUntil($this->getMyPR());
// Läuft bereits ein Boost dieser Stufe, verlängert die Buchung dessen
// Laufzeit — das Modal weist ausdrücklich darauf hin (10.07.).
$activeUntil = app(BoostService::class)->activeUntil($this->getMyPR(), $placement);
$this->purchaseConfirmed = false;
$this->pendingPurchase = [
'type' => 'boost',
'label' => __('Boost — :days Tage Platzierung', ['days' => $days]),
'placement' => $placement->value,
'label' => __(':placement — :days Tage', ['placement' => $placement->label(), 'days' => $days]),
'credits' => $options[$days],
'days' => $days,
'extendsUntil' => $activeUntil?->format('d.m.Y H:i'),
@ -171,6 +180,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
$this->purchaseConfirmed = false;
$this->pendingPurchase = [
'type' => 'proof',
'placement' => null,
'label' => __('Veröffentlichungsnachweis (PDF)'),
'credits' => app(ProofPdfService::class)->priceCredits(),
'days' => null,
@ -209,7 +219,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
}
if ($pending['type'] === 'boost') {
$this->buyBoost($pending['days']);
$this->buyBoost($pending['days'], $pending['placement'] ?? 'home');
return;
}
@ -219,18 +229,26 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
/**
* Boost (Platzierung) aus der Wallet buchen — nur veröffentlichte, grüne
* PMs sind boostbar (Gate im BoostService).
* PMs sind boostbar; ausgebuchte Slots lehnt der BoostService ab (Gate).
*/
public function buyBoost(int $days): void
public function buyBoost(int $days, string $placement = 'home'): void
{
$placement = BoostPlacement::tryFrom($placement);
if (! $placement) {
return;
}
$pr = $this->getMyPR();
$this->authorize('view', $pr);
try {
app(BoostService::class)->boost(auth()->user(), $pr, $days);
app(BoostService::class)->boost(auth()->user(), $pr, $days, $placement);
Flux::toast(
heading: __('Boost gebucht'),
text: __('Ihre Pressemitteilung wird jetzt auf Start- und Branchenseite hervorgehoben.'),
heading: __(':placement gebucht', ['placement' => $placement->label()]),
text: $placement === BoostPlacement::Home
? __('Ihre Pressemitteilung wird jetzt auf Start- und Branchenseite hervorgehoben.')
: __('Ihre Pressemitteilung wird jetzt auf der Branchenseite ihrer Rubrik hervorgehoben.'),
variant: 'success',
);
} catch (InsufficientCreditsException $e) {
@ -300,6 +318,41 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
]);
}
/**
* Anzeige-Daten der beiden Boost-Stufen (Startseiten-/Kategorie-Boost)
* fürs Add-on-Panel: Preise, laufende Platzierung, Slot-Verfügbarkeit
* („Derzeit ausgebucht — frei ab …", Entscheidung 13.07.).
*
* @return array<string, array{label: string, description: string, options: array<int, int>, isBoosted: bool, activeUntil: \Illuminate\Support\Carbon|null, available: bool, freeAt: \Illuminate\Support\Carbon|null}>
*/
private function boostAddOns(PressRelease $pr, BoostService $boostService): array
{
$pricing = app(CreditPricingService::class);
$canBoost = $boostService->canBoost($pr);
$addOns = [];
foreach (BoostPlacement::cases() as $placement) {
// Slot-Abfrage nur, wenn die PM überhaupt boostbar ist.
$availability = $canBoost
? $boostService->slotAvailability($pr, $placement)
: ['available' => false, 'freeAt' => null];
$addOns[$placement->value] = [
'label' => $placement->label(),
'description' => $placement === BoostPlacement::Home
? __('Hervorhebung auf Start- und Branchenseite.')
: __('Hervorhebung auf der Branchenseite Ihrer Rubrik.'),
'options' => $pricing->boostOptions($placement),
'isBoosted' => $boostService->isBoosted($pr, $placement),
'activeUntil' => $boostService->activeUntil($pr, $placement),
'available' => $availability['available'],
'freeAt' => $availability['freeAt'],
];
}
return $addOns;
}
public function with(): array
{
$pr = $this->getMyPR();
@ -335,9 +388,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
: [],
'creditBalance' => $user->creditBalance(),
'canBoost' => $boostService->canBoost($pr),
'isBoosted' => $boostService->isBoosted($pr),
'boostedUntil' => $boostService->activeUntil($pr),
'boostOptions' => app(CreditPricingService::class)->boostOptions(),
'boostAddOns' => $this->boostAddOns($pr, $boostService),
'proofPrice' => $proofService->priceCredits(),
'proofPurchased' => $proofService->hasPurchased($user, $pr),
'companyLogoUrl' => $company?->logoUrl(),
@ -647,45 +698,60 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
<span class="section-eyebrow">{{ __('Add-ons') }}</span>
<span class="badge">{{ __(':n Credits Guthaben', ['n' => $creditBalance]) }}</span>
</div>
<div class="p-5 grid gap-5 md:grid-cols-2">
{{-- Boost --}}
<div class="space-y-3">
<div class="flex items-start gap-3">
<div class="w-9 h-9 rounded-[5px] flex items-center justify-center flex-shrink-0 bg-[color:var(--color-hub-soft)] text-[color:var(--color-hub)]">
<flux:icon.rocket-launch class="size-[18px]" />
<div class="p-5 grid gap-5 md:grid-cols-3">
{{-- Boost-Stufen: Startseiten-Boost + Kategorie-Boost (13.07.) --}}
@foreach ($boostAddOns as $placementValue => $addOn)
<div class="space-y-3 @if (! $loop->first) md:border-l md:border-[color:var(--color-bg-rule)] md:pl-5 @endif">
<div class="flex items-start gap-3">
<div class="w-9 h-9 rounded-[5px] flex items-center justify-center flex-shrink-0 bg-[color:var(--color-hub-soft)] text-[color:var(--color-hub)]">
@if ($placementValue === 'home')
<flux:icon.rocket-launch class="size-[18px]" />
@else
<flux:icon.tag class="size-[18px]" />
@endif
</div>
<div class="min-w-0">
<h3 class="text-[14px] font-bold text-[color:var(--color-ink)] m-0">{{ $addOn['label'] }}</h3>
<p class="text-[12.5px] text-[color:var(--color-ink-2)] mt-1 mb-0">
{{ $addOn['description'] }}
</p>
</div>
</div>
<div class="min-w-0">
<h3 class="text-[14px] font-bold text-[color:var(--color-ink)] m-0">{{ __('Boost — Platzierung') }}</h3>
<p class="text-[12.5px] text-[color:var(--color-ink-2)] mt-1 mb-0">
{{ __('Hervorhebung auf Start- und Branchenseite.') }}
</p>
</div>
</div>
@if ($isBoosted)
<div class="rounded-[6px] border border-[color:var(--color-ok)]/30 bg-[color:var(--color-ok-soft)] px-3 py-2 text-[12.5px] text-[color:var(--color-gain-deep)]">
{{ __('Aktiv bis :date', ['date' => $boostedUntil?->format('d.m.Y H:i')]) }}
</div>
@endif
@if ($canBoost)
<div class="flex flex-wrap gap-2">
@foreach ($boostOptions as $days => $credits)
<flux:button size="sm" variant="primary"
wire:click="confirmBoost({{ $days }})" wire:loading.attr="disabled">
{{ __(':days Tage für :credits Credits buchen', ['days' => $days, 'credits' => $credits]) }}
</flux:button>
@endforeach
</div>
@if ($isBoosted)
<p class="text-[11.5px] text-[color:var(--color-ink-3)] m-0">{{ __('Ein weiterer Kauf verlängert die Laufzeit.') }}</p>
@if ($addOn['isBoosted'])
<div class="rounded-[6px] border border-[color:var(--color-ok)]/30 bg-[color:var(--color-ok-soft)] px-3 py-2 text-[12.5px] text-[color:var(--color-gain-deep)]">
{{ __('Aktiv bis :date', ['date' => $addOn['activeUntil']?->format('d.m.Y H:i')]) }}
</div>
@endif
@else
<p class="text-[12px] text-[color:var(--color-ink-3)] m-0">
{{ __('Nur grün geprüfte Pressemitteilungen sind boostbar.') }}
</p>
@endif
</div>
@if ($canBoost && $addOn['available'])
<div class="flex flex-wrap gap-2">
@foreach ($addOn['options'] as $days => $credits)
<flux:button size="sm" variant="primary"
wire:click="confirmBoost({{ $days }}, '{{ $placementValue }}')" wire:loading.attr="disabled">
{{ __(':days Tage für :credits Credits buchen', ['days' => $days, 'credits' => $credits]) }}
</flux:button>
@endforeach
</div>
@if ($addOn['isBoosted'])
<p class="text-[11.5px] text-[color:var(--color-ink-3)] m-0">{{ __('Ein weiterer Kauf verlängert die Laufzeit.') }}</p>
@endif
@elseif ($canBoost)
{{-- Slot-Inventar erschöpft: ehrlich ausweisen statt verkaufen (13.07.). --}}
<div class="rounded-[6px] border border-[color:var(--color-warn)]/40 bg-[color:var(--color-warn-soft)] px-3 py-2 text-[12.5px] text-[color:var(--color-ink)]">
@if ($addOn['freeAt'])
{{ __('Derzeit ausgebucht — der nächste Platz wird am :date frei.', ['date' => $addOn['freeAt']->format('d.m.Y')]) }}
@else
{{ __('Derzeit ausgebucht.') }}
@endif
</div>
@else
<p class="text-[12px] text-[color:var(--color-ink-3)] m-0">
{{ __('Nur grün geprüfte Pressemitteilungen sind boostbar.') }}
</p>
@endif
</div>
@endforeach
{{-- Veröffentlichungsnachweis --}}
<div class="space-y-3 md:border-l md:border-[color:var(--color-bg-rule)] md:pl-5">

View file

@ -1,9 +1,13 @@
<?php
use App\Enums\BoostPlacement;
use App\Enums\Portal;
use App\Enums\PressReleaseClassification;
use App\Enums\PressReleaseStatus;
use App\Exceptions\BoostNotAllowedException;
use App\Exceptions\InsufficientCreditsException;
use App\Models\Boost;
use App\Models\Category;
use App\Models\PressRelease;
use App\Models\User;
use App\Services\Billing\CreditWalletService;
@ -14,14 +18,36 @@ beforeEach(function (): void {
$this->wallet = app(CreditWalletService::class);
});
function boostableRelease(User $user): PressRelease
function boostableRelease(User $user, array $attributes = []): PressRelease
{
return PressRelease::factory()->published()->create([
'user_id' => $user->id,
'classification' => PressReleaseClassification::Green->value,
...$attributes,
]);
}
/**
* Belegt `$count` Slots der Stufe mit fremden, aktiv geboosteten PMs
* derselben Ausgabe (Portal × Sprache, optional Rubrik).
*
* @return array<int, Boost>
*/
function occupySlots(int $count, BoostPlacement $placement, Portal $portal, ?Category $category = null): array
{
return collect(range(1, $count))->map(function (int $i) use ($placement, $portal, $category): Boost {
$pr = PressRelease::factory()->published()->forPortal($portal)->create([
'classification' => PressReleaseClassification::Green->value,
'category_id' => ($category ?? Category::factory()->create())->id,
]);
return Boost::factory()->for($pr)->create([
'placement' => $placement,
'ends_at' => now()->addDays($i),
]);
})->all();
}
test('only published green releases are boostable', function () {
$user = User::factory()->create();
@ -96,3 +122,114 @@ test('a second boost extends from the running end instead of overlapping', funct
expect($second->starts_at->timestamp)->toBe($first->ends_at->timestamp);
expect($second->ends_at->timestamp)->toBe($first->ends_at->copy()->addDays(14)->timestamp);
});
test('a category boost costs half the staffel and only activates the category placement', function () {
$user = User::factory()->create();
$this->wallet->credit($user, 10);
$pr = boostableRelease($user);
$boost = $this->service->boost($user, $pr, 7, BoostPlacement::Category);
expect($boost->placement)->toBe(BoostPlacement::Category);
expect($boost->credits_charged)->toBe(6);
expect($this->wallet->balance($user))->toBe(4);
expect($this->service->isBoosted($pr, BoostPlacement::Category))->toBeTrue();
expect($this->service->isBoosted($pr, BoostPlacement::Home))->toBeFalse();
});
test('the placements extend independently of each other', function () {
$user = User::factory()->create();
$this->wallet->credit($user, 100);
$pr = boostableRelease($user);
$home = $this->service->boost($user, $pr, 30, BoostPlacement::Home);
$category = $this->service->boost($user, $pr, 7, BoostPlacement::Category);
// Der Kategorie-Boost startet sofort — er verlängert NICHT den laufenden
// Startseiten-Boost (getrennte Stufen).
expect($category->starts_at->timestamp)->toBeLessThan($home->ends_at->timestamp);
expect($this->service->activeUntil($pr, BoostPlacement::Category)->timestamp)
->toBe($category->ends_at->timestamp);
});
test('a full homepage slot inventory rejects new home boosts with the next free date', function () {
$this->freezeTime();
$user = User::factory()->create();
$this->wallet->credit($user, 50);
$pr = boostableRelease($user, ['portal' => Portal::Presseecho->value]);
// 5 von 5 Startseiten-Slots der Ausgabe belegt; der früheste wird in
// einem Tag frei.
occupySlots(5, BoostPlacement::Home, Portal::Presseecho);
expect(fn () => $this->service->boost($user, $pr, 7))
->toThrow(BoostNotAllowedException::class, 'ausgebucht');
expect($this->wallet->balance($user))->toBe(50);
$availability = $this->service->slotAvailability($pr, BoostPlacement::Home);
expect($availability['available'])->toBeFalse();
expect($availability['freeAt']->timestamp)->toBe(now()->addDay()->timestamp);
});
test('an extension is allowed even when all slots are taken — the release keeps its slot', function () {
$user = User::factory()->create();
$this->wallet->credit($user, 50);
$pr = boostableRelease($user, ['portal' => Portal::Presseecho->value]);
$first = $this->service->boost($user, $pr, 7);
occupySlots(5, BoostPlacement::Home, Portal::Presseecho);
$second = $this->service->boost($user, $pr, 7);
expect($second->starts_at->timestamp)->toBe($first->ends_at->timestamp);
});
test('slots on another portal or in another language do not block the booking', function () {
$user = User::factory()->create();
$this->wallet->credit($user, 50);
$pr = boostableRelease($user, ['portal' => Portal::Presseecho->value, 'language' => 'de']);
// Volle Startseite beim anderen Spoke — für diese PM irrelevant.
occupySlots(5, BoostPlacement::Home, Portal::Businessportal24);
expect($this->service->boost($user, $pr, 7))->toBeInstanceOf(Boost::class);
});
test('a full category page rejects new category boosts while other categories stay open', function () {
$user = User::factory()->create();
$this->wallet->credit($user, 50);
$category = Category::factory()->create();
$pr = boostableRelease($user, [
'portal' => Portal::Presseecho->value,
'category_id' => $category->id,
]);
// 3 von 3 Kategorie-Slots dieser Rubrik belegt.
occupySlots(3, BoostPlacement::Category, Portal::Presseecho, $category);
expect(fn () => $this->service->boost($user, $pr, 7, BoostPlacement::Category))
->toThrow(BoostNotAllowedException::class, 'ausgebucht');
// Eine PM in einer anderen Rubrik ist nicht betroffen.
$other = boostableRelease($user, ['portal' => Portal::Presseecho->value]);
expect($this->service->boost($user, $other, 7, BoostPlacement::Category))->toBeInstanceOf(Boost::class);
});
test('home boosts do not consume category slots and vice versa', function () {
$user = User::factory()->create();
$this->wallet->credit($user, 50);
$category = Category::factory()->create();
$pr = boostableRelease($user, [
'portal' => Portal::Presseecho->value,
'category_id' => $category->id,
]);
// Startseite komplett voll (auch mit PMs derselben Rubrik) — die
// Kategorie-Slots bleiben davon unberührt.
occupySlots(5, BoostPlacement::Home, Portal::Presseecho, $category);
expect($this->service->boost($user, $pr, 7, BoostPlacement::Category))->toBeInstanceOf(Boost::class);
});

View file

@ -1,5 +1,6 @@
<?php
use App\Enums\BoostPlacement;
use App\Enums\Tier;
use App\Models\Plan;
use App\Models\User;
@ -46,6 +47,16 @@ test('boost credits follow the duration staffel and reject unknown durations', f
->toThrow(InvalidArgumentException::class);
});
test('category boost credits are half the homepage staffel, rounded up', function () {
expect($this->pricing->boostCredits(7, BoostPlacement::Category))->toBe(6);
expect($this->pricing->boostCredits(14, BoostPlacement::Category))->toBe(10);
expect($this->pricing->boostCredits(30, BoostPlacement::Category))->toBe(18);
expect($this->pricing->boostOptions(BoostPlacement::Category))->toBe([7 => 6, 14 => 10, 30 => 18]);
expect(fn () => $this->pricing->boostCredits(99, BoostPlacement::Category))
->toThrow(InvalidArgumentException::class);
});
test('flat prices match the decision update', function () {
expect($this->pricing->proofPdfCredits())->toBe(3);
expect($this->pricing->depublishCredits())->toBe(35);

View file

@ -1,5 +1,6 @@
<?php
use App\Enums\BoostPlacement;
use App\Enums\PressReleaseClassification;
use App\Models\Boost;
use App\Models\Category;
@ -8,6 +9,7 @@ use App\Models\PressRelease;
use App\Models\User;
use App\Services\Billing\CreditWalletService;
use App\Services\Billing\ProofPdfService;
use App\Services\PressRelease\BoostService;
use Database\Seeders\RolesAndPermissionsSeeder;
use Livewire\Volt\Volt as LivewireVolt;
use Tests\TestCase;
@ -49,8 +51,10 @@ test('a published green release shows the boost and proof add-ons', function ()
LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
->assertSee('Add-ons')
->assertSee('Boost — Platzierung')
->assertSee('Startseiten-Boost')
->assertSee('7 Tage für 12 Credits buchen')
->assertSee('Kategorie-Boost')
->assertSee('7 Tage für 6 Credits buchen')
->assertSee('Veröffentlichungsnachweis')
->assertSee('Für 3 Credits kaufen');
});
@ -66,7 +70,8 @@ test('add-on buttons only debit after the purchase confirmation modal', function
->call('confirmBoost', 14)
->assertSet('pendingPurchase', [
'type' => 'boost',
'label' => 'Boost — 14 Tage Platzierung',
'placement' => 'home',
'label' => 'Startseiten-Boost — 14 Tage',
'credits' => 20,
'days' => 14,
'extendsUntil' => null,
@ -160,6 +165,57 @@ test('booking a boost from the detail page debits the wallet and activates it',
expect($pr->fresh()->isBoosted())->toBeTrue();
});
test('a category boost books through the confirmation modal at the half staffel', function () {
/** @var TestCase $this */
['customer' => $customer, 'pr' => $pr] = publishedGreenPrForOwner();
$this->wallet->credit($customer, 10);
$this->actingAs($customer);
LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
->call('confirmBoost', 7, 'category')
->assertSet('pendingPurchase.placement', 'category')
->assertSet('pendingPurchase.label', 'Kategorie-Boost — 7 Tage')
->assertSet('pendingPurchase.credits', 6)
->set('purchaseConfirmed', true)
->call('buyPendingAddOn');
expect($customer->fresh()->creditBalance())->toBe(4); // 10 - 6 (7 Tage, halber Satz)
expect(app(BoostService::class)->isBoosted($pr->fresh(), BoostPlacement::Category))->toBeTrue();
expect(app(BoostService::class)->isBoosted($pr->fresh(), BoostPlacement::Home))->toBeFalse();
});
test('sold-out slots replace the buy buttons with the next free date', function () {
/** @var TestCase $this */
['customer' => $customer, 'pr' => $pr] = publishedGreenPrForOwner();
$this->wallet->credit($customer, 40);
// Alle 5 Startseiten-Slots der Ausgabe belegt (fremde PMs, gleiche Ausgabe).
$freeAt = now()->addDays(2);
foreach (range(1, 5) as $i) {
$other = PressRelease::factory()->published()->create([
'portal' => $pr->portal->value,
'classification' => PressReleaseClassification::Green->value,
]);
Boost::factory()->for($other)->create(['ends_at' => $freeAt->copy()->addDays($i - 1)]);
}
$this->actingAs($customer);
LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
->assertSee('Derzeit ausgebucht — der nächste Platz wird am '.$freeAt->format('d.m.Y').' frei.')
->assertDontSee('7 Tage für 12 Credits buchen')
// Der Kategorie-Boost hat ein eigenes Slot-Inventar und bleibt buchbar.
->assertSee('7 Tage für 6 Credits buchen');
// Serverseitiges Gate: ein direkter Kauf-Call bucht trotzdem nicht.
LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
->call('buyBoost', 7);
expect($customer->fresh()->creditBalance())->toBe(40);
expect($pr->fresh()->isBoosted())->toBeFalse();
});
test('buying a proof from the detail page unlocks the download', function () {
/** @var TestCase $this */
['customer' => $customer, 'pr' => $pr] = publishedGreenPrForOwner();

View file

@ -1,5 +1,6 @@
<?php
use App\Enums\BoostPlacement;
use App\Enums\Portal;
use App\Enums\PressReleaseClassification;
use App\Models\Boost;
@ -10,6 +11,7 @@ use App\Models\User;
use App\Services\Billing\CreditWalletService;
use App\Services\PressRelease\BoostService;
use App\Services\Web\HomePageData;
use Illuminate\Support\Facades\Cache;
use Tests\TestCase;
/**
@ -126,3 +128,75 @@ test('a boosted release leads its category page and is labelled', function () {
'Neueste unbezahlte Meldung',
]);
});
test('a category boost leads its category page but never the homepage', function () {
/** @var TestCase $this */
['boosted' => $boosted, 'newest' => $newest] = boostPlacementFixture();
Boost::factory()->for($boosted)->category()->create();
// Branchenseite: Kategorie-Boost besetzt den Lead-Slot.
$this->get('https://presseecho.test/de/category/energie-klima')
->assertSuccessful()
->assertSeeTextInOrder(['Top-Platzierung', 'Geboostete aeltere Meldung']);
// Startseite: bleibt bei der Chronologie — der Kategorie-Boost kauft
// sie nicht mit (Entscheidung 13.07.).
$data = app(HomePageData::class)->build(Portal::Presseecho);
expect($data['leadRelease']->id)->toBe($newest->id)
->and($data['boostedIds'])->toBe([]);
});
test('category boosts take the category slots ahead of homepage boosts', function () {
/** @var TestCase $this */
['boosted' => $categoryBoosted, 'newest' => $newest, 'category' => $category, 'company' => $company] = boostPlacementFixture();
// Neuere PM mit Startseiten-Boost — auf der Branchenseite hat der
// Kategorie-Boost trotzdem Vorrang (seine 3 Slots sind die Garantie).
$homeBoosted = PressRelease::factory()
->published()
->forPortal(Portal::Presseecho)
->for($category)
->for($company)
->create([
'title' => 'Startseiten-Boost Meldung',
'slug' => 'startseiten-boost-meldung',
'published_at' => now()->subHour(),
'classification' => PressReleaseClassification::Green->value,
]);
Boost::factory()->for($categoryBoosted)->category()->create();
Boost::factory()->for($homeBoosted)->create();
// Ohne Vorrang würde die neuere Startseiten-Boost-Meldung den Lead
// chronologisch gewinnen — der Kategorie-Boost muss vorne stehen.
$this->get('https://presseecho.test/de/category/energie-klima')
->assertSuccessful()
->assertSeeTextInOrder([
'Geboostete aeltere Meldung',
'Startseiten-Boost Meldung',
]);
});
test('booking a category boost invalidates the category page but keeps the homepage cache', function () {
/** @var TestCase $this */
['boosted' => $boosted, 'newest' => $newest] = boostPlacementFixture();
$owner = User::factory()->create(['is_active' => true]);
app(CreditWalletService::class)->credit($owner, 40);
// Beide Seiten in den Cache legen — noch ohne Boost.
expect(app(HomePageData::class)->build(Portal::Presseecho)['leadRelease']->id)->toBe($newest->id);
$this->get('https://presseecho.test/de/category/energie-klima')
->assertSuccessful()
->assertDontSeeText('Top-Platzierung');
app(BoostService::class)->boost($owner, $boosted, 7, BoostPlacement::Category);
// Branchenseite sofort aktualisiert, Startseiten-Cache bleibt stehen
// (der Kategorie-Boost berührt die Startseite nicht).
expect(Cache::has('web.home.presseecho.de'))->toBeTrue();
$this->get('https://presseecho.test/de/category/energie-klima')
->assertSuccessful()
->assertSeeText('Top-Platzierung');
});