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:
parent
e962cc2213
commit
af99189b23
4 changed files with 321 additions and 0 deletions
124
app/Services/Customer/CustomerStatusSummary.php
Normal file
124
app/Services/Customer/CustomerStatusSummary.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
84
resources/views/components/customer/status-menu.blade.php
Normal file
84
resources/views/components/customer/status-menu.blade.php
Normal 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
|
||||||
|
|
@ -307,6 +307,15 @@
|
||||||
</flux:dropdown>
|
</flux:dropdown>
|
||||||
</flux:sidebar>
|
</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 -->
|
<!-- Mobile User Menu -->
|
||||||
<flux:header class="xl:hidden">
|
<flux:header class="xl:hidden">
|
||||||
<flux:sidebar.toggle class="xl:hidden" icon="bars-2" inset="left" />
|
<flux:sidebar.toggle class="xl:hidden" icon="bars-2" inset="left" />
|
||||||
|
|
@ -317,6 +326,9 @@
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
<flux:spacer />
|
<flux:spacer />
|
||||||
|
@if ($canCustomer)
|
||||||
|
<x-customer.status-menu :user="$user" />
|
||||||
|
@endif
|
||||||
|
|
||||||
<flux:dropdown position="top" align="end">
|
<flux:dropdown position="top" align="end">
|
||||||
<flux:profile :initials="auth()->user()->initials()" icon-trailing="chevron-down" />
|
<flux:profile :initials="auth()->user()->initials()" icon-trailing="chevron-down" />
|
||||||
|
|
|
||||||
101
tests/Feature/CustomerStatusSummaryTest.php
Normal file
101
tests/Feature/CustomerStatusSummaryTest.php
Normal 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);
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue