UI: Credit-Wallet-Hub + Per-PM Add-ons im Backend-Pressekonto

Wallet-Oekonomie im Customer-Bereich sichtbar gemacht:

- Buchungen & Add-ons: Guthaben-Anzeige, Credit-Paket-Topup (Bonus-Staffel),
  Extra-PM-Kauf aus der Wallet mit kontextuellem Mini-Checkout-Hinweis,
  Pruefkontingent-Status (frei/Monat, heute/Tageslimit), Wallet-Verlauf
- PM-Detailseite: Add-ons-Panel fuer veroeffentlichte PMs -- Boost buchen
  (7/14/30 Tage, Gate nur gruen, Verlaengerung), Veroeffentlichungsnachweis
  kaufen + Download. Aktionen ueber BoostService/ProofPdfService

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-06-17 15:02:31 +00:00
parent 344aac0740
commit 77a4476fd0
4 changed files with 482 additions and 2 deletions

View file

@ -3,8 +3,14 @@
use App\Enums\Portal;
use App\Enums\SinglePurchaseStatus;
use App\Enums\UserPaymentOptionStatus;
use App\Exceptions\InsufficientCreditsException;
use App\Models\Plan;
use App\Models\UserPaymentOption;
use App\Services\Billing\CreditPricingService;
use App\Services\Billing\CreditTopupService;
use App\Services\Billing\CreditWalletService;
use App\Services\Billing\ExtraPmPurchaseService;
use App\Services\PressRelease\ReviewCheckService;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Volt\Component;
@ -12,15 +18,41 @@ use Livewire\Volt\Component;
/**
* Buchungen & Add-ons (Phase 9F): Tarif-Raster mit Stripe-Checkout,
* Einzel-PM als separater No-Abo-Block, Bestandstarife (MAN-Kreis) und
* echte Buchungsdaten. Launch-Credits (Extra-PM, Boost, Nachweis-PDF)
* folgen mit Phase 9I, das Credit-Wallet mit Phase 2.
* echte Buchungsdaten. Die Credit-Wallet (Topup, Extra-PM, Prüfkontingent)
* bildet den Add-on-Block laut Decision-Update (Rev. 4) ab.
*/
new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class extends Component {
/** Erfolgs-/Fehlerhinweis des Wallet-Blocks (Extra-PM-Kauf). */
public ?string $walletNotice = null;
public ?string $walletError = null;
public function formatEuro(int $cents): string
{
return number_format($cents / 100, $cents % 100 === 0 ? 0 : 2, ',', '.') . ' €';
}
/**
* Extra-PM aus der Wallet nachkaufen (tier-gestaffelter Preis). Reicht das
* Guthaben nicht, zeigt der kontextuelle Mini-Checkout den Fehlbetrag.
*/
public function buyExtraPm(ExtraPmPurchaseService $service): void
{
$this->walletNotice = null;
$this->walletError = null;
try {
$service->purchase(auth()->user());
$this->walletNotice = __('Extra-Pressemitteilung gebucht — Ihr PM-Kontingent wurde um eine Veröffentlichung erhöht.');
} catch (InsufficientCreditsException $e) {
$this->walletError = __('Die Extra-PM kostet :need Credits, Ihr Guthaben beträgt :have. Bitte laden Sie mindestens :short Credits nach.', [
'need' => $e->required,
'have' => $e->available,
'short' => $e->shortfall(),
]);
}
}
public function planIcon(Plan $plan): string
{
return match ($plan->slug) {
@ -91,6 +123,16 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte
'quotaTotal' => $user->pressReleaseQuotaTotal(),
'singlePmPrice' => $this->formatEuro((int) config('billing.single_pm_price_cents')),
'singlePmAvailable' => (bool) config('billing.single_pm_stripe_price_id'),
// Credit-Wallet & Add-ons (Decision-Update Rev. 4).
'creditBalance' => $user->creditBalance(),
'creditPacks' => app(CreditTopupService::class)->packs(),
'extraPmPrice' => app(CreditPricingService::class)->extraPmCreditsFor($user),
'reviewFreeRemaining' => app(ReviewCheckService::class)->freeRemaining($user),
'reviewFreeQuota' => app(CreditPricingService::class)->reviewFreeQuota($user->currentTier()),
'reviewUsedToday' => app(ReviewCheckService::class)->usedToday($user),
'reviewDailyLimit' => app(CreditPricingService::class)->reviewDailyLimit(),
'recentTransactions' => $user->creditTransactions()->latest()->limit(6)->get(),
];
}
}; ?>
@ -308,6 +350,153 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte
</article>
@endif
{{-- ============== CREDIT-WALLET & ADD-ONS ============== --}}
<section class="space-y-4">
<div>
<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)]">
{{ __('Guthaben, Extra-PM und Prüfungen') }}
</h2>
<p class="text-[12.5px] text-[color:var(--color-ink-3)] max-w-[640px] m-0">
{{ __('1 Credit = 1 €. Mit Guthaben kaufen Sie Extra-Pressemitteilungen, Boosts und Veröffentlichungsnachweise — größere Pakete enthalten Bonus-Credits.') }}
</p>
</div>
{{-- Guthaben + Aufladen --}}
<article class="panel">
<div class="p-5 grid gap-5 md:grid-cols-[0.9fr_1.6fr] md:items-center">
<div class="flex items-center gap-4">
<div
class="w-12 h-12 rounded-[8px] flex items-center justify-center flex-shrink-0 bg-[color:var(--color-hub-soft)] border border-[color:var(--color-hub-soft-2)] text-[color:var(--color-hub)]">
<flux:icon.wallet class="size-6" />
</div>
<div>
<div class="text-[12px] text-[color:var(--color-ink-3)]">{{ __('Ihr Guthaben') }}</div>
<div class="text-[28px] font-bold tracking-[-0.7px] leading-none text-[color:var(--color-ink)]">
{{ $creditBalance }} <span class="text-[14px] font-normal text-[color:var(--color-ink-3)]">{{ __('Credits') }}</span>
</div>
</div>
</div>
<div class="space-y-2">
<div class="text-[12px] text-[color:var(--color-ink-3)]">{{ __('Guthaben aufladen') }}</div>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-2">
@foreach ($creditPacks as $pack)
@php($bonus = $pack['credits'] - intdiv($pack['price_cents'], 100))
<a href="{{ route('me.checkout.credit-topup', ['pack' => $pack['key']]) }}"
class="rounded-[6px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-subtle)] p-3 text-center transition-colors hover:border-[color:var(--color-hub)] hover:bg-[color:var(--color-hub-soft)]"
wire:key="pack-{{ $pack['key'] }}">
<div class="text-[16px] font-bold text-[color:var(--color-ink)]">{{ $pack['credits'] }} {{ __('Credits') }}</div>
<div class="text-[11.5px] text-[color:var(--color-ink-3)]">{{ $this->formatEuro($pack['price_cents']) }} {{ __('netto') }}</div>
@if ($bonus > 0)
<span class="badge ok mt-1 inline-block">+{{ $bonus }} {{ __('Bonus') }}</span>
@endif
</a>
@endforeach
</div>
</div>
</div>
</article>
{{-- Mini-Checkout-Hinweise (Extra-PM) --}}
@if ($walletNotice)
<div class="px-4 py-3 rounded-[5px] border text-[13px] flex items-start gap-3
bg-[color:var(--color-hub-soft)] border-[color:var(--color-hub-soft-2)] text-[color:var(--color-ink-2)]">
<flux:icon.check-circle class="size-[16px] flex-shrink-0 mt-0.5 text-[color:var(--color-hub)]" />
<div class="flex-1">{{ $walletNotice }}</div>
</div>
@endif
@if ($walletError)
<div class="px-4 py-3 rounded-[5px] border text-[13px] flex items-start gap-3
bg-[color:var(--color-bg-subtle)] border-[color:var(--color-bg-rule)] text-[color:var(--color-ink-2)]">
<flux:icon.information-circle class="size-[16px] flex-shrink-0 mt-0.5 text-[color:var(--color-ink-3)]" />
<div class="flex-1">{{ $walletError }}</div>
</div>
@endif
<div class="grid gap-4 md:grid-cols-2">
{{-- Extra-PM --}}
<article class="panel">
<div class="p-5 space-y-4">
<div class="flex items-start gap-3">
<div class="w-10 h-10 rounded-[6px] flex items-center justify-center flex-shrink-0 bg-[color:var(--color-hub-soft)] text-[color:var(--color-hub)]">
<flux:icon.document-plus class="size-5" />
</div>
<div class="min-w-0">
<h3 class="text-[15px] font-bold text-[color:var(--color-ink)] m-0">{{ __('Extra-Pressemitteilung') }}</h3>
<p class="text-[12.5px] text-[color:var(--color-ink-2)] mt-1 mb-0">
{{ __('Eine weitere PM, wenn Ihr Monatskontingent voll ist — ohne Tarifwechsel. Preis je nach Tarif.') }}
</p>
</div>
</div>
<div class="flex items-center justify-between gap-3 border-t border-[color:var(--color-bg-rule)] pt-4">
<div>
<span class="text-[22px] font-bold text-[color:var(--color-ink)]">{{ $extraPmPrice }}</span>
<span class="text-[12px] text-[color:var(--color-ink-3)]">{{ __('Credits') }}</span>
</div>
<flux:button size="sm" variant="primary" wire:click="buyExtraPm" wire:loading.attr="disabled">
{{ __('Aus Guthaben buchen') }}
</flux:button>
</div>
</div>
</article>
{{-- Prüfkontingent --}}
<article class="panel">
<div class="p-5 space-y-4">
<div class="flex items-start gap-3">
<div class="w-10 h-10 rounded-[6px] flex items-center justify-center flex-shrink-0 bg-[color:var(--color-hub-soft)] text-[color:var(--color-hub)]">
<flux:icon.shield-check class="size-5" />
</div>
<div class="min-w-0">
<h3 class="text-[15px] font-bold text-[color:var(--color-ink)] m-0">{{ __('Prüfkontingent') }}</h3>
<p class="text-[12.5px] text-[color:var(--color-ink-2)] mt-1 mb-0">
{{ __('Inklusive Vorab-Prüfungen pro Monat. Darüber hinaus zieht jede Prüfung 1 Credit.') }}
</p>
</div>
</div>
<div class="border-t border-[color:var(--color-bg-rule)] pt-4 space-y-1.5">
<div class="flex items-baseline justify-between">
<span class="text-[12.5px] text-[color:var(--color-ink-3)]">{{ __('Frei diesen Monat') }}</span>
<span class="text-[16px] font-semibold text-[color:var(--color-ink)]">{{ $reviewFreeRemaining }} / {{ $reviewFreeQuota }}</span>
</div>
<div class="flex items-baseline justify-between">
<span class="text-[12.5px] text-[color:var(--color-ink-3)]">{{ __('Heute genutzt') }}</span>
<span class="text-[13px] text-[color:var(--color-ink-2)]">{{ $reviewUsedToday }} / {{ $reviewDailyLimit }} {{ __('Tageslimit') }}</span>
</div>
</div>
</div>
</article>
</div>
{{-- Wallet-Verlauf --}}
@if ($recentTransactions->isNotEmpty())
<article class="panel">
<div class="p-5">
<div class="text-[12px] text-[color:var(--color-ink-3)] mb-3">{{ __('Letzte Wallet-Buchungen') }}</div>
<div class="divide-y divide-[color:var(--color-bg-rule)]">
@foreach ($recentTransactions as $tx)
<div class="py-2.5 first:pt-0 last:pb-0 flex items-center justify-between gap-4"
wire:key="tx-{{ $tx->id }}">
<div class="min-w-0">
<div class="text-[13px] text-[color:var(--color-ink)] truncate">{{ $tx->description ?? $tx->type->label() }}</div>
<div class="text-[11px] text-[color:var(--color-ink-3)]">{{ $tx->created_at?->format('d.m.Y H:i') }}</div>
</div>
<div @class([
'text-[14px] font-semibold flex-shrink-0',
'text-[color:var(--color-gain-deep)]' => $tx->amount_credits > 0,
'text-[color:var(--color-ink-2)]' => $tx->amount_credits < 0,
])>
{{ $tx->amount_credits > 0 ? '+' : '' }}{{ $tx->amount_credits }}
</div>
</div>
@endforeach
</div>
</div>
</article>
@endif
</section>
{{-- ============== TARIF-RASTER ============== --}}
<section class="space-y-4" x-data="{ interval: '{{ $currentInterval ?? 'monthly' }}' }">
<div class="flex items-end justify-between gap-4 flex-wrap">

