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

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