Status-Widget: immer verfuegbarer Buchungsueberblick in der Topbar

Ein Header-Dropdown oben rechts (Desktop) bzw. im Mobile-Header zeigt
fuer alle Kunden-Panel-User den aktuellen Status: aktuelle Buchung/Tarif,
verbleibendes PM-Kontingent und Credit-Guthaben. Ohne aktive Buchung
weist es direkt auf die noetige Buchung hin und verlinkt die Buchungsseite.

CustomerStatusSummary buendelt die Anzeige-Werte ueber die oeffentliche
User-API (Gating bleibt Single Source of Truth im Modell); die anonyme
Blade-Komponente x-customer.status-menu rendert das Dropdown.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-06-18 13:03:25 +00:00
parent e962cc2213
commit af99189b23
4 changed files with 321 additions and 0 deletions

View file

@ -0,0 +1,124 @@
<?php
namespace App\Services\Customer;
use App\Enums\Tier;
use App\Enums\UserPaymentOptionStatus;
use App\Models\Plan;
use App\Models\User;
/**
* Read-only Statusüberblick für das immer verfügbare Buchungs-Widget im
* Header (aktuelle Buchung, verbleibendes PM-Kontingent, Credit-Guthaben).
*
* Single Source of Truth für die Gating-Logik bleibt das User-Modell; dieses
* DTO bündelt nur die fürs UI nötigen Werte, damit die Blade-Komponente keine
* eigene Geschäftslogik trägt.
*/
class CustomerStatusSummary
{
public function __construct(
public readonly Tier $tier,
public readonly ?string $planName,
public readonly ?string $billingInterval,
public readonly bool $hasActiveBooking,
public readonly bool $hasSubscription,
public readonly bool $hasGrandfathered,
public readonly int $openSinglePurchases,
public readonly bool $unlimitedQuota,
public readonly ?int $quotaTotal,
public readonly ?int $quotaRemaining,
public readonly int $creditBalance,
) {}
public static function fromUser(User $user): self
{
$plan = $user->currentPlan();
$subscription = $user->subscription();
$hasSubscription = (bool) $subscription?->valid();
$billingInterval = null;
if ($plan instanceof Plan && $subscription?->stripe_price) {
$billingInterval = $subscription->stripe_price === $plan->stripe_price_id_yearly
? 'yearly'
: 'monthly';
}
$hasGrandfathered = $user->userPaymentOptions()
->whereIn('status', [
UserPaymentOptionStatus::Active->value,
UserPaymentOptionStatus::Grandfathered->value,
])
->exists();
return new self(
tier: $user->currentTier(),
planName: $plan?->name,
billingInterval: $billingInterval,
hasActiveBooking: $user->hasActiveBooking(),
hasSubscription: $hasSubscription,
hasGrandfathered: $hasGrandfathered,
openSinglePurchases: $user->singlePurchases()->grantingSubmission()->count(),
unlimitedQuota: $user->hasUnlimitedPressReleaseQuota(),
quotaTotal: $user->pressReleaseQuotaTotal(),
quotaRemaining: $user->pressReleaseQuotaRemaining(),
creditBalance: $user->creditBalance(),
);
}
public function hasCredits(): bool
{
return $this->creditBalance > 0;
}
/**
* Menschlich lesbares Etikett der aktuellen Buchung.
*/
public function bookingLabel(): string
{
if ($this->planName !== null) {
return $this->planName;
}
if ($this->hasGrandfathered) {
return __('Bestandstarif');
}
if ($this->openSinglePurchases > 0) {
return trans_choice(
'{1}:count Einzelbuchung|[2,*]:count Einzelbuchungen',
$this->openSinglePurchases,
['count' => $this->openSinglePurchases],
);
}
return __('Kein Abo');
}
public function billingIntervalLabel(): ?string
{
return match ($this->billingInterval) {
'monthly' => __('monatlich'),
'yearly' => __('jährlich'),
default => null,
};
}
/**
* Kompakter Text für das Trigger-Badge in der Topbar.
*/
public function badgeLabel(): string
{
$parts = [];
$parts[] = $this->unlimitedQuota
? __(':icon PM', ['icon' => '∞'])
: trans_choice('{0}:count PM|{1}:count PM|[2,*]:count PM', (int) $this->quotaRemaining, ['count' => (int) $this->quotaRemaining]);
if ($this->hasCredits()) {
$parts[] = __(':count Cr', ['count' => $this->creditBalance]);
}
return implode(' · ', $parts);
}
}

