presseportale/resources/views/livewire/customer/bookings.blade.php
Kevin Adametz 3c6190099f UI-Feinschliff Buchungen: Tarife oben, Credits darunter, Preisliste, USt.-Modal
- Reihenfolge: Tarife -> Einzel-PM -> Credit-Wallet & Add-ons -> Preisliste
- Buchungs-Bestaetigungs-Modal mit USt.-Aufschluesselung (netto/USt./gesamt,
  Reverse-Charge & steuerbefreit) vor Stripe; greift fuer Tarife, Einzel-PM
  und Credit-Pakete (selectBooking)
- Proaktiver Banner oben, wenn die Rechnungsadresse fehlt (Link ins Profil);
  im Modal Adress-Gate statt Stripe-Link
- Extra-PM-Karte nur bei Guthaben > 0 oder aktivem Abo (sonst Preisliste)
- Pruefkontingent ausgeblendet (Phase 2)
- Preisliste: Extra-PM nach Tarif (eigener Tarif markiert), Boost-Laufzeiten,
  Nachweis-PDF

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 15:26:04 +00:00

950 lines
52 KiB
PHP

<?php
use App\Enums\Portal;
use App\Enums\SinglePurchaseStatus;
use App\Enums\Tier;
use App\Enums\UserPaymentOptionStatus;
use App\Enums\VatTreatment;
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\ExtraPmPurchaseService;
use App\Services\Billing\VatResolver;
use Flux\Flux;
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. Die Credit-Wallet (Topup, Extra-PM) bildet den
* Add-on-Block laut Decision-Update (Rev. 4) ab. Jede Buchung läuft über ein
* Bestätigungs-Modal mit USt.-Aufschlüsselung. Prüfkontingent folgt mit Phase 2.
*/
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;
/** Im Bestätigungs-Modal angezeigte Buchung (inkl. USt.-Aufschlüsselung). */
public ?array $booking = null;
public function formatEuro(int $cents): string
{
return number_format($cents / 100, $cents % 100 === 0 ? 0 : 2, ',', '.') . ' €';
}
/**
* Öffnet das Bestätigungs-Modal für eine Buchung (Tarif-Abo, Einzel-PM
* oder Credit-Paket) mit USt.-Aufschlüsselung anhand der Rechnungsadresse.
* Bestätigt wird per Weiterleitung auf die jeweilige Checkout-Route.
*/
public function selectBooking(string $type, ?string $ref = null, ?string $interval = null): void
{
$user = auth()->user();
[$title, $subtitle, $netCents, $url] = match ($type) {
'subscription' => $this->subscriptionBooking($ref, $interval),
'single_pm' => [
__('Einzel-Pressemitteilung'),
__('Genau eine Veröffentlichung inkl. Prüfung — eingelöst erst bei Veröffentlichung.'),
(int) config('billing.single_pm_price_cents'),
route('me.checkout.single-pm'),
],
'credit_pack' => $this->creditPackBooking($ref),
default => [null, null, 0, null],
};
if (! $url) {
return;
}
$resolver = app(VatResolver::class);
$treatment = $resolver->resolve($user->billingAddress?->country_code, $user->billingAddress?->vat_id);
$vatCents = $resolver->taxCentsFor($netCents, $treatment);
$this->booking = [
'title' => $title,
'subtitle' => $subtitle,
'interval' => $interval,
'net' => $this->formatEuro($netCents),
'vat_percent' => (int) round($resolver->rateFor($treatment) * 100),
'vat' => $this->formatEuro($vatCents),
'gross' => $this->formatEuro($netCents + $vatCents),
'reverse_charge' => $treatment === VatTreatment::ReverseCharge,
'tax_exempt' => $treatment->isTaxExempt(),
'url' => $url,
'needs_address' => ! $user->hasCompleteBillingAddress(),
];
Flux::modal('confirm-booking')->show();
}
/**
* @return array{0: ?string, 1: ?string, 2: int, 3: ?string}
*/
private function subscriptionBooking(?string $slug, ?string $interval): array
{
$plan = Plan::query()->active()->where('slug', $slug)->first();
if (! $plan) {
return [null, null, 0, null];
}
$isYearly = $interval === 'yearly';
return [
$plan->name . ' — ' . ($isYearly ? __('Jährlich') : __('Monatlich')),
__(':quota Pressemitteilungen pro Monat, Prüfung und Veröffentlichung inklusive.', ['quota' => $plan->press_release_quota]),
$isYearly ? (int) $plan->yearly_price_cents : (int) $plan->monthly_price_cents,
route('me.checkout.subscription', ['planSlug' => $plan->slug, 'interval' => $isYearly ? 'yearly' : 'monthly']),
];
}
/**
* @return array{0: ?string, 1: ?string, 2: int, 3: ?string}
*/
private function creditPackBooking(?string $key): array
{
$pack = app(CreditTopupService::class)->findPack((string) $key);
if (! $pack) {
return [null, null, 0, null];
}
$bonus = $pack['credits'] - intdiv($pack['price_cents'], 100);
return [
__(':credits Credits Guthaben', ['credits' => $pack['credits']]),
$bonus > 0
? __('Enthält :bonus Bonus-Credits. 1 Credit = 1 € beim Einlösen.', ['bonus' => $bonus])
: __('1 Credit = 1 € beim Einlösen.'),
(int) $pack['price_cents'],
route('me.checkout.credit-topup', ['pack' => $pack['key']]),
];
}
/**
* 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) {
'starter' => 'rocket-launch',
'business' => 'briefcase',
'pro' => 'chart-bar',
'agency' => 'building-office-2',
default => 'megaphone',
};
}
/**
* Lesbares Portal-Label für einen Bestandsschutz-Posten (aus den
* Legacy-Konditionen). Fällt auf „Portal unbekannt" zurück.
*/
public function legacyPortalLabel(UserPaymentOption $option): string
{
$value = data_get($option->legacy_conditions, 'legacy_portal');
return Portal::tryFrom((string) $value)?->label() ?? __('Portal unbekannt');
}
/**
* Abrechnungsintervall eines Bestandsschutz-Postens als Label.
*/
public function legacyIntervalLabel(UserPaymentOption $option): string
{
return match (data_get($option->legacy_conditions, 'interval')) {
'monthly' => __('Monat'),
default => __('Jahr'),
};
}
public function with(): array
{
$user = auth()->user();
$subscription = $user->subscription();
$currentPlan = $user->currentPlan();
$currentTier = $user->currentTier();
$pricing = app(CreditPricingService::class);
$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'),
// Credit-Wallet & Add-ons (Decision-Update Rev. 4).
'hasCompleteBillingAddress' => $user->hasCompleteBillingAddress(),
'creditBalance' => $user->creditBalance(),
'creditPacks' => app(CreditTopupService::class)->packs(),
'extraPmPrice' => $pricing->extraPmCreditsFor($user),
// Extra-PM nur anbieten, wenn man Guthaben hat oder im Abo ist —
// sonst ergibt sich der Preis aus der Preisliste (Decision-Update).
'showExtraPm' => $user->creditBalance() > 0 || (bool) $subscription,
'recentTransactions' => $user->creditTransactions()->latest()->limit(6)->get(),
// Preisliste (Add-on-Credits).
'extraPmStaffel' => collect(Tier::cases())->map(fn (Tier $t): array => [
'label' => $t->label(),
'credits' => $pricing->extraPmCredits($t),
'isCurrent' => $t === $currentTier,
])->all(),
'boostStaffel' => $pricing->boostOptions(),
'proofPrice' => $pricing->proofPdfCredits(),
];
}
}; ?>
<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
{{-- ============== RECHNUNGSADRESSE FEHLT ============== --}}
{{-- Proaktiver Hinweis: jede Buchung setzt eine vollständige
Rechnungsadresse voraus (sonst leitet der Checkout-Guard ins Profil). --}}
@if (! $hasCompleteBillingAddress)
<div
class="px-4 py-3 rounded-[5px] border text-[13px] flex items-start gap-3
bg-[color:var(--color-warn-soft)] border-[color:var(--color-warn)]/30 text-[color:var(--color-ink-2)]">
<flux:icon.exclamation-triangle class="size-[16px] flex-shrink-0 mt-0.5 text-[color:var(--color-accent-deep)]" />
<div class="flex-1">
<span class="font-semibold text-[color:var(--color-ink)]">{{ __('Rechnungsadresse fehlt.') }}</span>
{{ __('Für jede Buchung ist eine vollständige Rechnungsadresse nötig — sie wird an Stripe übergeben.') }}
<a href="{{ route('me.profile') }}" wire:navigate
class="font-semibold text-[color:var(--color-hub)] underline underline-offset-2">{{ __('Jetzt hinterlegen') }}</a>
</div>
</div>
@endif
{{-- ============== PAGE HEADER ============== --}}
<header class="page-header">
<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>
{{-- ============== BESTANDSSCHUTZ-PAKETE ============== --}}
{{-- Migrierte Legacy-Vereinbarungen als echte, gebuchte Pakete dargestellt:
eigener Block, Rahmen, Icon, Portal-Badge, klare Typo. --}}
@if ($legacyOptions->isNotEmpty())
<section class="space-y-4">
<div>
<span class="section-eyebrow">{{ __('Ihr Bestandsschutz') }}</span>
<h2 class="text-[22px] font-bold tracking-[-0.4px] mt-1 mb-1 text-[color:var(--color-ink)]">
{{ trans_choice('Ihr gebuchtes Paket|Ihre gebuchten Pakete', $legacyOptions->count()) }}
</h2>
<p class="text-[12.5px] text-[color:var(--color-ink-3)] max-w-[640px] m-0">
{{ __('Ihre bisherigen Konditionen gelten unverändert weiter — unbegrenzte Pressemitteilungen, Abrechnung wie gewohnt per Rechnung.') }}
</p>
</div>
<div @class(['grid gap-4', 'md:grid-cols-2' => $legacyOptions->count() > 1])>
@foreach ($legacyOptions as $option)
@php
$netCents = (int) data_get($option->legacy_conditions, 'net_cents', 0);
$legacyName = data_get($option->legacy_conditions, 'name')
?? ($option->paymentOption?->article_number ?? __('Bestehende Vereinbarung'));
@endphp
<article class="panel overflow-hidden ring-1 ring-[color:var(--color-ok)]/25" wire:key="legacy-{{ $option->id }}">
{{-- Akzent-Leiste --}}
<div class="h-1 bg-[color:var(--color-ok)]/60"></div>
<div class="p-6 space-y-5">
<div class="flex items-start justify-between gap-4">
<div class="flex items-start gap-4 min-w-0">
<div class="w-12 h-12 rounded-[8px] flex items-center justify-center flex-shrink-0
bg-[color:var(--color-ok-soft)] border border-[color:var(--color-ok)]/30 text-[color:var(--color-gain-deep)]">
<flux:icon.shield-check class="size-6" />
</div>
<div class="min-w-0">
<div class="flex items-center gap-2 mb-1.5 flex-wrap">
<span class="badge ok dot">{{ __('Bestandsschutz') }}</span>
<span class="badge hub">{{ $this->legacyPortalLabel($option) }}</span>
</div>
<h3 class="text-[18px] font-bold tracking-[-0.3px] text-[color:var(--color-ink)] m-0 break-words">
{{ $legacyName }}
</h3>
</div>
</div>
<div class="text-right flex-shrink-0">
<div class="text-[26px] font-bold tracking-[-0.6px] leading-none text-[color:var(--color-ink)]">
{{ $this->formatEuro($netCents) }}
</div>
<div class="text-[11px] text-[color:var(--color-ink-3)] mt-1">
{{ __('netto / :interval', ['interval' => $this->legacyIntervalLabel($option)]) }}
</div>
</div>
</div>
<ul class="m-0 p-0 list-none space-y-2.5 text-[12.5px] text-[color:var(--color-ink-2)]
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-gain-deep)]" />
<span><strong class="text-[color:var(--color-ink)]">{{ __('Unbegrenzte') }}</strong> {{ __('Pressemitteilungen') }}</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-gain-deep)]" />
{{ __('Konditionen gelten unverändert weiter') }}
</li>
@if ($option->current_period_end)
<li class="flex items-start gap-2">
<flux:icon.calendar-days class="size-4 flex-shrink-0 mt-0.5 text-[color:var(--color-ink-3)]" />
{{ __('Nächste Rechnung am :date', ['date' => $option->current_period_end->format('d.m.Y')]) }}
</li>
@endif
</ul>
</div>
</article>
@endforeach
</div>
@if ($legacyOptions->count() > 1)
<p class="text-[11.5px] text-[color:var(--color-ink-3)] m-0">
{{ __('Ihre Pakete werden gemeinsam geführt und sind nur zusammen kündbar (keine Einzelkündigung).') }}
</p>
@endif
</section>
@endif
{{-- ============== 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 || $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>
@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 ($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"
wire:click="selectBooking('subscription', '{{ $plan->slug }}', 'monthly')">
{{ __('Monatlich buchen') }}
</flux:button>
</div>
<div x-show="interval === 'yearly'" x-cloak>
<flux:button variant="primary" class="w-full"
wire:click="selectBooking('subscription', '{{ $plan->slug }}', '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. Inklusive KI-Prüfung und Veröffentlichung.info@pressekonto.com') }}
</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 Prüfung und Veröffentlichung. 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" wire:click="selectBooking('single_pm')"
:disabled="!$singlePmAvailable">
{{ __('Jetzt buchen') }}
</flux:button>
</div>
</div>
</article>
{{-- ============== 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 & Credit-Add-ons') }}
</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))
<button type="button"
wire:click="selectBooking('credit_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
</button>
@endforeach
</div>
</div>
</div>
</article>
{{-- Extra-PM: nur bei vorhandenem Guthaben oder aktivem Abo (sonst Preisliste) --}}
@if ($showExtraPm)
@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
<article class="panel">
<div class="p-5 grid gap-4 md:grid-cols-[1fr_auto] md:items-center">
<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.') }}
</p>
</div>
</div>
<div class="flex items-center gap-4 md:justify-end">
<div class="text-right">
<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>
@endif
{{-- 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>
{{-- ============== PREISLISTE (Add-on-Credits) ============== --}}
<section class="space-y-4">
<div>
<span class="section-eyebrow">{{ __('Preisliste') }}</span>
<h2 class="text-[22px] font-bold tracking-[-0.4px] mt-1 mb-1 text-[color:var(--color-ink)]">
{{ __('Was kostet wie viel') }}
</h2>
<p class="text-[12.5px] text-[color:var(--color-ink-3)] max-w-[640px] m-0">
{{ __('Add-ons werden mit Credits bezahlt (1 Credit = 1 €). Der Extra-PM-Preis richtet sich nach Ihrem Tarif — höhere Tarife zahlen weniger.') }}
</p>
</div>
<article class="panel">
<div class="p-5 grid gap-6 md:grid-cols-3 md:divide-x md:divide-[color:var(--color-bg-rule)]">
{{-- Extra-PM nach Tarif --}}
<div class="md:pr-6">
<div class="flex items-center gap-2 mb-3">
<flux:icon.document-plus class="size-4 text-[color:var(--color-hub)]" />
<h3 class="text-[13.5px] font-bold text-[color:var(--color-ink)] m-0">{{ __('Extra-Pressemitteilung') }}</h3>
</div>
<table class="w-full text-[12.5px]">
<tbody class="divide-y divide-[color:var(--color-bg-rule)]">
@foreach ($extraPmStaffel as $row)
<tr @class(['', 'font-semibold' => $row['isCurrent']])>
<td class="py-1.5 text-[color:var(--color-ink-2)]">
{{ $row['label'] }}
@if ($row['isCurrent'])
<span class="badge hub ms-1">{{ __('Ihr Tarif') }}</span>
@endif
</td>
<td class="py-1.5 text-right text-[color:var(--color-ink)]">{{ $row['credits'] }} {{ __('Credits') }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
{{-- Boost nach Laufzeit --}}
<div class="md:px-6">
<div class="flex items-center gap-2 mb-3">
<flux:icon.rocket-launch class="size-4 text-[color:var(--color-hub)]" />
<h3 class="text-[13.5px] font-bold text-[color:var(--color-ink)] m-0">{{ __('Boost — Platzierung') }}</h3>
</div>
<table class="w-full text-[12.5px]">
<tbody class="divide-y divide-[color:var(--color-bg-rule)]">
@foreach ($boostStaffel as $days => $credits)
<tr>
<td class="py-1.5 text-[color:var(--color-ink-2)]">{{ __(':days Tage', ['days' => $days]) }}</td>
<td class="py-1.5 text-right text-[color:var(--color-ink)]">{{ $credits }} {{ __('Credits') }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
{{-- Veröffentlichungsnachweis --}}
<div class="md:pl-6">
<div class="flex items-center gap-2 mb-3">
<flux:icon.document-check class="size-4 text-[color:var(--color-hub)]" />
<h3 class="text-[13.5px] font-bold text-[color:var(--color-ink)] m-0">{{ __('Veröffentlichungsnachweis') }}</h3>
</div>
<div class="flex items-baseline justify-between text-[12.5px] py-1.5">
<span class="text-[color:var(--color-ink-2)]">{{ __('PDF pro PM') }}</span>
<span class="text-[color:var(--color-ink)] font-semibold">{{ $proofPrice }} {{ __('Credits') }}</span>
</div>
<p class="text-[11.5px] text-[color:var(--color-ink-3)] mt-2 m-0">
{{ __('Boost und Nachweis buchen Sie direkt an der jeweiligen veröffentlichten Pressemitteilung.') }}
</p>
</div>
</div>
</article>
</section>
{{-- ============== 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>
{{-- ============== BUCHUNGS-BESTÄTIGUNG (Modal) ============== --}}
<flux:modal name="confirm-booking" class="max-w-lg">
@if ($booking)
<div class="space-y-6">
<div>
<flux:heading size="lg">{{ __('Buchung bestätigen') }}</flux:heading>
<flux:subheading>{{ $booking['title'] }}</flux:subheading>
</div>
@if ($booking['subtitle'])
<p class="text-[13px] text-[color:var(--color-ink-2)] m-0">{{ $booking['subtitle'] }}</p>
@endif
<div class="rounded-[6px] border border-[color:var(--color-bg-rule)] divide-y divide-[color:var(--color-bg-rule)] text-[13px]">
<div class="flex items-center justify-between px-4 py-2.5">
<span class="text-[color:var(--color-ink-3)]">{{ __('Netto') }}</span>
<span class="font-medium text-[color:var(--color-ink)]">{{ $booking['net'] }}</span>
</div>
@if ($booking['reverse_charge'])
<div class="flex items-center justify-between px-4 py-2.5">
<span class="text-[color:var(--color-ink-3)]">{{ __('USt.') }}</span>
<span class="text-[color:var(--color-ink-2)]">{{ __('Reverse Charge (0 %)') }}</span>
</div>
@elseif ($booking['tax_exempt'])
<div class="flex items-center justify-between px-4 py-2.5">
<span class="text-[color:var(--color-ink-3)]">{{ __('USt.') }}</span>
<span class="text-[color:var(--color-ink-2)]">{{ __('steuerbefreit') }}</span>
</div>
@else
<div class="flex items-center justify-between px-4 py-2.5">
<span class="text-[color:var(--color-ink-3)]">{{ __('zzgl. :p % USt.', ['p' => $booking['vat_percent']]) }}</span>
<span class="font-medium text-[color:var(--color-ink)]">{{ $booking['vat'] }}</span>
</div>
@endif
<div class="flex items-center justify-between px-4 py-2.5 bg-[color:var(--color-bg-subtle)]">
<span class="font-semibold text-[color:var(--color-ink)]">{{ __('Gesamt') }}</span>
<span class="font-bold text-[color:var(--color-ink)]">{{ $booking['gross'] }}</span>
</div>
</div>
<p class="text-[11.5px] text-[color:var(--color-ink-3)] m-0">
{{ __('Die endgültige Steuer berechnet Stripe anhand Ihrer Rechnungsadresse. Die Abrechnung erfolgt sicher über Stripe.') }}
</p>
@if ($booking['needs_address'])
<div class="px-4 py-3 rounded-[5px] border text-[13px] bg-[color:var(--color-warn-soft)] border-[color:var(--color-warn)]/30 text-[color:var(--color-ink-2)]">
{{ __('Bitte hinterlegen Sie zuerst eine vollständige Rechnungsadresse — sie ist Voraussetzung für jede Buchung.') }}
</div>
<div class="flex justify-end gap-2">
<flux:modal.close>
<flux:button variant="filled">{{ __('Abbrechen') }}</flux:button>
</flux:modal.close>
<flux:button variant="primary" icon="map-pin" href="{{ route('me.profile') }}" wire:navigate>
{{ __('Rechnungsadresse hinterlegen') }}
</flux:button>
</div>
@else
<div class="flex justify-end gap-2">
<flux:modal.close>
<flux:button variant="filled">{{ __('Abbrechen') }}</flux:button>
</flux:modal.close>
<flux:button variant="primary" icon="lock-closed" href="{{ $booking['url'] }}">
{{ __('Kostenpflichtig buchen') }}
</flux:button>
</div>
@endif
</div>
@endif
</flux:modal>
</div>