Add-on-Panel zeigt freie Boost-Plaetze je Stufe

slotAvailability() liefert zusaetzlich free/limit (knappste Ausgabe, ohne
den eigenen gehaltenen Slot); solange buchbar, steht unter den Kauf-Buttons
"Noch X von Y Plaetzen frei." — bei laufendem Boost weiterhin nur der
Verlaengerungs-Hinweis.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-07-13 13:12:05 +00:00
parent 842d6a2bd0
commit 0cddf5fbdc
5 changed files with 65 additions and 12 deletions

View file

@ -71,18 +71,17 @@ class BoostService
* Slot-Verfügbarkeit der Stufe für diese PM. Eine Verlängerung ist immer * 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 * 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 * der die PM erscheint (Portal × Sprache, Kategorie-Boost zusätzlich je
* Rubrik), ein Slot frei sein. `freeAt` nennt bei „ausgebucht" den * Rubrik), ein Slot frei sein. `free` zählt die noch freien Plätze
* frühesten Zeitpunkt, zu dem wieder ein Platz frei wird. * (knappste Ausgabe, ohne einen eigenen gehaltenen Slot); `freeAt` nennt
* bei „ausgebucht" den frühesten Zeitpunkt, zu dem ein Platz frei wird.
* *
* @return array{available: bool, freeAt: Carbon|null} * @return array{available: bool, free: int, limit: int, freeAt: Carbon|null}
*/ */
public function slotAvailability(PressRelease $pressRelease, BoostPlacement $placement): array public function slotAvailability(PressRelease $pressRelease, BoostPlacement $placement): array
{ {
if ($this->isBoosted($pressRelease, $placement)) {
return ['available' => true, 'freeAt' => null];
}
$limit = $this->slotLimit($placement); $limit = $this->slotLimit($placement);
$holdsSlot = $this->isBoosted($pressRelease, $placement);
$free = $limit;
$freeAt = null; $freeAt = null;
foreach ($this->affectedPortals($pressRelease) as $portal) { foreach ($this->affectedPortals($pressRelease) as $portal) {
@ -106,7 +105,9 @@ class BoostService
->selectRaw('press_release_id, MAX(ends_at) AS held_until') ->selectRaw('press_release_id, MAX(ends_at) AS held_until')
->pluck('held_until'); ->pluck('held_until');
if ($heldUntil->count() < $limit) { $free = min($free, max(0, $limit - $heldUntil->count()));
if ($holdsSlot || $heldUntil->count() < $limit) {
continue; continue;
} }
@ -117,7 +118,12 @@ class BoostService
$freeAt = $freeAt === null ? $portalFreeAt : $freeAt->max($portalFreeAt); $freeAt = $freeAt === null ? $portalFreeAt : $freeAt->max($portalFreeAt);
} }
return ['available' => $freeAt === null, 'freeAt' => $freeAt]; return [
'available' => $holdsSlot || $free > 0,
'free' => $free,
'limit' => $limit,
'freeAt' => $freeAt,
];
} }
public function slotLimit(BoostPlacement $placement): int public function slotLimit(BoostPlacement $placement): int

View file