View file

@ -1,10 +1,15 @@
<?php
use App\Enums\PressReleaseStatus;
use App\Exceptions\BoostNotAllowedException;
use App\Exceptions\InsufficientCreditsException;
use App\Models\PressRelease;
use App\Services\Auth\MagicLinkGenerator;
use App\Services\Billing\CreditPricingService;
use App\Services\Billing\ProofPdfService;
use App\Services\PressRelease\BlacklistViolationException;
use App\Services\PressRelease\BookingRequiredException;
use App\Services\PressRelease\BoostService;
use App\Services\PressRelease\PressReleaseCoverImage;
use App\Services\PressRelease\PressReleaseService;
use App\Services\PressRelease\QuotaExceededException;
@ -83,6 +88,60 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
Flux::toast(text: __('Vorschau-Link wurde erzeugt.'), variant: 'success');
}
/**
* Boost (Platzierung) aus der Wallet buchen nur veröffentlichte, grüne
* PMs sind boostbar (Gate im BoostService).
*/
public function buyBoost(int $days): void
{
$pr = $this->getMyPR();
$this->authorize('view', $pr);
try {
app(BoostService::class)->boost(auth()->user(), $pr, $days);
Flux::toast(
heading: __('Boost gebucht'),
text: __('Ihre Pressemitteilung wird jetzt auf Start- und Branchenseite hervorgehoben.'),
variant: 'success',
);
} catch (InsufficientCreditsException $e) {
Flux::toast(
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) {
Flux::toast(text: $e->getMessage(), variant: 'warning', duration: 8000);
}
}
/**
* Veröffentlichungsnachweis-PDF kaufen (3 Credits, einmalig je PM). Danach
* steht der Download über die Nachweis-Route bereit.
*/
public function buyProof(): void
{
$pr = $this->getMyPR();
$this->authorize('downloadProof', $pr);
try {
app(ProofPdfService::class)->purchase(auth()->user(), $pr);
Flux::toast(
heading: __('Nachweis gekauft'),
text: __('Der Veröffentlichungsnachweis steht jetzt zum Download bereit.'),
variant: 'success',
);
} catch (InsufficientCreditsException $e) {
Flux::toast(
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,
);
}
}
public function with(): array
{
$pr = $this->getMyPR();
@ -101,8 +160,18 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
$company = $pr->company?->loadCount(['pressReleases', 'contacts']);
$boostService = app(BoostService::class);
$proofService = app(ProofPdfService::class);
return [
'pr' => $pr,
'creditBalance' => $user->creditBalance(),
'canBoost' => $boostService->canBoost($pr),
'isBoosted' => $boostService->isBoosted($pr),
'boostedUntil' => $boostService->activeUntil($pr),
'boostOptions' => app(CreditPricingService::class)->boostOptions(),
'proofPrice' => $proofService->priceCredits(),
'proofPurchased' => $proofService->hasPurchased($user, $pr),
'companyLogoUrl' => $company?->logoUrl(),
'companyInitials' => $this->companyInitials($company?->name),
'companyMetaLine' => $this->companyMetaLine($company),
@ -349,6 +418,82 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
</article>
@endif
{{-- ============== ADD-ONS (Boost + Veröffentlichungsnachweis) ============== --}}
@if ($pr->status === PressReleaseStatus::Published)
<article class="panel">
<div class="panel-head">
<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>
<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?->copy()->setTimezone(\App\Models\PressRelease::DISPLAY_TIMEZONE)->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="filled"
wire:click="buyBoost({{ $days }})" wire:loading.attr="disabled">
{{ __(':days Tage · :credits Credits', ['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>
@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>
{{-- Veröffentlichungsnachweis --}}
<div class="space-y-3 md:border-l md:border-[color:var(--color-bg-rule)] md:pl-5">
<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.document-check class="size-[18px]" />
</div>
<div class="min-w-0">
<h3 class="text-[14px] font-bold text-[color:var(--color-ink)] m-0">{{ __('Veröffentlichungsnachweis') }}</h3>
<p class="text-[12.5px] text-[color:var(--color-ink-2)] mt-1 mb-0">
{{ __('PDF mit Datum, Portal und URL — :credits Credits.', ['credits' => $proofPrice]) }}
</p>
</div>
</div>
@if ($proofPurchased)
<flux:button size="sm" variant="primary" icon="arrow-down-tray"
href="{{ route('me.press-releases.proof', $pr->id) }}">
{{ __('Nachweis herunterladen') }}
</flux:button>
@else
<flux:button size="sm" variant="primary" wire:click="buyProof" wire:loading.attr="disabled">
{{ __('Für :credits Credits kaufen', ['credits' => $proofPrice]) }}
</flux:button>
@endif
</div>
</div>
</article>
@endif
@if ($pr->status === PressReleaseStatus::Review)
<article class="panel" style="border-color:var(--color-warn); border-left-width:3px; background:var(--color-warn-soft);">
<div class="panel-head">