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:
parent
344aac0740
commit
77a4476fd0
4 changed files with 482 additions and 2 deletions
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ use App\Models\Plan;
|
|||
use App\Models\SinglePurchase;
|
||||
use App\Models\User;
|
||||
use App\Models\UserPaymentOption;
|
||||
use App\Services\Billing\CreditWalletService;
|
||||
use App\Services\Billing\StripeCheckoutService;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Livewire\Volt\Volt as LivewireVolt;
|
||||
|
|
@ -162,3 +163,48 @@ test('the checkout success banner is shown after returning from stripe', functio
|
|||
->assertOk()
|
||||
->assertSee('Vielen Dank für Ihre Buchung!');
|
||||
});
|
||||
|
||||
test('the wallet block shows the balance and the credit pack topup links', function () {
|
||||
/** @var TestCase $this */
|
||||
$user = bookingsTestCustomer();
|
||||
app(CreditWalletService::class)->credit($user, 30);
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
LivewireVolt::test('customer.bookings')
|
||||
->assertSee('Credit-Wallet & Add-ons')
|
||||
->assertSee('30')
|
||||
->assertSee('27 Credits') // Bonus-Paket
|
||||
->assertSee(route('me.checkout.credit-topup', ['pack' => 'p25']), false)
|
||||
->assertSee('Prüfkontingent')
|
||||
->assertSee('4 / 4'); // Einzel-Tier: 4 Freiprüfungen
|
||||
});
|
||||
|
||||
test('buying an extra pm from the wallet succeeds with enough credits', function () {
|
||||
/** @var TestCase $this */
|
||||
$user = bookingsTestCustomer();
|
||||
app(CreditWalletService::class)->credit($user, 25);
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
LivewireVolt::test('customer.bookings')
|
||||
->call('buyExtraPm')
|
||||
->assertSee('Extra-Pressemitteilung gebucht');
|
||||
|
||||
expect($user->fresh()->creditBalance())->toBe(6); // 25 - 19 (Einzel-Tier)
|
||||
expect($user->singlePurchases()->grantingSubmission()->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('buying an extra pm without enough credits shows the mini checkout hint', function () {
|
||||
/** @var TestCase $this */
|
||||
$user = bookingsTestCustomer();
|
||||
app(CreditWalletService::class)->credit($user, 8);
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
LivewireVolt::test('customer.bookings')
|
||||
->call('buyExtraPm')
|
||||
->assertSee('Bitte laden Sie mindestens 11 Credits nach');
|
||||
|
||||
expect($user->singlePurchases()->count())->toBe(0);
|
||||
});
|
||||
|
|
|
|||
100
tests/Feature/PressReleaseShowAddonsTest.php
Normal file
100
tests/Feature/PressReleaseShowAddonsTest.php
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\PressReleaseClassification;
|
||||
use App\Models\Category;
|
||||
use App\Models\Company;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\User;
|
||||
use App\Services\Billing\CreditWalletService;
|
||||
use App\Services\Billing\ProofPdfService;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Livewire\Volt\Volt as LivewireVolt;
|
||||
use Tests\TestCase;
|
||||
|
||||
beforeEach(function (): void {
|
||||
/** @var TestCase $this */
|
||||
$this->seed(RolesAndPermissionsSeeder::class);
|
||||
$this->wallet = app(CreditWalletService::class);
|
||||
});
|
||||
|
||||
/**
|
||||
* @return array{customer: User, pr: PressRelease}
|
||||
*/
|
||||
function publishedGreenPrForOwner(): array
|
||||
{
|
||||
$customer = User::factory()->create(['is_active' => true]);
|
||||
$customer->assignRole('customer');
|
||||
|
||||
$company = Company::factory()->presseecho()->create();
|
||||
$customer->companies()->attach($company->id, ['role' => 'owner']);
|
||||
|
||||
$pr = PressRelease::factory()->published()->create([
|
||||
'user_id' => $customer->id,
|
||||
'company_id' => $company->id,
|
||||
'category_id' => Category::factory()->create()->id,
|
||||
'portal' => $company->portal->value,
|
||||
'classification' => PressReleaseClassification::Green->value,
|
||||
]);
|
||||
|
||||
return compact('customer', 'pr');
|
||||
}
|
||||
|
||||
test('a published green release shows the boost and proof add-ons', function () {
|
||||
/** @var TestCase $this */
|
||||
['customer' => $customer, 'pr' => $pr] = publishedGreenPrForOwner();
|
||||
$this->wallet->credit($customer, 40);
|
||||
|
||||
$this->actingAs($customer);
|
||||
|
||||
LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
|
||||
->assertSee('Add-ons')
|
||||
->assertSee('Boost — Platzierung')
|
||||
->assertSee('7 Tage · 12 Credits')
|
||||
->assertSee('Veröffentlichungsnachweis')
|
||||
->assertSee('Für 3 Credits kaufen');
|
||||
});
|
||||
|
||||
test('booking a boost from the detail page debits the wallet and activates it', function () {
|
||||
/** @var TestCase $this */
|
||||
['customer' => $customer, 'pr' => $pr] = publishedGreenPrForOwner();
|
||||
$this->wallet->credit($customer, 40);
|
||||
|
||||
$this->actingAs($customer);
|
||||
|
||||
LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
|
||||
->call('buyBoost', 14);
|
||||
|
||||
expect($customer->fresh()->creditBalance())->toBe(20); // 40 - 20 (14 Tage)
|
||||
expect($pr->fresh()->isBoosted())->toBeTrue();
|
||||
});
|
||||
|
||||
test('buying a proof from the detail page unlocks the download', function () {
|
||||
/** @var TestCase $this */
|
||||
['customer' => $customer, 'pr' => $pr] = publishedGreenPrForOwner();
|
||||
$this->wallet->credit($customer, 10);
|
||||
|
||||
$this->actingAs($customer);
|
||||
|
||||
LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
|
||||
->call('buyProof')
|
||||
->assertSee('Nachweis herunterladen');
|
||||
|
||||
expect(app(ProofPdfService::class)->hasPurchased($customer->fresh(), $pr))->toBeTrue();
|
||||
expect($customer->fresh()->creditBalance())->toBe(7);
|
||||
});
|
||||
|
||||
test('a non-green release cannot be boosted from the detail page', function () {
|
||||
/** @var TestCase $this */
|
||||
['customer' => $customer, 'pr' => $pr] = publishedGreenPrForOwner();
|
||||
$pr->update(['classification' => PressReleaseClassification::Yellow->value]);
|
||||
$this->wallet->credit($customer, 40);
|
||||
|
||||
$this->actingAs($customer);
|
||||
|
||||
LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
|
||||
->assertSee('Nur grün geprüfte Pressemitteilungen sind boostbar')
|
||||
->call('buyBoost', 7);
|
||||
|
||||
expect($customer->fresh()->creditBalance())->toBe(40); // nichts abgebucht
|
||||
expect($pr->fresh()->isBoosted())->toBeFalse();
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue