Improve package usage visibility
This commit is contained in:
@@ -203,9 +203,20 @@ class SeedDemoSwitcherTenants extends Command
|
||||
|
||||
$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(
|
||||
tenant: $tenant,
|
||||
package: $packages['starter'],
|
||||
package: $packages['standard'],
|
||||
eventType: $eventTypes['wedding'] ?? null,
|
||||
attributes: [
|
||||
'name' => ['de' => 'Hochzeit Mia & Jonas', 'en' => 'Wedding Mia & Jonas'],
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\EventPackage;
|
||||
use App\Models\TenantPackage;
|
||||
use App\Support\ApiError;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -29,12 +30,17 @@ class TenantPackageController extends Controller
|
||||
->orderBy('created_at', 'desc')
|
||||
->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');
|
||||
|
||||
if ($activePackage instanceof TenantPackage) {
|
||||
$this->hydratePackageSnapshot($activePackage);
|
||||
$this->hydratePackageSnapshot($activePackage, $usageEventPackage);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -52,6 +58,7 @@ class TenantPackageController extends Controller
|
||||
$package->remaining_events = $maxEvents === null ? null : max($maxEvents - $package->used_events, 0);
|
||||
$package->package_limits = array_merge(
|
||||
$pkg?->limits ?? [],
|
||||
$this->buildUsageSnapshot($eventPackage),
|
||||
[
|
||||
'branding_allowed' => $pkg?->branding_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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2705,6 +2705,7 @@
|
||||
"mobileBilling": {
|
||||
"packageFallback": "Paket",
|
||||
"remainingEvents": "{{count}} Events",
|
||||
"remainingEventsOf": "{{remaining}} von {{limit}} Events übrig",
|
||||
"remainingEventsZero": "Keine Events mehr verfügbar",
|
||||
"eventsCreated": "{{used}} von {{limit}} Events angelegt",
|
||||
"openEvent": "Event öffnen",
|
||||
@@ -2716,9 +2717,15 @@
|
||||
"events": "Events",
|
||||
"guests": "Gäste",
|
||||
"photos": "Fotos",
|
||||
"gallery": "Galerietage",
|
||||
"value": "{{used}} / {{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": {
|
||||
"completed": "Abgeschlossen",
|
||||
|
||||
@@ -2709,6 +2709,7 @@
|
||||
"mobileBilling": {
|
||||
"packageFallback": "Package",
|
||||
"remainingEvents": "{{count}} events",
|
||||
"remainingEventsOf": "{{remaining}} of {{limit}} events remaining",
|
||||
"remainingEventsZero": "No events remaining",
|
||||
"eventsCreated": "{{used}} of {{limit}} events created",
|
||||
"openEvent": "Open event",
|
||||
@@ -2720,9 +2721,15 @@
|
||||
"events": "Events",
|
||||
"guests": "Guests",
|
||||
"photos": "Photos",
|
||||
"gallery": "Gallery days",
|
||||
"value": "{{used}} / {{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": {
|
||||
"completed": "Completed",
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
import { TenantAddonHistoryEntry, getTenantAddonHistory } from '../api';
|
||||
import { getApiErrorMessage } from '../lib/apiError';
|
||||
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 { useAdminTheme } from './theme';
|
||||
import {
|
||||
@@ -154,6 +154,8 @@ export default function MobileBillingPage() {
|
||||
pkg={activePackage}
|
||||
label={t('billing.sections.packages.card.statusActive', 'Aktiv')}
|
||||
isActive
|
||||
onOpenPortal={openPortal}
|
||||
portalBusy={portalBusy}
|
||||
/>
|
||||
) : null}
|
||||
{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 { border, primary, accentSoft, textStrong, muted } = useAdminTheme();
|
||||
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 =
|
||||
remaining === 0
|
||||
? t('mobileBilling.remainingEventsZero', 'No events 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 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 eventUsageText = formatEventUsage(
|
||||
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
|
||||
);
|
||||
return (
|
||||
@@ -345,6 +371,18 @@ function PackageCard({ pkg, label, isActive = false }: { pkg: TenantPackageSumma
|
||||
))}
|
||||
</YStack>
|
||||
) : 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>
|
||||
);
|
||||
}
|
||||
@@ -358,25 +396,38 @@ function renderFeatureBadge(pkg: TenantPackageSummary, t: any, key: string, labe
|
||||
|
||||
function UsageBar({ metric }: { metric: PackageUsageMetric }) {
|
||||
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> = {
|
||||
events: t('mobileBilling.usage.events', 'Events'),
|
||||
guests: t('mobileBilling.usage.guests', 'Guests'),
|
||||
photos: t('mobileBilling.usage.photos', 'Photos'),
|
||||
gallery: t('mobileBilling.usage.gallery', 'Gallery days'),
|
||||
};
|
||||
|
||||
if (!metric.limit) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const status = getUsageState(metric);
|
||||
const hasUsage = metric.used !== null;
|
||||
const valueText = hasUsage
|
||||
? t('mobileBilling.usage.value', { used: metric.used, limit: metric.limit })
|
||||
: t('mobileBilling.usage.limit', { limit: metric.limit });
|
||||
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;
|
||||
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 (
|
||||
<YStack space="$1.5">
|
||||
@@ -384,12 +435,15 @@ function UsageBar({ metric }: { metric: PackageUsageMetric }) {
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{labelMap[metric.key]}
|
||||
</Text>
|
||||
<XStack alignItems="center" space="$1.5">
|
||||
{statusLabel ? <PillBadge tone={status === 'danger' ? 'danger' : 'warning'}>{statusLabel}</PillBadge> : null}
|
||||
<Text fontSize="$xs" color={textStrong} fontWeight="700">
|
||||
{valueText}
|
||||
</Text>
|
||||
</XStack>
|
||||
</XStack>
|
||||
<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>
|
||||
{remainingText ? (
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
|
||||
@@ -511,7 +511,7 @@ function PackageSummarySheet({
|
||||
branding_allowed: (limits as any)?.branding_allowed ?? null,
|
||||
watermark_allowed: (limits as any)?.watermark_allowed ?? null,
|
||||
} as any);
|
||||
const limitEntries = getPackageLimitEntries(limits, t);
|
||||
const limitEntries = getPackageLimitEntries(limits, t, { remainingEvents });
|
||||
const hasFeatures = resolvedFeatures.length > 0;
|
||||
|
||||
const formatDate = (value?: string | null) => formatEventDate(value, locale) ?? t('mobileDashboard.packageSummary.unknown', 'Unknown');
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { TenantPackageSummary } from '../../api';
|
||||
import { buildPackageUsageMetrics, usagePercent } from '../billingUsage';
|
||||
import { buildPackageUsageMetrics, getUsageState, usagePercent } from '../billingUsage';
|
||||
|
||||
const basePackage: TenantPackageSummary = {
|
||||
id: 1,
|
||||
@@ -17,6 +17,8 @@ const basePackage: TenantPackageSummary = {
|
||||
max_events_per_year: 5,
|
||||
max_guests: 150,
|
||||
max_photos: 1000,
|
||||
gallery_days: 90,
|
||||
remaining_gallery_days: 30,
|
||||
},
|
||||
branding_allowed: true,
|
||||
watermark_allowed: true,
|
||||
@@ -27,12 +29,25 @@ describe('buildPackageUsageMetrics', () => {
|
||||
it('builds usage metrics for event, guest, and photo limits', () => {
|
||||
const metrics = buildPackageUsageMetrics(basePackage);
|
||||
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');
|
||||
expect(eventMetric?.used).toBe(2);
|
||||
expect(eventMetric?.limit).toBe(5);
|
||||
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', () => {
|
||||
@@ -62,3 +77,16 @@ describe('usagePercent', () => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { TenantPackageSummary } from '../api';
|
||||
|
||||
export type PackageUsageMetricKey = 'events' | 'guests' | 'photos';
|
||||
export type PackageUsageMetricKey = 'events' | 'guests' | 'photos' | 'gallery';
|
||||
|
||||
export type PackageUsageMetric = {
|
||||
key: PackageUsageMetricKey;
|
||||
@@ -9,6 +9,8 @@ export type PackageUsageMetric = {
|
||||
remaining: number | null;
|
||||
};
|
||||
|
||||
export type PackageUsageState = 'ok' | 'warning' | 'danger';
|
||||
|
||||
const toNumber = (value: unknown): number | null => {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value;
|
||||
@@ -48,25 +50,56 @@ const deriveUsedFromRemaining = (limit: number | null, remaining: number | null)
|
||||
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[] => {
|
||||
const limits = pkg.package_limits ?? {};
|
||||
|
||||
const eventLimit = resolveLimitValue(limits, 'max_events_per_year');
|
||||
const eventRemaining = resolveUsageValue(pkg.remaining_events);
|
||||
const eventUsed = resolveUsageValue(pkg.used_events) ?? deriveUsedFromRemaining(eventLimit, eventRemaining);
|
||||
const resolvedEventRemaining = eventRemaining ?? deriveRemainingFromUsed(eventLimit, eventUsed);
|
||||
|
||||
const guestLimit = resolveLimitValue(limits, 'max_guests');
|
||||
const guestRemaining = resolveUsageValue(limits['remaining_guests']);
|
||||
const guestUsed = resolveUsageValue(limits['used_guests']) ?? deriveUsedFromRemaining(guestLimit, guestRemaining);
|
||||
const resolvedGuestRemaining = guestRemaining ?? deriveRemainingFromUsed(guestLimit, guestUsed);
|
||||
|
||||
const photoLimit = resolveLimitValue(limits, 'max_photos');
|
||||
const photoRemaining = resolveUsageValue(limits['remaining_photos']);
|
||||
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 [
|
||||
{ key: 'events', limit: eventLimit, used: eventUsed, remaining: eventRemaining },
|
||||
{ key: 'guests', limit: guestLimit, used: guestUsed, remaining: guestRemaining },
|
||||
{ key: 'photos', limit: photoLimit, used: photoUsed, remaining: photoRemaining },
|
||||
{ key: 'events', limit: eventLimit, used: eventUsed, remaining: resolvedEventRemaining },
|
||||
{ key: 'guests', limit: guestLimit, used: guestUsed, remaining: resolvedGuestRemaining },
|
||||
{ key: 'photos', limit: photoLimit, used: photoUsed, remaining: resolvedPhotoRemaining },
|
||||
{ key: 'gallery', limit: galleryLimit, used: resolvedGalleryUsed, remaining: resolvedGalleryRemaining },
|
||||
].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)));
|
||||
};
|
||||
|
||||
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';
|
||||
};
|
||||
|
||||
@@ -34,7 +34,7 @@ export function PillBadge({
|
||||
tone = 'muted',
|
||||
children,
|
||||
}: {
|
||||
tone?: 'success' | 'warning' | 'muted';
|
||||
tone?: 'success' | 'warning' | 'danger' | 'muted';
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { theme } = useAdminTheme();
|
||||
@@ -49,6 +49,11 @@ export function PillBadge({
|
||||
text: String(theme.yellow11?.val ?? '#92400e'),
|
||||
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: {
|
||||
bg: String(theme.gray3?.val ?? '#f3f4f6'),
|
||||
text: String(theme.gray11?.val ?? '#374151'),
|
||||
|
||||
@@ -14,7 +14,8 @@ const t = (key: string, options?: Record<string, unknown> | string) => {
|
||||
const template = (options?.defaultValue as string | undefined) ?? key;
|
||||
return template
|
||||
.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', () => {
|
||||
@@ -46,10 +47,10 @@ describe('packageSummary helpers', () => {
|
||||
});
|
||||
|
||||
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].value).toBe('120');
|
||||
expect(result[0].value).toBe('30 of 120 remaining');
|
||||
});
|
||||
|
||||
it('formats event usage copy', () => {
|
||||
|
||||
@@ -2,6 +2,11 @@ import type { TenantPackageSummary } from '../../api';
|
||||
|
||||
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 }> = {
|
||||
priority_support: {
|
||||
key: 'mobileDashboard.packageSummary.feature.priority_support',
|
||||
@@ -111,6 +116,38 @@ export type PackageLimitEntry = {
|
||||
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 {
|
||||
const entry = FEATURE_LABELS[feature];
|
||||
if (entry) {
|
||||
@@ -128,7 +165,11 @@ export function formatPackageLimit(value: number | null | undefined, t: Translat
|
||||
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) {
|
||||
return [];
|
||||
}
|
||||
@@ -136,10 +177,37 @@ export function getPackageLimitEntries(limits: Record<string, unknown> | null, t
|
||||
return LIMIT_LABELS.map(({ key, labelKey, fallback }) => ({
|
||||
key,
|
||||
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[] {
|
||||
const features = new Set<string>();
|
||||
const direct = Array.isArray(pkg.features) ? pkg.features : [];
|
||||
|
||||
@@ -3,14 +3,19 @@
|
||||
namespace Tests\Feature\Tenant;
|
||||
|
||||
use App\Http\Controllers\Api\TenantPackageController;
|
||||
use App\Models\Event;
|
||||
use App\Models\EventPackage;
|
||||
use App\Models\Package;
|
||||
use App\Models\TenantPackage;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class TenantPackageOverviewTest extends TenantTestCase
|
||||
{
|
||||
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([
|
||||
'max_photos' => 1500,
|
||||
'max_guests' => 250,
|
||||
@@ -29,6 +34,20 @@ class TenantPackageOverviewTest extends TenantTestCase
|
||||
'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->attributes->set('tenant', $this->tenant);
|
||||
|
||||
@@ -43,6 +62,12 @@ class TenantPackageOverviewTest extends TenantTestCase
|
||||
$this->assertTrue($payload['active_package']['package_limits']['branding_allowed']);
|
||||
$this->assertFalse($payload['active_package']['package_limits']['watermark_allowed']);
|
||||
$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']);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user