diff --git a/app/Services/PressRelease/BoostService.php b/app/Services/PressRelease/BoostService.php index 8f1301a7..12b03509 100644 --- a/app/Services/PressRelease/BoostService.php +++ b/app/Services/PressRelease/BoostService.php @@ -71,18 +71,17 @@ class BoostService * 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. + * Rubrik), ein Slot frei sein. `free` zählt die noch freien Plätze + * (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 { - if ($this->isBoosted($pressRelease, $placement)) { - return ['available' => true, 'freeAt' => null]; - } - $limit = $this->slotLimit($placement); + $holdsSlot = $this->isBoosted($pressRelease, $placement); + $free = $limit; $freeAt = null; foreach ($this->affectedPortals($pressRelease) as $portal) { @@ -106,7 +105,9 @@ class BoostService ->selectRaw('press_release_id, MAX(ends_at) AS held_until') ->pluck('held_until'); - if ($heldUntil->count() < $limit) { + $free = min($free, max(0, $limit - $heldUntil->count())); + + if ($holdsSlot || $heldUntil->count() < $limit) { continue; } @@ -117,7 +118,12 @@ class BoostService $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 diff --git a/dev/release/boost-platzierung-offen.md b/dev/release/boost-platzierung-offen.md index 7e243596..dd72d63a 100644 --- a/dev/release/boost-platzierung-offen.md +++ b/dev/release/boost-platzierung-offen.md @@ -35,7 +35,8 @@ Die zwei offenen Punkte von Montag sind entschieden und umgesetzt. Startseiten-Boosts füllen nur übrige Plätze). - Kategorie-Boost invalidiert nur die Branchenseiten-Caches, nicht die 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." statt der Kauf-Buttons (serverseitig gegated). - Preisseite (`/preise`, beide Staffeln + Leistungsbeschreibungs-Note zu diff --git a/resources/views/livewire/customer/press-releases/show.blade.php b/resources/views/livewire/customer/press-releases/show.blade.php index cc2d76cf..16cebee9 100644 --- a/resources/views/livewire/customer/press-releases/show.blade.php +++ b/resources/views/livewire/customer/press-releases/show.blade.php @@ -323,7 +323,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends * fürs Add-on-Panel: Preise, laufende Platzierung, Slot-Verfügbarkeit * („Derzeit ausgebucht — frei ab …", Entscheidung 13.07.). * - * @return array, isBoosted: bool, activeUntil: \Illuminate\Support\Carbon|null, available: bool, freeAt: \Illuminate\Support\Carbon|null}> + * @return array, 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 { @@ -335,7 +335,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends // Slot-Abfrage nur, wenn die PM überhaupt boostbar ist. $availability = $canBoost ? $boostService->slotAvailability($pr, $placement) - : ['available' => false, 'freeAt' => null]; + : ['available' => false, 'free' => 0, 'limit' => $boostService->slotLimit($placement), 'freeAt' => null]; $addOns[$placement->value] = [ 'label' => $placement->label(), @@ -346,6 +346,8 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends 'isBoosted' => $boostService->isBoosted($pr, $placement), 'activeUntil' => $boostService->activeUntil($pr, $placement), 'available' => $availability['available'], + 'free' => $availability['free'], + 'limit' => $availability['limit'], 'freeAt' => $availability['freeAt'], ]; } @@ -735,6 +737,11 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends @if ($addOn['isBoosted'])

{{ __('Ein weiterer Kauf verlängert die Laufzeit.') }}

+ @else + {{-- Slot-Transparenz (13.07.): knappe Plätze sichtbar machen. --}} +

+ {{ __('Noch :free von :limit Plätzen frei.', ['free' => $addOn['free'], 'limit' => $addOn['limit']]) }} +

@endif @elseif ($canBoost) {{-- Slot-Inventar erschöpft: ehrlich ausweisen statt verkaufen (13.07.). --}} diff --git a/tests/Feature/BoostServiceTest.php b/tests/Feature/BoostServiceTest.php index 568026d7..6f46cb58 100644 --- a/tests/Feature/BoostServiceTest.php +++ b/tests/Feature/BoostServiceTest.php @@ -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); expect($availability['available'])->toBeFalse(); + expect($availability['free'])->toBe(0); + expect($availability['limit'])->toBe(5); 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 () { $user = User::factory()->create(); $this->wallet->credit($user, 50); diff --git a/tests/Feature/PressReleaseShowAddonsTest.php b/tests/Feature/PressReleaseShowAddonsTest.php index 9d100231..22e5ac92 100644 --- a/tests/Feature/PressReleaseShowAddonsTest.php +++ b/tests/Feature/PressReleaseShowAddonsTest.php @@ -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('Kategorie-Boost') ->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('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 () { /** @var TestCase $this */ ['customer' => $customer, 'pr' => $pr] = publishedGreenPrForOwner();