@ -35,7 +35,8 @@ Die zwei offenen Punkte von Montag sind entschieden und umgesetzt.
Startseiten-Boosts füllen nur übrige Plätze). Startseiten-Boosts füllen nur übrige Plätze).
- Kategorie-Boost invalidiert nur die Branchenseiten-Caches, nicht die - Kategorie-Boost invalidiert nur die Branchenseiten-Caches, nicht die
Startseite. Startseite.
- Kunden-UI (PM-Seite): zwei Boost-Angebote im Add-on-Panel; bei vollem - Kunden-UI (PM-Seite): zwei Boost-Angebote im Add-on-Panel; solange buchbar,
zeigt jede Stufe „Noch X von Y Plätzen frei." (knappste Ausgabe); bei vollem
Inventar „Derzeit ausgebucht — der nächste Platz wird am TT.MM.JJJJ frei." Inventar „Derzeit ausgebucht — der nächste Platz wird am TT.MM.JJJJ frei."
statt der Kauf-Buttons (serverseitig gegated). statt der Kauf-Buttons (serverseitig gegated).
- Preisseite (`/preise`, beide Staffeln + Leistungsbeschreibungs-Note zu - Preisseite (`/preise`, beide Staffeln + Leistungsbeschreibungs-Note zu

View file

@ -323,7 +323,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
* fürs Add-on-Panel: Preise, laufende Platzierung, Slot-Verfügbarkeit * fürs Add-on-Panel: Preise, laufende Platzierung, Slot-Verfügbarkeit
* („Derzeit ausgebucht — frei ab …", Entscheidung 13.07.). * („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}> * @return array<string, array{label: string, description: string, options: array<int, int>, isBoosted: bool, activeUntil: \Illuminate\Support\Carbon|null, available: bool, free: int, limit: int, freeAt: \Illuminate\Support\Carbon|null}>
*/ */
private function boostAddOns(PressRelease $pr, BoostService $boostService): array private function boostAddOns(PressRelease $pr, BoostService $boostService): array
{ {
@ -335,7 +335,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
// Slot-Abfrage nur, wenn die PM überhaupt boostbar ist. // Slot-Abfrage nur, wenn die PM überhaupt boostbar ist.
$availability = $canBoost $availability = $canBoost
? $boostService->slotAvailability($pr, $placement) ? $boostService->slotAvailability($pr, $placement)
: ['available' => false, 'freeAt' => null]; : ['available' => false, 'free' => 0, 'limit' => $boostService->slotLimit($placement), 'freeAt' => null];
$addOns[$placement->value] = [ $addOns[$placement->value] = [
'label' => $placement->label(), 'label' => $placement->label(),
@ -346,6 +346,8 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
'isBoosted' => $boostService->isBoosted($pr, $placement), 'isBoosted' => $boostService->isBoosted($pr, $placement),
'activeUntil' => $boostService->activeUntil($pr, $placement), 'activeUntil' => $boostService->activeUntil($pr, $placement),
'available' => $availability['available'], 'available' => $availability['available'],
'free' => $availability['free'],
'limit' => $availability['limit'],
'freeAt' => $availability['freeAt'], 'freeAt' => $availability['freeAt'],
]; ];
} }
@ -735,6 +737,11 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
</div> </div>
@if ($addOn['isBoosted']) @if ($addOn['isBoosted'])
<p class="text-[11.5px] text-[color:var(--color-ink-3)] m-0">{{ __('Ein weiterer Kauf verlängert die Laufzeit.') }}</p> <p class="text-[11.5px] text-[color:var(--color-ink-3)] m-0">{{ __('Ein weiterer Kauf verlängert die Laufzeit.') }}</p>
@else
{{-- Slot-Transparenz (13.07.): knappe Plätze sichtbar machen. --}}
<p class="text-[11.5px] text-[color:var(--color-ink-3)] m-0">
{{ __('Noch :free von :limit Plätzen frei.', ['free' => $addOn['free'], 'limit' => $addOn['limit']]) }}
</p>
@endif @endif
@elseif ($canBoost) @elseif ($canBoost)
{{-- Slot-Inventar erschöpft: ehrlich ausweisen statt verkaufen (13.07.). --}} {{-- Slot-Inventar erschöpft: ehrlich ausweisen statt verkaufen (13.07.). --}}

View file

@ -169,9 +169,24 @@ test('a full homepage slot inventory rejects new home boosts with the next free
$availability = $this->service->slotAvailability($pr, BoostPlacement::Home); $availability = $this->service->slotAvailability($pr, BoostPlacement::Home);
expect($availability['available'])->toBeFalse(); expect($availability['available'])->toBeFalse();
expect($availability['free'])->toBe(0);
expect($availability['limit'])->toBe(5);
expect($availability['freeAt']->timestamp)->toBe(now()->addDay()->timestamp); expect($availability['freeAt']->timestamp)->toBe(now()->addDay()->timestamp);
}); });
test('the availability reports the remaining free slots', function () {
$user = User::factory()->create();
$pr = boostableRelease($user, ['portal' => Portal::Presseecho->value]);
expect($this->service->slotAvailability($pr, BoostPlacement::Home))
->toMatchArray(['available' => true, 'free' => 5, 'limit' => 5]);
occupySlots(2, BoostPlacement::Home, Portal::Presseecho);
expect($this->service->slotAvailability($pr, BoostPlacement::Home))
->toMatchArray(['available' => true, 'free' => 3, 'limit' => 5]);
});
test('an extension is allowed even when all slots are taken — the release keeps its slot', function () { test('an extension is allowed even when all slots are taken — the release keeps its slot', function () {
$user = User::factory()->create(); $user = User::factory()->create();
$this->wallet->credit($user, 50); $this->wallet->credit($user, 50);

View file

@ -55,10 +55,34 @@ test('a published green release shows the boost and proof add-ons', function ()
->assertSee('7 Tage für 12 Credits buchen') ->assertSee('7 Tage für 12 Credits buchen')
->assertSee('Kategorie-Boost') ->assertSee('Kategorie-Boost')
->assertSee('7 Tage für 6 Credits buchen') ->assertSee('7 Tage für 6 Credits buchen')
// Slot-Transparenz: freie Plätze je Stufe (5 Startseite, 3 Branchenseite).
->assertSee('Noch 5 von 5 Plätzen frei.')
->assertSee('Noch 3 von 3 Plätzen frei.')
->assertSee('Veröffentlichungsnachweis') ->assertSee('Veröffentlichungsnachweis')
->assertSee('Für 3 Credits kaufen'); ->assertSee('Für 3 Credits kaufen');
}); });
test('the slot hint counts down when competitors occupy places', function () {
/** @var TestCase $this */
['customer' => $customer, 'pr' => $pr] = publishedGreenPrForOwner();
$this->wallet->credit($customer, 40);
// 2 der 5 Startseiten-Slots der Ausgabe sind belegt.
foreach (range(1, 2) as $i) {
$other = PressRelease::factory()->published()->create([
'portal' => $pr->portal->value,
'classification' => PressReleaseClassification::Green->value,
]);
Boost::factory()->for($other)->create();
}
$this->actingAs($customer);
LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
->assertSee('Noch 3 von 5 Plätzen frei.')
->assertSee('Noch 3 von 3 Plätzen frei.');
});
test('add-on buttons only debit after the purchase confirmation modal', function () { test('add-on buttons only debit after the purchase confirmation modal', function () {
/** @var TestCase $this */ /** @var TestCase $this */
['customer' => $customer, 'pr' => $pr] = publishedGreenPrForOwner(); ['customer' => $customer, 'pr' => $pr] = publishedGreenPrForOwner();