View file

@ -0,0 +1,84 @@
@props(['user' => null])
@php
$user ??= auth()->user();
$status = $user ? \App\Services\Customer\CustomerStatusSummary::fromUser($user) : null;
@endphp
@if ($status)
<flux:dropdown position="bottom" align="end">
{{-- Trigger: kompaktes Status-Badge in der Topbar --}}
<button
type="button"
class="inline-flex items-center gap-2 rounded-full border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-card)] px-3 py-1.5 text-[12px] font-semibold text-[color:var(--color-ink)] transition-colors hover:border-[color:var(--color-hub)] hover:text-[color:var(--color-hub)]"
aria-label="{{ __('Mein Status & Guthaben') }}"
>
<flux:icon name="wallet" variant="micro" class="size-3.5 text-[color:var(--color-hub)]" />
<span class="whitespace-nowrap font-mono">{{ $status->badgeLabel() }}</span>
<flux:icon name="chevron-down" variant="micro" class="size-3 text-[color:var(--color-ink-3)]" />
</button>
<flux:menu class="w-[268px]">
{{-- Aktuelle Buchung --}}
<div class="px-3 pt-2 pb-1">
<div class="mb-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-[color:var(--color-ink-3)]">
{{ __('Aktuelle Buchung') }}
</div>
<div class="flex items-center justify-between gap-2">
<span class="text-[13.5px] font-semibold text-[color:var(--color-ink)]">
{{ $status->bookingLabel() }}
@if ($status->billingIntervalLabel())
<span class="font-normal text-[color:var(--color-ink-3)]">· {{ $status->billingIntervalLabel() }}</span>
@endif
</span>
@if ($status->hasActiveBooking)
<span class="inline-flex items-center gap-1 text-[11px] font-semibold text-[color:var(--color-ok)]">
<span class="size-1.5 rounded-full bg-[color:var(--color-ok)]"></span>{{ __('aktiv') }}
</span>
@else
<span class="text-[11px] font-semibold text-[color:var(--color-warn)]">{{ __('keine') }}</span>
@endif
</div>
</div>
<flux:menu.separator />
{{-- Kennzahlen: PM-Kontingent & Credits --}}
<div class="grid grid-cols-2 gap-px bg-[color:var(--color-bg-rule)] mx-3 my-1 rounded-[5px] overflow-hidden border border-[color:var(--color-bg-rule)]">
<div class="bg-[color:var(--color-bg-card)] p-2.5">
<div class="text-[10px] font-semibold uppercase tracking-[0.14em] text-[color:var(--color-ink-3)]">
{{ __('PMs frei') }}
</div>
<div class="mt-0.5 text-[18px] font-bold leading-none text-[color:var(--color-ink)]">
@if ($status->unlimitedQuota)
∞
@else
{{ $status->quotaRemaining }}<span class="text-[12px] font-medium text-[color:var(--color-ink-3)]"> / {{ $status->quotaTotal }}</span>
@endif
</div>
</div>
<div class="bg-[color:var(--color-bg-card)] p-2.5">
<div class="text-[10px] font-semibold uppercase tracking-[0.14em] text-[color:var(--color-ink-3)]">
{{ __('Guthaben') }}
</div>
<div class="mt-0.5 text-[18px] font-bold leading-none text-[color:var(--color-ink)]">
{{ $status->creditBalance }}<span class="text-[12px] font-medium text-[color:var(--color-ink-3)]"> Cr</span>
</div>
</div>
</div>
@unless ($status->hasActiveBooking)
<div class="mx-3 my-1.5 flex items-start gap-1.5 rounded-[4px] border border-[color:color-mix(in_srgb,var(--color-warn)_45%,transparent)] bg-[color:var(--color-warn-soft,transparent)] px-2.5 py-2 text-[11px] leading-[1.45] text-[color:var(--color-ink-2)]">
<flux:icon name="exclamation-triangle" variant="micro" class="mt-0.5 size-3.5 flex-shrink-0 text-[color:var(--color-warn)]" />
<span>{{ __('Zum Veröffentlichen brauchst du eine Buchung.') }}</span>
</div>
@endunless
<flux:menu.separator />
<flux:menu.item :href="route('me.bookings.index')" icon="shopping-bag" wire:navigate>
{{ __('Buchungen & Add-ons') }}
</flux:menu.item>
</flux:menu>
</flux:dropdown>
@endif

View file

@ -307,6 +307,15 @@
</flux:dropdown>
</flux:sidebar>
{{-- Desktop-Topbar: immer verfügbarer Status (Buchung, freie PMs, Credits)
oben rechts. Nur für Panel-User mit Kundenbereich. --}}
@if ($canCustomer)
<flux:header class="hidden xl:flex">
<flux:spacer />
<x-customer.status-menu :user="$user" />
</flux:header>
@endif
<!-- Mobile User Menu -->
<flux:header class="xl:hidden">
<flux:sidebar.toggle class="xl:hidden" icon="bars-2" inset="left" />
@ -317,6 +326,9 @@
</span>
</a>
<flux:spacer />
@if ($canCustomer)
<x-customer.status-menu :user="$user" />
@endif
<flux:dropdown position="top" align="end">
<flux:profile :initials="auth()->user()->initials()" icon-trailing="chevron-down" />

View file

@ -0,0 +1,101 @@
<?php
use App\Enums\CreditTransactionType;
use App\Enums\Tier;
use App\Enums\UserPaymentOptionStatus;
use App\Models\User;
use App\Models\UserPaymentOption;
use App\Services\Billing\CreditWalletService;
use App\Services\Customer\CustomerStatusSummary;
use Database\Seeders\RolesAndPermissionsSeeder;
use Tests\TestCase;
beforeEach(function (): void {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
});
test('a user without any booking and the gate off counts as unlimited and active', function () {
config()->set('billing.enforce_booking', false);
$user = User::factory()->create();
$status = CustomerStatusSummary::fromUser($user);
expect($status->tier)->toBe(Tier::Einzel);
expect($status->hasActiveBooking)->toBeTrue();
expect($status->unlimitedQuota)->toBeTrue();
expect($status->quotaRemaining)->toBeNull();
expect($status->bookingLabel())->toBe(__('Kein Abo'));
expect($status->badgeLabel())->toContain('∞');
});
test('a user without booking and the gate enforced has no active booking and zero quota', function () {
config()->set('billing.enforce_booking', true);
$user = User::factory()->create();
$status = CustomerStatusSummary::fromUser($user);
expect($status->hasActiveBooking)->toBeFalse();
expect($status->unlimitedQuota)->toBeFalse();
expect($status->quotaRemaining)->toBe(0);
expect($status->quotaTotal)->toBe(0);
});
test('a grandfathered bestandskunde is labelled and counts as active', function () {
config()->set('billing.enforce_booking', true);
$user = User::factory()->create();
UserPaymentOption::factory()->create([
'user_id' => $user->id,
'status' => UserPaymentOptionStatus::Grandfathered->value,
'stripe_subscription_id' => null,
]);
$status = CustomerStatusSummary::fromUser($user->fresh());
expect($status->hasActiveBooking)->toBeTrue();
expect($status->hasGrandfathered)->toBeTrue();
expect($status->bookingLabel())->toBe(__('Bestandstarif'));
});
test('credit balance is surfaced and shown in the badge', function () {
config()->set('billing.enforce_booking', true);
$user = User::factory()->create();
app(CreditWalletService::class)->credit($user, 27, CreditTransactionType::Topup, 'Paket 25');
$status = CustomerStatusSummary::fromUser($user->fresh());
expect($status->creditBalance)->toBe(27);
expect($status->hasCredits())->toBeTrue();
expect($status->badgeLabel())->toContain('27');
});
test('a user without credits hides the credit segment in the badge', function () {
config()->set('billing.enforce_booking', false);
$user = User::factory()->create();
$status = CustomerStatusSummary::fromUser($user);
expect($status->hasCredits())->toBeFalse();
expect($status->badgeLabel())->not->toContain('Cr');
});
test('the status menu renders inside the customer layout header', function () {
/** @var TestCase $this */
config()->set('billing.enforce_booking', true);
$user = User::factory()->create(['is_active' => true]);
$user->assignRole('customer');
$this->actingAs($user);
$this->get(route('me.dashboard'))
->assertOk()
->assertSee(__('Aktuelle Buchung'))
->assertSee(__('PMs frei'))
->assertSee(route('me.bookings.index'), false);
});