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:
parent
0677728d05
commit
0474d22347
19 changed files with 702 additions and 98 deletions
34
app/Enums/BoostPlacement.php
Normal file
34
app/Enums/BoostPlacement.php
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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 --}}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue