This commit adds the ability for tenants to upgrade their package directly from the mobile billing page. It includes: - New API function createTenantPaddleCheckout in api.ts - Upgrade handler and UI in BillingPage.tsx - Updated navigation in EventAnalyticsPage.tsx to link to the packages section of the billing page
580 lines
22 KiB
TypeScript
580 lines
22 KiB
TypeScript
import React from 'react';
|
|
import { useNavigate, useLocation } from 'react-router-dom';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { Package, Receipt, RefreshCcw, Sparkles } from 'lucide-react';
|
|
import { YStack, XStack } from '@tamagui/stacks';
|
|
import { SizableText as Text } from '@tamagui/text';
|
|
import { Pressable } from '@tamagui/react-native-web-lite';
|
|
import toast from 'react-hot-toast';
|
|
import { MobileShell, HeaderActionButton } from './components/MobileShell';
|
|
import { MobileCard, CTAButton, PillBadge } from './components/Primitives';
|
|
import {
|
|
createTenantBillingPortalSession,
|
|
getTenantPackagesOverview,
|
|
getTenantPaddleTransactions,
|
|
TenantPackageSummary,
|
|
PaddleTransactionSummary,
|
|
createTenantPaddleCheckout,
|
|
} from '../api';
|
|
import { TenantAddonHistoryEntry, getTenantAddonHistory } from '../api';
|
|
import { getApiErrorMessage } from '../lib/apiError';
|
|
import { ADMIN_EVENT_VIEW_PATH, adminPath } from '../constants';
|
|
import { buildPackageUsageMetrics, getUsageState, PackageUsageMetric, usagePercent } from './billingUsage';
|
|
import { useBackNavigation } from './hooks/useBackNavigation';
|
|
import { useAdminTheme } from './theme';
|
|
import {
|
|
collectPackageFeatures,
|
|
formatEventUsage,
|
|
getPackageFeatureLabel,
|
|
getPackageLimitEntries,
|
|
} from './lib/packageSummary';
|
|
|
|
export default function MobileBillingPage() {
|
|
const { t } = useTranslation('management');
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
const { textStrong, text, muted, subtle, danger, border, primary, accentSoft } = useAdminTheme();
|
|
const [packages, setPackages] = React.useState<TenantPackageSummary[]>([]);
|
|
const [activePackage, setActivePackage] = React.useState<TenantPackageSummary | null>(null);
|
|
const [transactions, setTransactions] = React.useState<PaddleTransactionSummary[]>([]);
|
|
const [addons, setAddons] = React.useState<TenantAddonHistoryEntry[]>([]);
|
|
const [loading, setLoading] = React.useState(true);
|
|
const [error, setError] = React.useState<string | null>(null);
|
|
const [portalBusy, setPortalBusy] = React.useState(false);
|
|
const [upgradeBusy, setUpgradeBusy] = React.useState<number | null>(null);
|
|
const packagesRef = React.useRef<HTMLDivElement | null>(null);
|
|
const invoicesRef = React.useRef<HTMLDivElement | null>(null);
|
|
const supportEmail = 'support@fotospiel.de';
|
|
const back = useBackNavigation(adminPath('/mobile/profile'));
|
|
|
|
const load = React.useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const [pkg, trx, addonHistory] = await Promise.all([
|
|
getTenantPackagesOverview({ force: true }),
|
|
getTenantPaddleTransactions().catch(() => ({ data: [] as PaddleTransactionSummary[] })),
|
|
getTenantAddonHistory().catch(() => ({ data: [] as TenantAddonHistoryEntry[] })),
|
|
]);
|
|
setPackages(pkg.packages ?? []);
|
|
setActivePackage(pkg.activePackage ?? null);
|
|
setTransactions(trx.data ?? []);
|
|
setAddons(addonHistory.data ?? []);
|
|
setError(null);
|
|
} catch (err) {
|
|
const message = getApiErrorMessage(err, t('billing.errors.load', 'Konnte Abrechnung nicht laden.'));
|
|
setError(message);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [t]);
|
|
|
|
const scrollToPackages = React.useCallback(() => {
|
|
packagesRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
}, []);
|
|
|
|
const openSupport = React.useCallback(() => {
|
|
if (typeof window !== 'undefined') {
|
|
window.location.href = `mailto:${supportEmail}`;
|
|
}
|
|
}, [supportEmail]);
|
|
|
|
const openPortal = React.useCallback(async () => {
|
|
if (portalBusy) {
|
|
return;
|
|
}
|
|
|
|
setPortalBusy(true);
|
|
try {
|
|
const { url } = await createTenantBillingPortalSession();
|
|
if (typeof window !== 'undefined') {
|
|
window.open(url, '_blank', 'noopener');
|
|
}
|
|
} catch (err) {
|
|
const message = getApiErrorMessage(err, t('billing.errors.portal', 'Konnte das Paddle-Portal nicht öffnen.'));
|
|
toast.error(message);
|
|
} finally {
|
|
setPortalBusy(false);
|
|
}
|
|
}, [portalBusy, t]);
|
|
|
|
const handleUpgrade = React.useCallback(async (pkg: TenantPackageSummary) => {
|
|
if (upgradeBusy) return;
|
|
setUpgradeBusy(pkg.package_id);
|
|
|
|
try {
|
|
const { checkout_url } = await createTenantPaddleCheckout(pkg.package_id, {
|
|
success_url: window.location.href,
|
|
return_url: window.location.href,
|
|
});
|
|
|
|
if (typeof window !== 'undefined') {
|
|
window.location.href = checkout_url;
|
|
}
|
|
} catch (err) {
|
|
const message = getApiErrorMessage(err, t('billing.errors.upgrade', 'Konnte Checkout nicht starten.'));
|
|
toast.error(message);
|
|
setUpgradeBusy(null);
|
|
}
|
|
}, [upgradeBusy, t]);
|
|
|
|
React.useEffect(() => {
|
|
void load();
|
|
}, [load]);
|
|
|
|
React.useEffect(() => {
|
|
if (!location.hash) return;
|
|
const hash = location.hash.replace('#', '');
|
|
const target = hash === 'invoices' ? invoicesRef.current : packagesRef.current;
|
|
if (target) {
|
|
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
}
|
|
}, [location.hash, loading]);
|
|
|
|
return (
|
|
<MobileShell
|
|
activeTab="profile"
|
|
title={t('billing.title', 'Billing & Packages')}
|
|
onBack={back}
|
|
headerActions={
|
|
<HeaderActionButton onPress={() => load()} ariaLabel={t('common.refresh', 'Refresh')}>
|
|
<RefreshCcw size={18} color={textStrong} />
|
|
</HeaderActionButton>
|
|
}
|
|
>
|
|
{error ? (
|
|
<MobileCard>
|
|
<Text fontWeight="700" color={danger}>
|
|
{error}
|
|
</Text>
|
|
<CTAButton label={t('billing.actions.refresh', 'Refresh')} tone="ghost" onPress={load} />
|
|
</MobileCard>
|
|
) : null}
|
|
|
|
<MobileCard space="$2" ref={packagesRef as any}>
|
|
<XStack alignItems="center" space="$2">
|
|
<Package size={18} color={textStrong} />
|
|
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
|
{t('billing.sections.packages.title', 'Packages')}
|
|
</Text>
|
|
</XStack>
|
|
<Text fontSize="$xs" color={muted}>
|
|
{t('billing.sections.packages.hint', 'Active package, limits, and history at a glance.')}
|
|
</Text>
|
|
<CTAButton
|
|
label={portalBusy ? t('billing.actions.portalBusy', 'Öffne Portal...') : t('billing.actions.portal', 'Manage in Paddle')}
|
|
onPress={openPortal}
|
|
disabled={portalBusy}
|
|
/>
|
|
{loading ? (
|
|
<Text fontSize="$sm" color={muted}>
|
|
{t('common.loading', 'Lädt...')}
|
|
</Text>
|
|
) : (
|
|
<YStack space="$2">
|
|
{activePackage ? (
|
|
<PackageCard
|
|
pkg={activePackage}
|
|
label={t('billing.sections.packages.card.statusActive', 'Aktiv')}
|
|
isActive
|
|
onOpenPortal={openPortal}
|
|
portalBusy={portalBusy}
|
|
/>
|
|
) : null}
|
|
{packages
|
|
.filter((pkg) => !activePackage || pkg.id !== activePackage.id)
|
|
.map((pkg) => (
|
|
<PackageCard
|
|
key={pkg.id}
|
|
pkg={pkg}
|
|
onUpgrade={() => handleUpgrade(pkg)}
|
|
upgradeBusy={upgradeBusy === pkg.package_id}
|
|
/>
|
|
))}
|
|
</YStack>
|
|
)}
|
|
</MobileCard>
|
|
|
|
<MobileCard space="$2" ref={invoicesRef as any}>
|
|
<XStack alignItems="center" space="$2">
|
|
<Receipt size={18} color={textStrong} />
|
|
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
|
{t('billing.sections.invoices.title', 'Invoices & Payments')}
|
|
</Text>
|
|
</XStack>
|
|
<Text fontSize="$xs" color={muted}>
|
|
{t('billing.sections.invoices.hint', 'Review transactions and download receipts.')}
|
|
</Text>
|
|
{loading ? (
|
|
<Text fontSize="$sm" color={muted}>
|
|
{t('common.loading', 'Lädt...')}
|
|
</Text>
|
|
) : transactions.length === 0 ? (
|
|
<YStack space="$2">
|
|
<Text fontSize="$sm" color={text}>
|
|
{t('billing.sections.invoices.empty', 'Keine Zahlungen gefunden.')}
|
|
</Text>
|
|
<CTAButton label={t('billing.actions.openPackages', 'Open packages')} onPress={scrollToPackages} />
|
|
<CTAButton label={t('billing.actions.contactSupport', 'Contact support')} tone="ghost" onPress={openSupport} />
|
|
</YStack>
|
|
) : (
|
|
<YStack space="$1.5">
|
|
{transactions.slice(0, 8).map((trx) => (
|
|
<XStack key={trx.id} alignItems="center" justifyContent="space-between" borderBottomWidth={1} borderColor={border} paddingVertical="$1.5">
|
|
<YStack>
|
|
<Text fontSize="$sm" color={textStrong} fontWeight="700">
|
|
{trx.status ?? '—'}
|
|
</Text>
|
|
<Text fontSize="$xs" color={muted}>
|
|
{formatDate(trx.created_at)}
|
|
</Text>
|
|
{trx.origin ? (
|
|
<Text fontSize="$xs" color={subtle}>
|
|
{trx.origin}
|
|
</Text>
|
|
) : null}
|
|
</YStack>
|
|
<YStack alignItems="flex-end">
|
|
<Text fontSize="$sm" color={textStrong} fontWeight="700">
|
|
{formatAmount(trx.amount, trx.currency)}
|
|
</Text>
|
|
{trx.tax ? (
|
|
<Text fontSize="$xs" color={muted}>
|
|
{t('billing.sections.transactions.labels.tax', { value: formatAmount(trx.tax, trx.currency) })}
|
|
</Text>
|
|
) : null}
|
|
{trx.receipt_url ? (
|
|
<a href={trx.receipt_url} target="_blank" rel="noreferrer" style={{ fontSize: 12, color: primary }}>
|
|
{t('billing.sections.transactions.labels.receipt', 'Beleg')}
|
|
</a>
|
|
) : null}
|
|
</YStack>
|
|
</XStack>
|
|
))}
|
|
</YStack>
|
|
)}
|
|
{null}
|
|
</MobileCard>
|
|
|
|
<MobileCard space="$2">
|
|
<XStack alignItems="center" space="$2">
|
|
<Sparkles size={18} color={textStrong} />
|
|
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
|
{t('billing.sections.addOns.title', 'Add-ons')}
|
|
</Text>
|
|
</XStack>
|
|
<Text fontSize="$xs" color={muted}>
|
|
{t('billing.sections.addOns.hint', 'Track extra photo, guest, or time bundles per event.')}
|
|
</Text>
|
|
{loading ? (
|
|
<Text fontSize="$sm" color={muted}>
|
|
{t('common.loading', 'Lädt...')}
|
|
</Text>
|
|
) : addons.length === 0 ? (
|
|
<Text fontSize="$sm" color={text}>
|
|
{t('billing.sections.addOns.empty', 'Keine Add-ons gebucht.')}
|
|
</Text>
|
|
) : (
|
|
<YStack space="$1.5">
|
|
{addons.slice(0, 8).map((addon) => (
|
|
<AddonRow key={addon.id} addon={addon} />
|
|
))}
|
|
</YStack>
|
|
)}
|
|
{null}
|
|
</MobileCard>
|
|
</MobileShell>
|
|
);
|
|
}
|
|
|
|
function PackageCard({
|
|
pkg,
|
|
label,
|
|
isActive = false,
|
|
onOpenPortal,
|
|
portalBusy,
|
|
onUpgrade,
|
|
upgradeBusy,
|
|
}: {
|
|
pkg: TenantPackageSummary;
|
|
label?: string;
|
|
isActive?: boolean;
|
|
onOpenPortal?: () => void;
|
|
portalBusy?: boolean;
|
|
onUpgrade?: () => void;
|
|
upgradeBusy?: boolean;
|
|
}) {
|
|
const { t } = useTranslation('management');
|
|
const { border, primary, accentSoft, textStrong, muted } = useAdminTheme();
|
|
const limits = (pkg.package_limits ?? null) as Record<string, unknown> | null;
|
|
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 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,
|
|
limitMaxEvents,
|
|
t
|
|
);
|
|
return (
|
|
<MobileCard
|
|
borderColor={isActive ? primary : border}
|
|
borderWidth={isActive ? 2 : 1}
|
|
backgroundColor={isActive ? accentSoft : undefined}
|
|
space="$2"
|
|
>
|
|
<XStack alignItems="center" justifyContent="space-between">
|
|
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
|
{pkg.package_name ?? t('mobileBilling.packageFallback', 'Package')}
|
|
</Text>
|
|
{label ? <PillBadge tone="success">{label}</PillBadge> : null}
|
|
</XStack>
|
|
{expires ? (
|
|
<Text fontSize="$xs" color={muted}>
|
|
{expires}
|
|
</Text>
|
|
) : null}
|
|
<XStack space="$2" marginTop="$2" flexWrap="wrap">
|
|
<PillBadge tone="muted">{remainingText}</PillBadge>
|
|
{pkg.price !== null && pkg.price !== undefined ? (
|
|
<PillBadge tone="muted">{formatAmount(pkg.price, pkg.currency ?? 'EUR')}</PillBadge>
|
|
) : null}
|
|
{renderFeatureBadge(pkg, t, 'branding_allowed', t('billing.features.branding', 'Branding'))}
|
|
{renderFeatureBadge(pkg, t, 'watermark_allowed', t('billing.features.watermark', 'Watermark'))}
|
|
</XStack>
|
|
{eventUsageText ? (
|
|
<Text fontSize="$xs" color={muted}>
|
|
{eventUsageText}
|
|
</Text>
|
|
) : null}
|
|
{limitEntries.length ? (
|
|
<YStack space="$1.5" marginTop="$2">
|
|
<Text fontSize="$xs" color={muted}>
|
|
{t('mobileBilling.details.limitsTitle', 'Limits')}
|
|
</Text>
|
|
{limitEntries.map((entry) => (
|
|
<XStack key={entry.key} alignItems="center" justifyContent="space-between">
|
|
<Text fontSize="$xs" color={muted}>
|
|
{entry.label}
|
|
</Text>
|
|
<Text fontSize="$xs" color={textStrong} fontWeight="700">
|
|
{entry.value}
|
|
</Text>
|
|
</XStack>
|
|
))}
|
|
</YStack>
|
|
) : null}
|
|
{featureKeys.length ? (
|
|
<YStack space="$1.5" marginTop="$2">
|
|
<Text fontSize="$xs" color={muted}>
|
|
{t('mobileBilling.details.featuresTitle', 'Features')}
|
|
</Text>
|
|
{featureKeys.map((feature) => (
|
|
<XStack key={feature} alignItems="center" space="$2">
|
|
<Sparkles size={14} color={primary} />
|
|
<Text fontSize="$xs" color={textStrong}>
|
|
{getPackageFeatureLabel(feature, t)}
|
|
</Text>
|
|
</XStack>
|
|
))}
|
|
</YStack>
|
|
) : null}
|
|
{usageMetrics.length ? (
|
|
<YStack space="$2" marginTop="$2">
|
|
{usageMetrics.map((metric) => (
|
|
<UsageBar key={metric.key} metric={metric} />
|
|
))}
|
|
</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}
|
|
{!isActive && onUpgrade ? (
|
|
<CTAButton
|
|
label={upgradeBusy ? t('billing.actions.upgradeBusy', 'Einen Moment...') : t('billing.actions.upgrade', 'Buy / Upgrade')}
|
|
onPress={onUpgrade}
|
|
disabled={upgradeBusy}
|
|
tone="primary"
|
|
/>
|
|
) : null}
|
|
</MobileCard>
|
|
);
|
|
}
|
|
|
|
function renderFeatureBadge(pkg: TenantPackageSummary, t: any, key: string, label: string) {
|
|
const value = (pkg.package_limits as any)?.[key] ?? (pkg as any)[key];
|
|
if (value === undefined || value === null) return null;
|
|
const enabled = value !== false;
|
|
return <PillBadge tone={enabled ? 'success' : 'muted'}>{enabled ? label : `${label} off`}</PillBadge>;
|
|
}
|
|
|
|
function UsageBar({ metric }: { metric: PackageUsageMetric }) {
|
|
const { t } = useTranslation('management');
|
|
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.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">
|
|
<XStack alignItems="center" justifyContent="space-between">
|
|
<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 ? fillColor : subtle} />
|
|
</YStack>
|
|
{remainingText ? (
|
|
<Text fontSize="$xs" color={muted}>
|
|
{remainingText}
|
|
</Text>
|
|
) : null}
|
|
</YStack>
|
|
);
|
|
}
|
|
|
|
function formatAmount(value: number | null | undefined, currency: string | null | undefined): string {
|
|
if (value === null || value === undefined) {
|
|
return '—';
|
|
}
|
|
const cur = currency ?? 'EUR';
|
|
try {
|
|
return new Intl.NumberFormat(undefined, { style: 'currency', currency: cur }).format(value);
|
|
} catch {
|
|
return `${value} ${cur}`;
|
|
}
|
|
}
|
|
|
|
function AddonRow({ addon }: { addon: TenantAddonHistoryEntry }) {
|
|
const { t } = useTranslation('management');
|
|
const navigate = useNavigate();
|
|
const { border, textStrong, text, muted, subtle, primary } = useAdminTheme();
|
|
const labels: Record<TenantAddonHistoryEntry['status'], { tone: 'success' | 'warning' | 'muted'; text: string }> = {
|
|
completed: { tone: 'success', text: t('mobileBilling.status.completed', 'Completed') },
|
|
pending: { tone: 'warning', text: t('mobileBilling.status.pending', 'Pending') },
|
|
failed: { tone: 'muted', text: t('mobileBilling.status.failed', 'Failed') },
|
|
};
|
|
const status = labels[addon.status];
|
|
const eventName =
|
|
(addon.event?.name && typeof addon.event.name === 'string' && addon.event.name) ||
|
|
(addon.event?.name && typeof addon.event.name === 'object' ? addon.event.name?.en ?? addon.event.name?.de ?? Object.values(addon.event.name)[0] : null) ||
|
|
null;
|
|
const eventPath = addon.event?.slug ? ADMIN_EVENT_VIEW_PATH(addon.event.slug) : null;
|
|
const hasImpact = Boolean(addon.extra_photos || addon.extra_guests || addon.extra_gallery_days);
|
|
const impactBadges = hasImpact ? (
|
|
<XStack space="$2" marginTop="$1.5" flexWrap="wrap">
|
|
{addon.extra_photos ? (
|
|
<PillBadge tone="muted">{t('mobileBilling.extra.photos', '+{{count}} photos', { count: addon.extra_photos })}</PillBadge>
|
|
) : null}
|
|
{addon.extra_guests ? (
|
|
<PillBadge tone="muted">{t('mobileBilling.extra.guests', '+{{count}} guests', { count: addon.extra_guests })}</PillBadge>
|
|
) : null}
|
|
{addon.extra_gallery_days ? (
|
|
<PillBadge tone="muted">{t('mobileBilling.extra.days', '+{{count}} days', { count: addon.extra_gallery_days })}</PillBadge>
|
|
) : null}
|
|
</XStack>
|
|
) : null;
|
|
|
|
return (
|
|
<MobileCard borderColor={border} padding="$3" space="$1.5">
|
|
<XStack alignItems="center" justifyContent="space-between">
|
|
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
|
{addon.label ?? addon.addon_key}
|
|
</Text>
|
|
<PillBadge tone={status.tone}>{status.text}</PillBadge>
|
|
</XStack>
|
|
{eventName ? (
|
|
eventPath ? (
|
|
<Pressable onPress={() => navigate(eventPath)}>
|
|
<XStack alignItems="center" justifyContent="space-between">
|
|
<Text fontSize="$xs" color={textStrong} fontWeight="600">
|
|
{eventName}
|
|
</Text>
|
|
<Text fontSize="$xs" color={primary} fontWeight="700">
|
|
{t('mobileBilling.openEvent', 'Open event')}
|
|
</Text>
|
|
</XStack>
|
|
</Pressable>
|
|
) : (
|
|
<Text fontSize="$xs" color={subtle}>
|
|
{eventName}
|
|
</Text>
|
|
)
|
|
) : null}
|
|
{impactBadges}
|
|
<Text fontSize="$sm" color={text} marginTop="$1.5">
|
|
{formatAmount(addon.amount, addon.currency)}
|
|
</Text>
|
|
<Text fontSize="$xs" color={muted}>
|
|
{formatDate(addon.purchased_at)}
|
|
</Text>
|
|
</MobileCard>
|
|
);
|
|
}
|
|
function formatDate(value: string | null | undefined): string {
|
|
if (!value) return '—';
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.getTime())) return '—';
|
|
return date.toLocaleDateString(undefined, { day: '2-digit', month: 'short', year: 'numeric' });
|
|
}
|