presseportale/resources/views/livewire/customer/bookings.blade.php
Kevin Adametz 6a82e2a2a8 Buchungs-Seite: Feinschliff nach Review
- Aktueller-Tarif-Card erscheint erst mit vorhandener Buchung; die
  Kontingent-Kachel zeigt nur noch echte Zahlen (kein irreführendes
  "Unbegrenzt" vor dem Launch-Schalter)
- Tarif-Cards plakativer: Icon je Tarif, größerer Preis, Trennlinie vor
  den Leistungen, mehr Abstand zum größeren Buchen-Button
- "Prüfung und Veröffentlichung inklusive" statt "KI-Prüfung"
- "Aktive Buchungen"-Panel entfernt (redundant zum Tarif-Panel);
  Verlauf als eigene, durch Trennlinie abgegrenzte Sektion
- Tests angepasst; Suite 519 passed / 4 skipped

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 13:30:06 +00:00

418 lines
24 KiB
PHP

<?php
use App\Enums\SinglePurchaseStatus;
use App\Enums\UserPaymentOptionStatus;
use App\Models\Plan;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
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.
*/
new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class extends Component
{
public function formatEuro(int $cents): string
{
return number_format($cents / 100, $cents % 100 === 0 ? 0 : 2, ',', '.').' €';
}
public function planIcon(Plan $plan): string
{
return match ($plan->slug) {
'starter' => 'rocket-launch',
'business' => 'briefcase',
'pro' => 'chart-bar',
'agency' => 'building-office-2',
default => 'megaphone',
};
}
public function with(): array
{
$user = auth()->user();
$subscription = $user->subscription();
$currentPlan = $user->currentPlan();
$currentInterval = null;
if ($currentPlan && $subscription) {
$currentInterval = $subscription->stripe_price === $currentPlan->stripe_price_id_yearly
? 'yearly'
: 'monthly';
}
return [
// Rückkehr aus dem Stripe-Checkout (?checkout=erfolg|abbruch)
// bzw. Hinweis aus den Checkout-Guards (Session-Flash).
'checkoutResult' => request()->query('checkout'),
'checkoutNotice' => session('checkout-notice'),
'plans' => Plan::query()->active()->get(),
'currentPlan' => $currentPlan,
'currentInterval' => $currentInterval,
'subscription' => $subscription,
// Bestandstarife: laufende Legacy-Vereinbarungen (MAN-Kreis,
// unbegrenzte PMs — Entscheidung 12.06.2026).
'legacyOptions' => $user->userPaymentOptions()
->whereIn('status', [
UserPaymentOptionStatus::Active->value,
UserPaymentOptionStatus::Grandfathered->value,
])
->orderBy('current_period_end')
->get(),
'openPurchases' => $user->singlePurchases()
->grantingSubmission()
->orderBy('paid_at')
->get(),
'consumedPurchases' => $user->singlePurchases()
->where('status', SinglePurchaseStatus::Consumed->value)
->with('pressRelease')
->latest('consumed_at')
->limit(10)
->get(),
'quotaRemaining' => $user->pressReleaseQuotaRemaining(),
'quotaTotal' => $user->pressReleaseQuotaTotal(),
'singlePmPrice' => $this->formatEuro((int) config('billing.single_pm_price_cents')),
'singlePmAvailable' => (bool) config('billing.single_pm_stripe_price_id'),
];
}
}; ?>
<div class="space-y-8">
{{-- ============== CHECKOUT-RÜCKMELDUNG ============== --}}
@if ($checkoutResult === 'erfolg')
<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">
<span class="font-semibold text-[color:var(--color-ink)]">{{ __('Vielen Dank für Ihre Buchung!') }}</span>
{{ __('Die Zahlung wird von Stripe bestätigt — die Buchung erscheint hier in wenigen Augenblicken. Die Rechnung finden Sie anschließend unter Rechnungen.') }}
</div>
</div>
@elseif ($checkoutResult === 'abbruch')
<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">
{{ __('Der Bezahlvorgang wurde abgebrochen. Es wurde nichts gebucht — Sie können den Checkout jederzeit erneut starten.') }}
</div>
</div>
@endif
@if ($checkoutNotice)
<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">{{ $checkoutNotice }}</div>
</div>
@endif
{{-- ============== PAGE HEADER ============== --}}
<header class="grid items-end gap-8" style="grid-template-columns:1fr auto;">
<div class="min-w-0">
<div class="flex items-center gap-3 mb-3 flex-wrap">
<span class="badge hub dot">{{ __('User Backend') }}</span>
<span class="eyebrow muted">{{ __('Mein Bereich · Finanzen') }}</span>
</div>
<h1 class="text-[30px] font-bold tracking-[-0.6px] leading-[1.15] m-0 text-[color:var(--color-ink)]">
{{ __('Buchungen & Add-ons') }}
</h1>
<p class="text-[13px] leading-[1.55] mt-2 m-0 max-w-[640px] text-[color:var(--color-ink-2)]">
{{ __('Tarif wählen oder einzelne Pressemitteilung buchen. Alle Preise sind Nettopreise zzgl. USt.; die Abrechnung erfolgt sicher über Stripe.') }}
</p>
</div>
<div class="flex items-center gap-2 flex-shrink-0">
<flux:button size="sm" variant="filled" icon="document-text" href="{{ route('me.invoices.index') }}" wire:navigate>
{{ __('Rechnungen') }}
</flux:button>
</div>
</header>
{{-- ============== AKTUELLER TARIF ============== --}}
{{-- Erscheint erst, wenn eine Buchung existiert — vorher würde hier nur
„kein Tarif" stehen und das Kontingent wäre irreführend. --}}
@if ($subscription || $legacyOptions->isNotEmpty() || $openPurchases->isNotEmpty())
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Aktueller Tarif') }}</span>
@if ($currentPlan)
<span class="badge hub">{{ $currentPlan->name }}</span>
@elseif ($legacyOptions->isNotEmpty())
<span class="badge ok dot">{{ __('Bestandstarif') }}</span>
@else
<span class="badge hub">{{ __('Einzel-PM') }}</span>
@endif
</div>
<div class="p-5 grid gap-5 md:grid-cols-[1.2fr_0.8fr]">
<div class="space-y-2">
@if ($currentPlan)
<div class="text-[28px] font-bold tracking-[-0.7px] text-[color:var(--color-ink)]">
{{ $currentInterval === 'yearly'
? $this->formatEuro($currentPlan->yearly_price_cents).' / '.__('Jahr')
: $this->formatEuro($currentPlan->monthly_price_cents).' / '.__('Monat') }}
<span class="text-[13px] font-normal text-[color:var(--color-ink-3)]">{{ __('netto') }}</span>
</div>
<p class="text-[12.5px] text-[color:var(--color-ink-2)] m-0">
{{ __(':quota Pressemitteilungen pro Monat', ['quota' => $currentPlan->press_release_quota]) }}
@if ($currentPlan->daily_limit)
· {{ __('max. :limit Veröffentlichungen pro Tag', ['limit' => $currentPlan->daily_limit]) }}
@endif
</p>
@if ($subscription?->onGracePeriod())
<p class="text-[12px] text-[color:var(--color-warn,#b45309)] m-0">
{{ __('Gekündigt — läuft am :date aus.', ['date' => $subscription->ends_at?->format('d.m.Y')]) }}
</p>
@endif
@elseif ($legacyOptions->isNotEmpty())
@foreach ($legacyOptions as $option)
<div>
<div class="text-[15px] font-semibold text-[color:var(--color-ink)]">
{{ data_get($option->legacy_conditions, 'name') ?? $option->paymentOption?->article_number ?? __('Bestehende Vereinbarung') }}
</div>
<p class="text-[12.5px] text-[color:var(--color-ink-2)] mt-1 mb-0">
{{ __('Unbegrenzte Pressemitteilungen (Bestandsschutz).') }}
@if ($option->current_period_end)
{{ __('Nächste Rechnung am :date.', ['date' => $option->current_period_end->format('d.m.Y')]) }}
@endif
</p>
</div>
@endforeach
<p class="text-[11.5px] text-[color:var(--color-ink-3)] m-0">
{{ __('Ihre bisherigen Konditionen gelten unverändert weiter; die Abrechnung erfolgt wie gewohnt per Rechnung.') }}
</p>
@elseif ($openPurchases->isNotEmpty())
<div class="text-[15px] font-semibold text-[color:var(--color-ink)]">
{{ trans_choice(':count Einzel-Pressemitteilung verfügbar|:count Einzel-Pressemitteilungen verfügbar', $openPurchases->count(), ['count' => $openPurchases->count()]) }}
</div>
<p class="text-[12.5px] text-[color:var(--color-ink-2)] m-0">
{{ __('Jeder Kauf berechtigt zu genau einer Veröffentlichung — eingelöst wird er erst, wenn die Pressemitteilung live geht.') }}
</p>
@endif
</div>
<div class="space-y-3">
{{-- Kontingent nur als echte Zahl — „unbegrenzt" wäre vor dem
Launch-Schalter inhaltlich falsch. --}}
@if (! is_null($quotaRemaining))
<div class="rounded-[6px] border border-[color:var(--color-bg-rule)] p-4 bg-[color:var(--color-bg-subtle)]">
<div class="text-[12px] text-[color:var(--color-ink-3)]">{{ __('PM-Kontingent diesen Monat') }}</div>
<div class="text-[22px] font-semibold text-[color:var(--color-ink)]">
{{ $quotaRemaining }} / {{ $quotaTotal }}
</div>
<div class="text-[11.5px] text-[color:var(--color-ink-3)]">
{{ __('Wird erst bei Veröffentlichung verbraucht.') }}
</div>
</div>
@endif
@if ($subscription)
<flux:button size="sm" variant="filled" icon="cog-6-tooth" class="w-full"
href="{{ route('me.checkout.billing-portal') }}">
{{ __('Abo verwalten') }}
</flux:button>
<p class="text-[11px] text-[color:var(--color-ink-3)] m-0">
{{ __('Zahlungsmethode, Rechnungen und Kündigung — sicher über das Stripe-Kundenportal.') }}
</p>
@endif
</div>
</div>
</article>
@endif
{{-- ============== TARIF-RASTER ============== --}}
<section class="space-y-4" x-data="{ interval: '{{ $currentInterval ?? 'monthly' }}' }">
<div class="flex items-end justify-between gap-4 flex-wrap">
<div>
<span class="section-eyebrow">{{ __('Tarife') }}</span>
<h2 class="text-[22px] font-bold tracking-[-0.4px] mt-1 mb-1 text-[color:var(--color-ink)]">
{{ __('Den passenden Tarif wählen') }}
</h2>
<p class="text-[12.5px] text-[color:var(--color-ink-3)] max-w-[640px] m-0">
{{ __('Monatlich kündbar. Im Jahrestarif sind 2 Monate gratis — Sie zahlen 10 von 12 Monaten.') }}
</p>
</div>
<div class="flex items-center gap-1 rounded-[6px] border border-[color:var(--color-bg-rule)] p-1 bg-[color:var(--color-bg-subtle)]">
<button type="button" @click="interval = 'monthly'"
class="px-3 py-1.5 rounded-[4px] text-[12.5px] font-semibold transition-colors"
:class="interval === 'monthly' ? 'bg-[color:var(--color-bg-elev)] text-[color:var(--color-ink)] shadow-sm' : 'text-[color:var(--color-ink-3)]'">
{{ __('Monatlich') }}
</button>
<button type="button" @click="interval = 'yearly'"
class="px-3 py-1.5 rounded-[4px] text-[12.5px] font-semibold transition-colors"
:class="interval === 'yearly' ? 'bg-[color:var(--color-bg-elev)] text-[color:var(--color-ink)] shadow-sm' : 'text-[color:var(--color-ink-3)]'">
{{ __('Jährlich') }} <span class="badge ok ms-1">{{ __('2 Monate gratis') }}</span>
</button>
</div>
</div>
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
@foreach ($plans as $plan)
@php($isCurrent = $currentPlan && $plan->is($currentPlan))
<article @class(['panel', 'ring-2 ring-[color:var(--color-hub)]' => $isCurrent]) wire:key="plan-{{ $plan->slug }}">
<div class="p-6 space-y-5 flex flex-col h-full">
<div class="flex items-start justify-between gap-2">
<div class="flex items-center gap-3">
<div class="w-11 h-11 rounded-[6px] 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 :name="$this->planIcon($plan)" class="size-5" />
</div>
<h3 class="text-[17px] font-bold tracking-[-0.3px] text-[color:var(--color-ink)] m-0">{{ $plan->name }}</h3>
</div>
@if ($isCurrent)
<span class="badge hub dot">{{ __('Aktuell') }}</span>
@endif
</div>
<div>
<div x-show="interval === 'monthly'">
<span class="text-[32px] font-bold tracking-[-0.8px] leading-none text-[color:var(--color-ink)]">{{ $this->formatEuro($plan->monthly_price_cents) }}</span>
<span class="text-[12.5px] text-[color:var(--color-ink-3)]">/ {{ __('Monat') }}</span>
</div>
<div x-show="interval === 'yearly'" x-cloak>
<span class="text-[32px] font-bold tracking-[-0.8px] leading-none text-[color:var(--color-ink)]">{{ $this->formatEuro($plan->yearly_price_cents) }}</span>
<span class="text-[12.5px] text-[color:var(--color-ink-3)]">/ {{ __('Jahr') }}</span>
</div>
<div class="text-[11px] text-[color:var(--color-ink-3)] mt-1.5">{{ __('netto zzgl. USt.') }}</div>
</div>
<ul class="m-0 p-0 list-none space-y-2.5 text-[12.5px] text-[color:var(--color-ink-2)] flex-1 border-t border-[color:var(--color-bg-rule)] pt-4">
<li class="flex items-start gap-2">
<flux:icon.check class="size-4 flex-shrink-0 mt-0.5 text-[color:var(--color-hub)]" />
<span><strong class="text-[color:var(--color-ink)]">{{ $plan->press_release_quota }}</strong> {{ __('Pressemitteilungen pro Monat') }}</span>
</li>
<li class="flex items-start gap-2">
<flux:icon.check class="size-4 flex-shrink-0 mt-0.5 text-[color:var(--color-hub)]" />
@if ($plan->daily_limit)
{{ __('max. :limit Veröffentlichungen pro Tag', ['limit' => $plan->daily_limit]) }}
@else
{{ __('Ohne Tageslimit') }}
@endif
</li>
<li class="flex items-start gap-2">
<flux:icon.check class="size-4 flex-shrink-0 mt-0.5 text-[color:var(--color-hub)]" />
{{ __('Prüfung und Veröffentlichung inklusive') }}
</li>
</ul>
<div class="pt-2">
@if ($subscription)
<flux:button variant="filled" class="w-full" disabled>
{{ $isCurrent ? __('Ihr aktueller Tarif') : __('Wechsel über „Abo verwalten"') }}
</flux:button>
@else
<div x-show="interval === 'monthly'">
<flux:button variant="primary" class="w-full"
href="{{ route('me.checkout.subscription', ['planSlug' => $plan->slug, 'interval' => 'monthly']) }}">
{{ __('Monatlich buchen') }}
</flux:button>
</div>
<div x-show="interval === 'yearly'" x-cloak>
<flux:button variant="primary" class="w-full"
href="{{ route('me.checkout.subscription', ['planSlug' => $plan->slug, 'interval' => 'yearly']) }}">
{{ __('Jährlich buchen') }}
</flux:button>
</div>
@endif
</div>
</div>
</article>
@endforeach
</div>
<p class="text-[12px] text-[color:var(--color-ink-3)] m-0">
{{ __('Mehr als 60 Pressemitteilungen pro Monat, mehrere Teams oder Sonderkonditionen? Enterprise-Konditionen erhalten Sie auf Anfrage über den Support.') }}
</p>
</section>
{{-- ============== EINZEL-PM (OHNE ABO) ============== --}}
<article class="panel">
<div class="p-5 grid gap-5 md:grid-cols-[1fr_auto] md:items-center">
<div class="flex items-start gap-4">
<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>
<h3 class="text-[15px] font-bold text-[color:var(--color-ink)] m-0">
{{ __('Einzel-Pressemitteilung — ohne Abo') }}
</h3>
<p class="text-[12.5px] text-[color:var(--color-ink-2)] mt-1 mb-0 max-w-[560px]">
{{ __('Genau eine Veröffentlichung inklusive KI-Prüfung. Eingelöst wird der Kauf erst, wenn die Pressemitteilung live geht — Ablehnungen kosten nichts.') }}
@if ($openPurchases->isNotEmpty())
<span class="font-semibold text-[color:var(--color-ink)]">
{{ trans_choice('Aktuell :count offener Kauf.|Aktuell :count offene Käufe.', $openPurchases->count(), ['count' => $openPurchases->count()]) }}
</span>
@endif
</p>
</div>
</div>
<div class="flex items-center gap-4 md:justify-end">
<div class="text-right">
<div class="text-[22px] font-bold text-[color:var(--color-ink)]">{{ $singlePmPrice }}</div>
<div class="text-[11px] text-[color:var(--color-ink-3)]">{{ __('netto zzgl. USt.') }}</div>
</div>
<flux:button size="sm" variant="primary"
href="{{ route('me.checkout.single-pm') }}"
:disabled="! $singlePmAvailable">
{{ __('Jetzt buchen') }}
</flux:button>
</div>
</div>
</article>
{{-- ============== VERLAUF ============== --}}
{{-- Aktive Buchungen stehen oben im Tarif-Panel — hier nur die Historie,
durch Trennlinie und Zwischenüberschrift klar abgesetzt. --}}
<section class="space-y-4 border-t border-[color:var(--color-bg-rule)] pt-8">
<div>
<span class="section-eyebrow">{{ __('Verlauf') }}</span>
<h2 class="text-[22px] font-bold tracking-[-0.4px] mt-1 mb-1 text-[color:var(--color-ink)]">
{{ __('Eingelöste Käufe') }}
</h2>
<p class="text-[12.5px] text-[color:var(--color-ink-3)] max-w-[640px] m-0">
{{ __('Jeder eingelöste Einzelkauf mit der zugehörigen Pressemitteilung — die Rechnungen dazu finden Sie unter Rechnungen.') }}
</p>
</div>
<article class="panel">
<div class="p-5">
@if ($consumedPurchases->isNotEmpty())
<div class="divide-y divide-[color:var(--color-bg-rule)]">
@foreach ($consumedPurchases as $purchase)
<div class="py-3 first:pt-0 last:pb-0 flex items-center justify-between gap-4" wire:key="consumed-purchase-{{ $purchase->id }}">
<div class="min-w-0">
<div class="text-[13px] font-semibold text-[color:var(--color-ink)] truncate">
{{ $purchase->pressRelease?->title ?? $purchase->type->label() }}
</div>
<div class="text-[11.5px] text-[color:var(--color-ink-3)]">
{{ __('eingelöst am :date', ['date' => $purchase->consumed_at?->format('d.m.Y')]) }}
</div>
</div>
<span class="badge">{{ __('eingelöst') }}</span>
</div>
@endforeach
</div>
@else
<div class="flex flex-col items-center justify-center px-4 py-10 text-center">
<div class="w-14 h-14 rounded-[6px] flex items-center justify-center mb-3
bg-[color:var(--color-bg-subtle)] border border-[color:var(--color-bg-rule)] text-[color:var(--color-ink-3)]">
<flux:icon.clock class="size-6" />
</div>
<div class="text-[14px] font-semibold text-[color:var(--color-ink)] mb-1">
{{ __('Noch kein Buchungsverlauf') }}
</div>
<p class="text-[12px] text-[color:var(--color-ink-3)] max-w-md m-0">
{{ __('Eingelöste Einzelkäufe erscheinen hier mit der zugehörigen Pressemitteilung.') }}
</p>
</div>
@endif
</div>
</article>
</section>
</div>