Improve package usage visibility
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

This commit is contained in:
Codex Agent
2026-01-06 14:17:27 +01:00
parent ef1773d966
commit 232302eb6f
12 changed files with 370 additions and 31 deletions

View File

@@ -203,9 +203,20 @@ class SeedDemoSwitcherTenants extends Command
$this->upsertAdmin($tenant, 'starter-wedding@demo.fotospiel'); $this->upsertAdmin($tenant, 'starter-wedding@demo.fotospiel');
TenantPackage::updateOrCreate(
['tenant_id' => $tenant->id, 'package_id' => $packages['standard']->id],
[
'price' => $packages['standard']->price,
'purchased_at' => Carbon::now()->subDays(1),
'expires_at' => Carbon::now()->addMonths(12),
'used_events' => 0,
'active' => true,
]
);
$event = $this->upsertEvent( $event = $this->upsertEvent(
tenant: $tenant, tenant: $tenant,
package: $packages['starter'], package: $packages['standard'],
eventType: $eventTypes['wedding'] ?? null, eventType: $eventTypes['wedding'] ?? null,
attributes: [ attributes: [
'name' => ['de' => 'Hochzeit Mia & Jonas', 'en' => 'Wedding Mia & Jonas'], 'name' => ['de' => 'Hochzeit Mia & Jonas', 'en' => 'Wedding Mia & Jonas'],

View File

@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Api; namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\EventPackage;
use App\Models\TenantPackage; use App\Models\TenantPackage;
use App\Support\ApiError; use App\Support\ApiError;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@@ -29,12 +30,17 @@ class TenantPackageController extends Controller
->orderBy('created_at', 'desc') ->orderBy('created_at', 'desc')
->get(); ->get();
$packages->each(fn (TenantPackage $package) => $this->hydratePackageSnapshot($package)); $usageEventPackage = $this->resolveUsageEventPackage($tenant->id);
$packages->each(function (TenantPackage $package) use ($usageEventPackage): void {
$eventPackage = $package->active ? $usageEventPackage : null;
$this->hydratePackageSnapshot($package, $eventPackage);
});
$activePackage = $tenant->activeResellerPackage?->load('package'); $activePackage = $tenant->activeResellerPackage?->load('package');
if ($activePackage instanceof TenantPackage) { if ($activePackage instanceof TenantPackage) {
$this->hydratePackageSnapshot($activePackage); $this->hydratePackageSnapshot($activePackage, $usageEventPackage);
} }
return response()->json([ return response()->json([
@@ -44,7 +50,7 @@ class TenantPackageController extends Controller
]); ]);
} }
private function hydratePackageSnapshot(TenantPackage $package): void private function hydratePackageSnapshot(TenantPackage $package, ?EventPackage $eventPackage = null): void
{ {
$pkg = $package->package; $pkg = $package->package;
@@ -52,6 +58,7 @@ class TenantPackageController extends Controller
$package->remaining_events = $maxEvents === null ? null : max($maxEvents - $package->used_events, 0); $package->remaining_events = $maxEvents === null ? null : max($maxEvents - $package->used_events, 0);
$package->package_limits = array_merge( $package->package_limits = array_merge(
$pkg?->limits ?? [], $pkg?->limits ?? [],
$this->buildUsageSnapshot($eventPackage),
[ [
'branding_allowed' => $pkg?->branding_allowed, 'branding_allowed' => $pkg?->branding_allowed,
'watermark_allowed' => $pkg?->watermark_allowed, 'watermark_allowed' => $pkg?->watermark_allowed,
@@ -59,4 +66,72 @@ class TenantPackageController extends Controller
] ]
); );
} }
/**
* @return Collection<int, EventPackage>
*/
private function resolveUsageEventPackage(int $tenantId): ?EventPackage
{
$baseQuery = EventPackage::query()
->whereHas('event', fn ($query) => $query->where('tenant_id', $tenantId))
->with('package')
->orderByDesc('purchased_at')
->orderByDesc('created_at');
$activeEventPackage = (clone $baseQuery)
->whereNotNull('gallery_expires_at')
->where('gallery_expires_at', '>=', now())
->first();
return $activeEventPackage ?? $baseQuery->first();
}
private function buildUsageSnapshot(?EventPackage $eventPackage): array
{
if (! $eventPackage) {
return [];
}
$limits = $eventPackage->effectiveLimits();
$maxPhotos = $this->normalizeLimit($limits['max_photos'] ?? null);
$maxGuests = $this->normalizeLimit($limits['max_guests'] ?? null);
$galleryDays = $this->normalizeLimit($limits['gallery_days'] ?? null);
$usedPhotos = (int) $eventPackage->used_photos;
$usedGuests = (int) $eventPackage->used_guests;
$remainingPhotos = $maxPhotos === null ? null : max(0, $maxPhotos - $usedPhotos);
$remainingGuests = $maxGuests === null ? null : max(0, $maxGuests - $usedGuests);
$remainingGalleryDays = null;
$usedGalleryDays = null;
if ($galleryDays !== null && $eventPackage->gallery_expires_at) {
$remainingGalleryDays = max(0, now()->diffInDays($eventPackage->gallery_expires_at, false));
$usedGalleryDays = max(0, $galleryDays - $remainingGalleryDays);
}
return array_filter([
'used_photos' => $maxPhotos === null ? null : $usedPhotos,
'remaining_photos' => $remainingPhotos,
'used_guests' => $maxGuests === null ? null : $usedGuests,
'remaining_guests' => $remainingGuests,
'used_gallery_days' => $usedGalleryDays,
'remaining_gallery_days' => $remainingGalleryDays,
], static fn ($value) => $value !== null);
}
private function normalizeLimit(?int $value): ?int
{
if ($value === null) {
return null;
}
$value = (int) $value;
if ($value <= 0) {
return null;
}
return $value;
}
} }

View File

@@ -2705,6 +2705,7 @@
"mobileBilling": { "mobileBilling": {
"packageFallback": "Paket", "packageFallback": "Paket",
"remainingEvents": "{{count}} Events", "remainingEvents": "{{count}} Events",
"remainingEventsOf": "{{remaining}} von {{limit}} Events übrig",
"remainingEventsZero": "Keine Events mehr verfügbar", "remainingEventsZero": "Keine Events mehr verfügbar",
"eventsCreated": "{{used}} von {{limit}} Events angelegt", "eventsCreated": "{{used}} von {{limit}} Events angelegt",
"openEvent": "Event öffnen", "openEvent": "Event öffnen",
@@ -2716,9 +2717,15 @@
"events": "Events", "events": "Events",
"guests": "Gäste", "guests": "Gäste",
"photos": "Fotos", "photos": "Fotos",
"gallery": "Galerietage",
"value": "{{used}} / {{limit}}", "value": "{{used}} / {{limit}}",
"limit": "Limit {{limit}}", "limit": "Limit {{limit}}",
"remaining": "Verbleibend {{count}}" "remaining": "Verbleibend {{count}}",
"remainingOf": "{{remaining}} von {{limit}} übrig",
"statusWarning": "Knapp",
"statusDanger": "Limit erreicht",
"ctaWarning": "Mehr Kapazität sichern",
"ctaDanger": "Paket upgraden"
}, },
"status": { "status": {
"completed": "Abgeschlossen", "completed": "Abgeschlossen",

View File

@@ -2709,6 +2709,7 @@
"mobileBilling": { "mobileBilling": {
"packageFallback": "Package", "packageFallback": "Package",
"remainingEvents": "{{count}} events", "remainingEvents": "{{count}} events",
"remainingEventsOf": "{{remaining}} of {{limit}} events remaining",
"remainingEventsZero": "No events remaining", "remainingEventsZero": "No events remaining",
"eventsCreated": "{{used}} of {{limit}} events created", "eventsCreated": "{{used}} of {{limit}} events created",
"openEvent": "Open event", "openEvent": "Open event",
@@ -2720,9 +2721,15 @@
"events": "Events", "events": "Events",
"guests": "Guests", "guests": "Guests",
"photos": "Photos", "photos": "Photos",
"gallery": "Gallery days",
"value": "{{used}} / {{limit}}", "value": "{{used}} / {{limit}}",
"limit": "Limit {{limit}}", "limit": "Limit {{limit}}",
"remaining": "Remaining {{count}}" "remaining": "Remaining {{count}}",
"remainingOf": "{{remaining}} of {{limit}} remaining",
"statusWarning": "Low",
"statusDanger": "Limit reached",
"ctaWarning": "Secure more capacity",
"ctaDanger": "Upgrade package"
}, },
"status": { "status": {
"completed": "Completed", "completed": "Completed",

View File

@@ -18,7 +18,7 @@ import {
import { TenantAddonHistoryEntry, getTenantAddonHistory } from '../api'; import { TenantAddonHistoryEntry, getTenantAddonHistory } from '../api';
import { getApiErrorMessage } from '../lib/apiError'; import { getApiErrorMessage } from '../lib/apiError';
import { ADMIN_EVENT_VIEW_PATH, adminPath } from '../constants'; import { ADMIN_EVENT_VIEW_PATH, adminPath } from '../constants';
import { buildPackageUsageMetrics, PackageUsageMetric, usagePercent } from './billingUsage'; import { buildPackageUsageMetrics, getUsageState, PackageUsageMetric, usagePercent } from './billingUsage';
import { useBackNavigation } from './hooks/useBackNavigation'; import { useBackNavigation } from './hooks/useBackNavigation';
import { useAdminTheme } from './theme'; import { useAdminTheme } from './theme';
import { import {
@@ -154,6 +154,8 @@ export default function MobileBillingPage() {
pkg={activePackage} pkg={activePackage}
label={t('billing.sections.packages.card.statusActive', 'Aktiv')} label={t('billing.sections.packages.card.statusActive', 'Aktiv')}
isActive isActive
onOpenPortal={openPortal}
portalBusy={portalBusy}
/> />
) : null} ) : null}
{packages {packages
@@ -257,22 +259,46 @@ export default function MobileBillingPage() {
); );
} }
function PackageCard({ pkg, label, isActive = false }: { pkg: TenantPackageSummary; label?: string; isActive?: boolean }) { function PackageCard({
pkg,
label,
isActive = false,
onOpenPortal,
portalBusy,
}: {
pkg: TenantPackageSummary;
label?: string;
isActive?: boolean;
onOpenPortal?: () => void;
portalBusy?: boolean;
}) {
const { t } = useTranslation('management'); const { t } = useTranslation('management');
const { border, primary, accentSoft, textStrong, muted } = useAdminTheme(); const { border, primary, accentSoft, textStrong, muted } = useAdminTheme();
const limits = (pkg.package_limits ?? null) as Record<string, unknown> | null; const limits = (pkg.package_limits ?? null) as Record<string, unknown> | null;
const remaining = pkg.remaining_events ?? (limits?.max_events_per_year as number | undefined) ?? 0; const limitMaxEvents = typeof limits?.max_events_per_year === 'number' ? (limits?.max_events_per_year as number) : null;
const remaining = pkg.remaining_events ?? limitMaxEvents ?? 0;
const remainingText = const remainingText =
remaining === 0 remaining === 0
? t('mobileBilling.remainingEventsZero', 'No events remaining') ? t('mobileBilling.remainingEventsZero', 'No events remaining')
: t('mobileBilling.remainingEvents', '{{count}} events', { count: remaining }); : limitMaxEvents
? t('mobileBilling.remainingEventsOf', '{{remaining}} of {{limit}} events remaining', {
remaining,
limit: limitMaxEvents,
})
: t('mobileBilling.remainingEvents', '{{count}} events', { count: remaining });
const expires = pkg.expires_at ? formatDate(pkg.expires_at) : null; const expires = pkg.expires_at ? formatDate(pkg.expires_at) : null;
const usageMetrics = buildPackageUsageMetrics(pkg); const usageMetrics = buildPackageUsageMetrics(pkg);
const limitEntries = getPackageLimitEntries(limits, t); const usageStates = usageMetrics.map((metric) => getUsageState(metric));
const hasUsageWarning = usageStates.some((state) => state === 'warning' || state === 'danger');
const isDanger = usageStates.includes('danger');
const limitEntries = getPackageLimitEntries(limits, t, {
remainingEvents: pkg.remaining_events ?? null,
usedEvents: typeof pkg.used_events === 'number' ? pkg.used_events : null,
});
const featureKeys = collectPackageFeatures(pkg); const featureKeys = collectPackageFeatures(pkg);
const eventUsageText = formatEventUsage( const eventUsageText = formatEventUsage(
typeof pkg.used_events === 'number' ? pkg.used_events : null, typeof pkg.used_events === 'number' ? pkg.used_events : null,
typeof limits?.max_events_per_year === 'number' ? (limits?.max_events_per_year as number) : null, limitMaxEvents,
t t
); );
return ( return (
@@ -345,6 +371,18 @@ function PackageCard({ pkg, label, isActive = false }: { pkg: TenantPackageSumma
))} ))}
</YStack> </YStack>
) : null} ) : null}
{isActive && hasUsageWarning && onOpenPortal ? (
<CTAButton
label={
isDanger
? t('mobileBilling.usage.ctaDanger', 'Upgrade package')
: t('mobileBilling.usage.ctaWarning', 'Secure more capacity')
}
onPress={onOpenPortal}
disabled={portalBusy}
tone={isDanger ? 'danger' : 'primary'}
/>
) : null}
</MobileCard> </MobileCard>
); );
} }
@@ -358,25 +396,38 @@ function renderFeatureBadge(pkg: TenantPackageSummary, t: any, key: string, labe
function UsageBar({ metric }: { metric: PackageUsageMetric }) { function UsageBar({ metric }: { metric: PackageUsageMetric }) {
const { t } = useTranslation('management'); const { t } = useTranslation('management');
const { muted, textStrong, border, primary, subtle } = useAdminTheme(); const { muted, textStrong, border, primary, subtle, warningText, danger } = useAdminTheme();
const labelMap: Record<PackageUsageMetric['key'], string> = { const labelMap: Record<PackageUsageMetric['key'], string> = {
events: t('mobileBilling.usage.events', 'Events'), events: t('mobileBilling.usage.events', 'Events'),
guests: t('mobileBilling.usage.guests', 'Guests'), guests: t('mobileBilling.usage.guests', 'Guests'),
photos: t('mobileBilling.usage.photos', 'Photos'), photos: t('mobileBilling.usage.photos', 'Photos'),
gallery: t('mobileBilling.usage.gallery', 'Gallery days'),
}; };
if (!metric.limit) { if (!metric.limit) {
return null; return null;
} }
const status = getUsageState(metric);
const hasUsage = metric.used !== null; const hasUsage = metric.used !== null;
const valueText = hasUsage const valueText = hasUsage
? t('mobileBilling.usage.value', { used: metric.used, limit: metric.limit }) ? t('mobileBilling.usage.value', { used: metric.used, limit: metric.limit })
: t('mobileBilling.usage.limit', { limit: metric.limit }); : t('mobileBilling.usage.limit', { limit: metric.limit });
const remainingText = metric.remaining !== null const remainingText = metric.remaining !== null
? t('mobileBilling.usage.remaining', { count: metric.remaining }) ? t('mobileBilling.usage.remainingOf', {
remaining: metric.remaining,
limit: metric.limit,
defaultValue: 'Remaining {{remaining}} of {{limit}}',
})
: null; : null;
const fill = usagePercent(metric); const fill = usagePercent(metric);
const statusLabel =
status === 'danger'
? t('mobileBilling.usage.statusDanger', 'Limit reached')
: status === 'warning'
? t('mobileBilling.usage.statusWarning', 'Low')
: null;
const fillColor = status === 'danger' ? danger : status === 'warning' ? warningText : primary;
return ( return (
<YStack space="$1.5"> <YStack space="$1.5">
@@ -384,12 +435,15 @@ function UsageBar({ metric }: { metric: PackageUsageMetric }) {
<Text fontSize="$xs" color={muted}> <Text fontSize="$xs" color={muted}>
{labelMap[metric.key]} {labelMap[metric.key]}
</Text> </Text>
<Text fontSize="$xs" color={textStrong} fontWeight="700"> <XStack alignItems="center" space="$1.5">
{valueText} {statusLabel ? <PillBadge tone={status === 'danger' ? 'danger' : 'warning'}>{statusLabel}</PillBadge> : null}
</Text> <Text fontSize="$xs" color={textStrong} fontWeight="700">
{valueText}
</Text>
</XStack>
</XStack> </XStack>
<YStack height={6} borderRadius={999} backgroundColor={border} overflow="hidden"> <YStack height={6} borderRadius={999} backgroundColor={border} overflow="hidden">
<YStack height="100%" width={`${fill}%`} backgroundColor={hasUsage ? primary : subtle} /> <YStack height="100%" width={`${fill}%`} backgroundColor={hasUsage ? fillColor : subtle} />
</YStack> </YStack>
{remainingText ? ( {remainingText ? (
<Text fontSize="$xs" color={muted}> <Text fontSize="$xs" color={muted}>

View File

@@ -511,7 +511,7 @@ function PackageSummarySheet({
branding_allowed: (limits as any)?.branding_allowed ?? null, branding_allowed: (limits as any)?.branding_allowed ?? null,
watermark_allowed: (limits as any)?.watermark_allowed ?? null, watermark_allowed: (limits as any)?.watermark_allowed ?? null,
} as any); } as any);
const limitEntries = getPackageLimitEntries(limits, t); const limitEntries = getPackageLimitEntries(limits, t, { remainingEvents });
const hasFeatures = resolvedFeatures.length > 0; const hasFeatures = resolvedFeatures.length > 0;
const formatDate = (value?: string | null) => formatEventDate(value, locale) ?? t('mobileDashboard.packageSummary.unknown', 'Unknown'); const formatDate = (value?: string | null) => formatEventDate(value, locale) ?? t('mobileDashboard.packageSummary.unknown', 'Unknown');

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import type { TenantPackageSummary } from '../../api'; import type { TenantPackageSummary } from '../../api';
import { buildPackageUsageMetrics, usagePercent } from '../billingUsage'; import { buildPackageUsageMetrics, getUsageState, usagePercent } from '../billingUsage';
const basePackage: TenantPackageSummary = { const basePackage: TenantPackageSummary = {
id: 1, id: 1,
@@ -17,6 +17,8 @@ const basePackage: TenantPackageSummary = {
max_events_per_year: 5, max_events_per_year: 5,
max_guests: 150, max_guests: 150,
max_photos: 1000, max_photos: 1000,
gallery_days: 90,
remaining_gallery_days: 30,
}, },
branding_allowed: true, branding_allowed: true,
watermark_allowed: true, watermark_allowed: true,
@@ -27,12 +29,25 @@ describe('buildPackageUsageMetrics', () => {
it('builds usage metrics for event, guest, and photo limits', () => { it('builds usage metrics for event, guest, and photo limits', () => {
const metrics = buildPackageUsageMetrics(basePackage); const metrics = buildPackageUsageMetrics(basePackage);
const keys = metrics.map((metric) => metric.key); const keys = metrics.map((metric) => metric.key);
expect(keys).toEqual(['events', 'guests', 'photos']); expect(keys).toEqual(['events', 'guests', 'photos', 'gallery']);
const eventMetric = metrics.find((metric) => metric.key === 'events'); const eventMetric = metrics.find((metric) => metric.key === 'events');
expect(eventMetric?.used).toBe(2); expect(eventMetric?.used).toBe(2);
expect(eventMetric?.limit).toBe(5); expect(eventMetric?.limit).toBe(5);
expect(eventMetric?.remaining).toBe(3); expect(eventMetric?.remaining).toBe(3);
const galleryMetric = metrics.find((metric) => metric.key === 'gallery');
expect(galleryMetric?.remaining).toBe(30);
});
it('derives remaining when only usage is known', () => {
const metrics = buildPackageUsageMetrics({
...basePackage,
remaining_events: null,
package_limits: { max_events_per_year: 10, gallery_days: 60 },
used_events: 4,
});
expect(metrics[0].remaining).toBe(6);
}); });
it('filters metrics without limits', () => { it('filters metrics without limits', () => {
@@ -62,3 +77,16 @@ describe('usagePercent', () => {
expect(usagePercent(metrics[0])).toBe(100); expect(usagePercent(metrics[0])).toBe(100);
}); });
}); });
describe('getUsageState', () => {
it('returns warning and danger based on thresholds', () => {
const metrics = buildPackageUsageMetrics({
...basePackage,
used_events: 8,
remaining_events: null,
package_limits: { max_events_per_year: 10 },
});
expect(getUsageState(metrics[0])).toBe('warning');
expect(getUsageState({ ...metrics[0], used: 10, remaining: 0 })).toBe('danger');
});
});

View File

@@ -1,6 +1,6 @@
import type { TenantPackageSummary } from '../api'; import type { TenantPackageSummary } from '../api';
export type PackageUsageMetricKey = 'events' | 'guests' | 'photos'; export type PackageUsageMetricKey = 'events' | 'guests' | 'photos' | 'gallery';
export type PackageUsageMetric = { export type PackageUsageMetric = {
key: PackageUsageMetricKey; key: PackageUsageMetricKey;
@@ -9,6 +9,8 @@ export type PackageUsageMetric = {
remaining: number | null; remaining: number | null;
}; };
export type PackageUsageState = 'ok' | 'warning' | 'danger';
const toNumber = (value: unknown): number | null => { const toNumber = (value: unknown): number | null => {
if (typeof value === 'number' && Number.isFinite(value)) { if (typeof value === 'number' && Number.isFinite(value)) {
return value; return value;
@@ -48,25 +50,56 @@ const deriveUsedFromRemaining = (limit: number | null, remaining: number | null)
return Math.max(limit - remaining, 0); return Math.max(limit - remaining, 0);
}; };
const deriveRemainingFromUsed = (limit: number | null, used: number | null): number | null => {
if (limit === null || used === null) {
return null;
}
return Math.max(limit - used, 0);
};
const normalizeCount = (value: number | null, precision: 'round' | 'floor' = 'round'): number | null => {
if (value === null) {
return null;
}
const safe = Number.isFinite(value) ? value : null;
if (safe === null) {
return null;
}
return Math.max(0, precision === 'floor' ? Math.floor(safe) : Math.round(safe));
};
export const buildPackageUsageMetrics = (pkg: TenantPackageSummary): PackageUsageMetric[] => { export const buildPackageUsageMetrics = (pkg: TenantPackageSummary): PackageUsageMetric[] => {
const limits = pkg.package_limits ?? {}; const limits = pkg.package_limits ?? {};
const eventLimit = resolveLimitValue(limits, 'max_events_per_year'); const eventLimit = resolveLimitValue(limits, 'max_events_per_year');
const eventRemaining = resolveUsageValue(pkg.remaining_events); const eventRemaining = resolveUsageValue(pkg.remaining_events);
const eventUsed = resolveUsageValue(pkg.used_events) ?? deriveUsedFromRemaining(eventLimit, eventRemaining); const eventUsed = resolveUsageValue(pkg.used_events) ?? deriveUsedFromRemaining(eventLimit, eventRemaining);
const resolvedEventRemaining = eventRemaining ?? deriveRemainingFromUsed(eventLimit, eventUsed);
const guestLimit = resolveLimitValue(limits, 'max_guests'); const guestLimit = resolveLimitValue(limits, 'max_guests');
const guestRemaining = resolveUsageValue(limits['remaining_guests']); const guestRemaining = resolveUsageValue(limits['remaining_guests']);
const guestUsed = resolveUsageValue(limits['used_guests']) ?? deriveUsedFromRemaining(guestLimit, guestRemaining); const guestUsed = resolveUsageValue(limits['used_guests']) ?? deriveUsedFromRemaining(guestLimit, guestRemaining);
const resolvedGuestRemaining = guestRemaining ?? deriveRemainingFromUsed(guestLimit, guestUsed);
const photoLimit = resolveLimitValue(limits, 'max_photos'); const photoLimit = resolveLimitValue(limits, 'max_photos');
const photoRemaining = resolveUsageValue(limits['remaining_photos']); const photoRemaining = resolveUsageValue(limits['remaining_photos']);
const photoUsed = resolveUsageValue(limits['used_photos']) ?? deriveUsedFromRemaining(photoLimit, photoRemaining); const photoUsed = resolveUsageValue(limits['used_photos']) ?? deriveUsedFromRemaining(photoLimit, photoRemaining);
const resolvedPhotoRemaining = photoRemaining ?? deriveRemainingFromUsed(photoLimit, photoUsed);
const galleryLimit = resolveLimitValue(limits, 'gallery_days');
const galleryRemaining = resolveUsageValue(limits['remaining_gallery_days']);
const galleryUsed = resolveUsageValue(limits['used_gallery_days']) ?? deriveUsedFromRemaining(galleryLimit, galleryRemaining);
const resolvedGalleryRemaining = normalizeCount(galleryRemaining ?? deriveRemainingFromUsed(galleryLimit, galleryUsed));
const resolvedGalleryUsed = normalizeCount(galleryUsed);
return [ return [
{ key: 'events', limit: eventLimit, used: eventUsed, remaining: eventRemaining }, { key: 'events', limit: eventLimit, used: eventUsed, remaining: resolvedEventRemaining },
{ key: 'guests', limit: guestLimit, used: guestUsed, remaining: guestRemaining }, { key: 'guests', limit: guestLimit, used: guestUsed, remaining: resolvedGuestRemaining },
{ key: 'photos', limit: photoLimit, used: photoUsed, remaining: photoRemaining }, { key: 'photos', limit: photoLimit, used: photoUsed, remaining: resolvedPhotoRemaining },
{ key: 'gallery', limit: galleryLimit, used: resolvedGalleryUsed, remaining: resolvedGalleryRemaining },
].filter((metric) => metric.limit !== null); ].filter((metric) => metric.limit !== null);
}; };
@@ -81,3 +114,28 @@ export const usagePercent = (metric: PackageUsageMetric): number => {
return Math.min(100, Math.max(0, Math.round((metric.used / metric.limit) * 100))); return Math.min(100, Math.max(0, Math.round((metric.used / metric.limit) * 100)));
}; };
export const getUsageState = (metric: PackageUsageMetric): PackageUsageState => {
if (!metric.limit || metric.limit <= 0) {
return 'ok';
}
if (metric.remaining !== null && metric.remaining <= 0) {
return 'danger';
}
if (metric.used === null) {
return 'ok';
}
const percent = usagePercent(metric);
if (percent >= 95) {
return 'danger';
}
if (percent >= 80) {
return 'warning';
}
return 'ok';
};

View File

@@ -34,7 +34,7 @@ export function PillBadge({
tone = 'muted', tone = 'muted',
children, children,
}: { }: {
tone?: 'success' | 'warning' | 'muted'; tone?: 'success' | 'warning' | 'danger' | 'muted';
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const { theme } = useAdminTheme(); const { theme } = useAdminTheme();
@@ -49,6 +49,11 @@ export function PillBadge({
text: String(theme.yellow11?.val ?? '#92400e'), text: String(theme.yellow11?.val ?? '#92400e'),
border: String(theme.yellow6?.val ?? '#fef3c7'), border: String(theme.yellow6?.val ?? '#fef3c7'),
}, },
danger: {
bg: String(theme.red3?.val ?? '#FEE2E2'),
text: String(theme.red11?.val ?? '#B91C1C'),
border: String(theme.red6?.val ?? '#FCA5A5'),
},
muted: { muted: {
bg: String(theme.gray3?.val ?? '#f3f4f6'), bg: String(theme.gray3?.val ?? '#f3f4f6'),
text: String(theme.gray11?.val ?? '#374151'), text: String(theme.gray11?.val ?? '#374151'),

View File

@@ -14,7 +14,8 @@ const t = (key: string, options?: Record<string, unknown> | string) => {
const template = (options?.defaultValue as string | undefined) ?? key; const template = (options?.defaultValue as string | undefined) ?? key;
return template return template
.replace('{{used}}', String(options?.used ?? '{{used}}')) .replace('{{used}}', String(options?.used ?? '{{used}}'))
.replace('{{limit}}', String(options?.limit ?? '{{limit}}')); .replace('{{limit}}', String(options?.limit ?? '{{limit}}'))
.replace('{{remaining}}', String(options?.remaining ?? '{{remaining}}'));
}; };
describe('packageSummary helpers', () => { describe('packageSummary helpers', () => {
@@ -46,10 +47,10 @@ describe('packageSummary helpers', () => {
}); });
it('returns labeled limit entries', () => { it('returns labeled limit entries', () => {
const result = getPackageLimitEntries({ max_photos: 120 }, t); const result = getPackageLimitEntries({ max_photos: 120, remaining_photos: 30 }, t);
expect(result[0].label).toBe('Photos'); expect(result[0].label).toBe('Photos');
expect(result[0].value).toBe('120'); expect(result[0].value).toBe('30 of 120 remaining');
}); });
it('formats event usage copy', () => { it('formats event usage copy', () => {

View File

@@ -2,6 +2,11 @@ import type { TenantPackageSummary } from '../../api';
type Translate = (key: string, options?: Record<string, unknown> | string) => string; type Translate = (key: string, options?: Record<string, unknown> | string) => string;
type LimitUsageOverrides = {
remainingEvents?: number | null;
usedEvents?: number | null;
};
const FEATURE_LABELS: Record<string, { key: string; fallback: string }> = { const FEATURE_LABELS: Record<string, { key: string; fallback: string }> = {
priority_support: { priority_support: {
key: 'mobileDashboard.packageSummary.feature.priority_support', key: 'mobileDashboard.packageSummary.feature.priority_support',
@@ -111,6 +116,38 @@ export type PackageLimitEntry = {
value: string; value: string;
}; };
const toNumber = (value: unknown): number | null => {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string' && value.trim() !== '') {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return null;
};
const formatLimitWithRemaining = (limit: number | null, remaining: number | null, t: Translate): string => {
if (limit === null || limit === undefined) {
return t('mobileDashboard.packageSummary.unlimited', 'Unlimited');
}
if (remaining !== null && remaining >= 0) {
const normalizedRemaining = Number.isFinite(remaining) ? Math.max(0, Math.round(remaining)) : remaining;
return t('mobileBilling.usage.remainingOf', {
remaining: normalizedRemaining,
limit,
defaultValue: '{{remaining}} of {{limit}} remaining',
});
}
return String(limit);
};
export function getPackageFeatureLabel(feature: string, t: Translate): string { export function getPackageFeatureLabel(feature: string, t: Translate): string {
const entry = FEATURE_LABELS[feature]; const entry = FEATURE_LABELS[feature];
if (entry) { if (entry) {
@@ -128,7 +165,11 @@ export function formatPackageLimit(value: number | null | undefined, t: Translat
return String(value); return String(value);
} }
export function getPackageLimitEntries(limits: Record<string, unknown> | null, t: Translate): PackageLimitEntry[] { export function getPackageLimitEntries(
limits: Record<string, unknown> | null,
t: Translate,
usageOverrides: LimitUsageOverrides = {}
): PackageLimitEntry[] {
if (!limits) { if (!limits) {
return []; return [];
} }
@@ -136,10 +177,37 @@ export function getPackageLimitEntries(limits: Record<string, unknown> | null, t
return LIMIT_LABELS.map(({ key, labelKey, fallback }) => ({ return LIMIT_LABELS.map(({ key, labelKey, fallback }) => ({
key, key,
label: t(labelKey, fallback), label: t(labelKey, fallback),
value: formatPackageLimit((limits as Record<string, number | null>)[key], t), value: formatLimitWithRemaining(
toNumber((limits as Record<string, number | null>)[key]),
resolveRemainingForKey(limits, key, usageOverrides),
t
),
})); }));
} }
const resolveRemainingForKey = (
limits: Record<string, unknown>,
key: string,
usageOverrides: LimitUsageOverrides
): number | null => {
if (key === 'max_events_per_year') {
return toNumber(usageOverrides.remainingEvents ?? limits.remaining_events);
}
const map: Record<string, string> = {
max_photos: 'remaining_photos',
max_guests: 'remaining_guests',
gallery_days: 'remaining_gallery_days',
};
const remainingKey = map[key];
if (!remainingKey) {
return null;
}
return toNumber(limits[remainingKey]);
};
export function collectPackageFeatures(pkg: TenantPackageSummary): string[] { export function collectPackageFeatures(pkg: TenantPackageSummary): string[] {
const features = new Set<string>(); const features = new Set<string>();
const direct = Array.isArray(pkg.features) ? pkg.features : []; const direct = Array.isArray(pkg.features) ? pkg.features : [];

View File

@@ -3,14 +3,19 @@
namespace Tests\Feature\Tenant; namespace Tests\Feature\Tenant;
use App\Http\Controllers\Api\TenantPackageController; use App\Http\Controllers\Api\TenantPackageController;
use App\Models\Event;
use App\Models\EventPackage;
use App\Models\Package; use App\Models\Package;
use App\Models\TenantPackage; use App\Models\TenantPackage;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
class TenantPackageOverviewTest extends TenantTestCase class TenantPackageOverviewTest extends TenantTestCase
{ {
public function test_active_package_contains_limits_and_features(): void public function test_active_package_contains_limits_and_features(): void
{ {
Carbon::setTestNow(Carbon::parse('2024-01-01 10:00:00'));
$package = Package::factory()->reseller()->create([ $package = Package::factory()->reseller()->create([
'max_photos' => 1500, 'max_photos' => 1500,
'max_guests' => 250, 'max_guests' => 250,
@@ -29,6 +34,20 @@ class TenantPackageOverviewTest extends TenantTestCase
'used_events' => 3, 'used_events' => 3,
]); ]);
$event = Event::factory()->create([
'tenant_id' => $this->tenant->id,
]);
EventPackage::create([
'event_id' => $event->id,
'package_id' => $package->id,
'purchased_price' => 199.00,
'purchased_at' => now()->subDay(),
'used_photos' => 700,
'used_guests' => 120,
'gallery_expires_at' => now()->addDays(20),
]);
$request = Request::create('/api/v1/tenant/packages', 'GET'); $request = Request::create('/api/v1/tenant/packages', 'GET');
$request->attributes->set('tenant', $this->tenant); $request->attributes->set('tenant', $this->tenant);
@@ -43,6 +62,12 @@ class TenantPackageOverviewTest extends TenantTestCase
$this->assertTrue($payload['active_package']['package_limits']['branding_allowed']); $this->assertTrue($payload['active_package']['package_limits']['branding_allowed']);
$this->assertFalse($payload['active_package']['package_limits']['watermark_allowed']); $this->assertFalse($payload['active_package']['package_limits']['watermark_allowed']);
$this->assertSame(['custom_branding', 'reseller_dashboard'], $payload['active_package']['package_limits']['features']); $this->assertSame(['custom_branding', 'reseller_dashboard'], $payload['active_package']['package_limits']['features']);
$this->assertSame(700, $payload['active_package']['package_limits']['used_photos']);
$this->assertSame(800, $payload['active_package']['package_limits']['remaining_photos']);
$this->assertSame(120, $payload['active_package']['package_limits']['used_guests']);
$this->assertSame(130, $payload['active_package']['package_limits']['remaining_guests']);
$this->assertSame(20, $payload['active_package']['package_limits']['remaining_gallery_days']);
$this->assertSame(10, $payload['active_package']['package_limits']['used_gallery_days']);
$this->assertSame(9, $payload['active_package']['remaining_events']); $this->assertSame(9, $payload['active_package']['remaining_events']);
} }
} }