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
b1be14e1b3
commit
4853d48b83
19 changed files with 702 additions and 98 deletions
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue