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

@@ -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')
: 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 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>
<Text fontSize="$xs" color={textStrong} fontWeight="700">
{valueText}
</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}>