Add-ons: Modal statt Toast bei fehlendem Credit-Guthaben

Scheiterte ein Boost- oder Nachweis-Kauf am Wallet-Guthaben, gab es nur
einen flüchtigen Toast ohne Weg zur Lösung. Jetzt öffnet sich ein Modal
mit Kosten, Guthaben und fehlender Differenz sowie einem Direktlink
"Credits aufladen" zur Wallet-Sektion der Buchungsseite (neuer
#credits-Anker).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-07-07 14:30:57 +00:00
parent 0f8f0b2d31
commit 9600a4f887
3 changed files with 99 additions and 13 deletions

View file

@ -775,7 +775,7 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte
</article> </article>
{{-- ============== CREDIT-WALLET & ADD-ONS ============== --}} {{-- ============== CREDIT-WALLET & ADD-ONS ============== --}}
<section class="space-y-4"> <section id="credits" class="space-y-4">
<div> <div>
<span class="section-eyebrow">{{ __('Credit-Wallet & Add-ons') }}</span> <span class="section-eyebrow">{{ __('Credit-Wallet & Add-ons') }}</span>
<h2 class="text-[22px] font-bold tracking-[-0.4px] mt-1 mb-1 text-[color:var(--color-ink)]"> <h2 class="text-[22px] font-bold tracking-[-0.4px] mt-1 mb-1 text-[color:var(--color-ink)]">

View file

@ -35,6 +35,14 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
public bool $depublicationConfirmed = false; public bool $depublicationConfirmed = false;
/**
* Kontext fürs „Nicht genügend Credits"-Modal (QA-Befund 07.07.):
* ['label' => Add-on-Name, 'required' => int, 'available' => int].
*
* @var array{label: string, required: int, available: int}|null
*/
public ?array $creditShortfall = null;
public function mount(int $id): void public function mount(int $id): void
{ {
$this->id = $id; $this->id = $id;
@ -112,12 +120,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
variant: 'success', variant: 'success',
); );
} catch (InsufficientCreditsException $e) { } catch (InsufficientCreditsException $e) {
Flux::toast( $this->showCreditShortfallModal(__('Der Boost'), $e);
heading: __('Nicht genügend Credits'),
text: __('Der Boost kostet :need Credits, Ihr Guthaben beträgt :have. Laden Sie unter „Buchungen & Add-ons" auf.', ['need' => $e->required, 'have' => $e->available]),
variant: 'warning',
duration: 8000,
);
} catch (BoostNotAllowedException $e) { } catch (BoostNotAllowedException $e) {
Flux::toast(text: $e->getMessage(), variant: 'warning', duration: 8000); Flux::toast(text: $e->getMessage(), variant: 'warning', duration: 8000);
} }
@ -140,15 +143,25 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
variant: 'success', variant: 'success',
); );
} catch (InsufficientCreditsException $e) { } catch (InsufficientCreditsException $e) {
Flux::toast( $this->showCreditShortfallModal(__('Der Veröffentlichungsnachweis'), $e);
heading: __('Nicht genügend Credits'),
text: __('Der Nachweis kostet :need Credits, Ihr Guthaben beträgt :have.', ['need' => $e->required, 'have' => $e->available]),
variant: 'warning',
duration: 8000,
);
} }
} }
/**
* Statt eines flüchtigen Toasts ein Modal mit Aufladen-Link öffnen
* (QA-Befund 07.07.): der Kunde soll direkt zur Wallet finden.
*/
private function showCreditShortfallModal(string $label, InsufficientCreditsException $e): void
{
$this->creditShortfall = [
'label' => $label,
'required' => $e->required,
'available' => $e->available,
];
Flux::modal('insufficient-credits')->show();
}
/** /**
* Unternehmer-Pflicht-Checkbox vor dem Depublikations-Checkout (T1neu): * Unternehmer-Pflicht-Checkbox vor dem Depublikations-Checkout (T1neu):
* protokolliert wie im Buchungs-Modal — der Checkout-Controller verlangt * protokolliert wie im Buchungs-Modal — der Checkout-Controller verlangt
@ -930,4 +943,41 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
</div> </div>
</article> </article>
@endif @endif
{{-- ============== MODAL: NICHT GENÜGEND CREDITS ==============
Öffnet sich serverseitig, wenn ein Add-on-Kauf (Boost/Nachweis)
am Wallet-Guthaben scheitert — mit Direktlink zum Aufladen. --}}
<flux:modal name="insufficient-credits" class="w-full max-w-md">
<div class="space-y-5">
<div>
<flux:text class="eyebrow muted">{{ __('Credit-Wallet') }}</flux:text>
<flux:heading size="lg">{{ __('Nicht genügend Credits') }}</flux:heading>
</div>
@if ($creditShortfall)
<div class="rounded-[6px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-4 text-[12.5px] leading-[1.6] text-[color:var(--color-ink-2)]">
<p class="m-0">
{{ __(':label kostet :required Credits — Ihr Guthaben beträgt :available Credits. Es fehlen :missing Credits.', [
'label' => $creditShortfall['label'],
'required' => $creditShortfall['required'],
'available' => $creditShortfall['available'],
'missing' => max(0, $creditShortfall['required'] - $creditShortfall['available']),
]) }}
</p>
<p class="m-0 mt-3 pt-3 border-t border-[color:var(--color-bg-rule)] text-[11.5px] text-[color:var(--color-ink-3)]">
{{ __('Guthaben laden Sie unter „Buchungen & Add-ons" auf — es steht sofort zur Verfügung (1 Credit = 1 €, größere Pakete enthalten Bonus-Credits).') }}
</p>
</div>
@endif
<div class="flex justify-end gap-2">
<flux:modal.close>
<flux:button variant="filled">{{ __('Schließen') }}</flux:button>
</flux:modal.close>
<flux:button variant="primary" icon="wallet" href="{{ route('me.bookings.index') }}#credits" wire:navigate>
{{ __('Credits aufladen') }}
</flux:button>
</div>
</div>
</flux:modal>
</div> </div>

View file

@ -83,6 +83,42 @@ test('buying a proof from the detail page unlocks the download', function () {
expect($customer->fresh()->creditBalance())->toBe(7); expect($customer->fresh()->creditBalance())->toBe(7);
}); });
test('an add-on purchase without enough credits opens the shortfall modal with a topup link', function () {
/** @var TestCase $this */
['customer' => $customer, 'pr' => $pr] = publishedGreenPrForOwner();
$this->wallet->credit($customer, 1);
$this->actingAs($customer);
LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
->call('buyProof')
->assertSet('creditShortfall', [
'label' => 'Der Veröffentlichungsnachweis',
'required' => 3,
'available' => 1,
])
->assertSee('Nicht genügend Credits')
->assertSee('Es fehlen 2 Credits.')
->assertSee('Credits aufladen')
->assertSee(route('me.bookings.index').'#credits', escape: false);
expect($customer->fresh()->creditBalance())->toBe(1); // nichts abgebucht
});
test('a boost without enough credits opens the shortfall modal instead of booking', function () {
/** @var TestCase $this */
['customer' => $customer, 'pr' => $pr] = publishedGreenPrForOwner();
$this->actingAs($customer);
LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
->call('buyBoost', 7)
->assertSet('creditShortfall.label', 'Der Boost')
->assertSee('Nicht genügend Credits');
expect($pr->fresh()->isBoosted())->toBeFalse();
});
test('a non-green release cannot be boosted from the detail page', function () { test('a non-green release cannot be boosted from the detail page', function () {
/** @var TestCase $this */ /** @var TestCase $this */
['customer' => $customer, 'pr' => $pr] = publishedGreenPrForOwner(); ['customer' => $customer, 'pr' => $pr] = publishedGreenPrForOwner();