Files
fotospiel-app/resources/js/admin/pages/BillingPage.tsx
2025-11-25 13:03:42 +01:00

828 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @ts-nocheck
import React from 'react';
import { AlertTriangle, Loader2, RefreshCw, Sparkles, ArrowUpRight } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { AdminLayout } from '../components/AdminLayout';
import {
getTenantPackagesOverview,
getTenantPaddleTransactions,
getTenantAddonHistory,
PaddleTransactionSummary,
TenantAddonHistoryEntry,
TenantPackageSummary,
PaginationMeta,
} from '../api';
import { isAuthError } from '../auth/tokens';
import { FrostedSurface, SectionCard, SectionHeader } from '../components/tenant';
type PackageWarning = { id: string; tone: 'warning' | 'danger'; message: string };
export default function BillingPage() {
const { t, i18n } = useTranslation(['management', 'dashboard']);
const locale = React.useMemo(
() => (i18n.language?.startsWith('en') ? 'en-GB' : 'de-DE'),
[i18n.language]
);
const [packages, setPackages] = React.useState<TenantPackageSummary[]>([]);
const [activePackage, setActivePackage] = React.useState<TenantPackageSummary | null>(null);
const [transactions, setTransactions] = React.useState<PaddleTransactionSummary[]>([]);
const [transactionCursor, setTransactionCursor] = React.useState<string | null>(null);
const [transactionsHasMore, setTransactionsHasMore] = React.useState(false);
const [transactionsLoading, setTransactionsLoading] = React.useState(false);
const [addonHistory, setAddonHistory] = React.useState<TenantAddonHistoryEntry[]>([]);
const [addonMeta, setAddonMeta] = React.useState<PaginationMeta | null>(null);
const [addonsLoading, setAddonsLoading] = React.useState(false);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const formatDate = React.useCallback(
(value: string | null | undefined) => {
if (!value) return '--';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '--';
return date.toLocaleDateString(locale, { day: '2-digit', month: 'short', year: 'numeric' });
},
[locale]
);
const formatCurrency = React.useCallback(
(value: number | null | undefined, currency = 'EUR') => {
if (value === null || value === undefined) return '--';
return new Intl.NumberFormat(locale, { style: 'currency', currency }).format(value);
},
[locale]
);
const resolveEventName = React.useCallback(
(event: TenantAddonHistoryEntry['event']) => {
const fallback = t('billing.sections.addOns.table.eventFallback', 'Event removed');
if (!event) {
return fallback;
}
if (typeof event.name === 'string' && event.name.trim().length > 0) {
return event.name;
}
if (event.name && typeof event.name === 'object') {
const lang = i18n.language?.split('-')[0] ?? 'de';
return (
event.name[lang] ??
event.name.de ??
event.name.en ??
Object.values(event.name)[0] ??
fallback
);
}
return fallback;
},
[i18n.language, t]
);
const packageLabels = React.useMemo(
() => ({
statusActive: t('billing.sections.packages.card.statusActive'),
statusInactive: t('billing.sections.packages.card.statusInactive'),
used: t('billing.sections.packages.card.used'),
available: t('billing.sections.packages.card.available'),
expires: t('billing.sections.packages.card.expires'),
}),
[t]
);
const loadAll = React.useCallback(async (force = false) => {
setLoading(true);
setError(null);
try {
const [packagesResult, paddleTransactions, addonHistoryResult] = await Promise.all([
getTenantPackagesOverview(force ? { force: true } : undefined),
getTenantPaddleTransactions().catch((err) => {
console.warn('Failed to load Paddle transactions', err);
return { data: [] as PaddleTransactionSummary[], nextCursor: null, hasMore: false };
}),
getTenantAddonHistory().catch((err) => {
console.warn('Failed to load add-on history', err);
return { data: [] as TenantAddonHistoryEntry[], meta: { current_page: 1, last_page: 1, per_page: 25, total: 0 } };
}),
]);
setPackages(packagesResult.packages);
setActivePackage(packagesResult.activePackage);
setTransactions(paddleTransactions.data);
setTransactionCursor(paddleTransactions.nextCursor);
setTransactionsHasMore(paddleTransactions.hasMore);
setAddonHistory(addonHistoryResult.data);
setAddonMeta(addonHistoryResult.meta);
} catch (err) {
if (!isAuthError(err)) {
setError(t('billing.errors.load'));
}
} finally {
setLoading(false);
}
}, [t]);
const loadMoreTransactions = React.useCallback(async () => {
if (!transactionsHasMore || transactionsLoading || !transactionCursor) {
return;
}
setTransactionsLoading(true);
try {
const result = await getTenantPaddleTransactions(transactionCursor);
setTransactions((current) => [...current, ...result.data]);
setTransactionCursor(result.nextCursor);
setTransactionsHasMore(result.hasMore && Boolean(result.nextCursor));
} catch (error) {
console.warn('Failed to load additional Paddle transactions', error);
setTransactionsHasMore(false);
} finally {
setTransactionsLoading(false);
}
}, [transactionCursor, transactionsHasMore, transactionsLoading]);
const loadMoreAddons = React.useCallback(async () => {
if (addonsLoading || !addonMeta || addonMeta.current_page >= addonMeta.last_page) {
return;
}
setAddonsLoading(true);
try {
const nextPage = addonMeta.current_page + 1;
const result = await getTenantAddonHistory(nextPage);
setAddonHistory((current) => [...current, ...result.data]);
setAddonMeta(result.meta);
} catch (error) {
console.warn('Failed to load additional add-on history', error);
} finally {
setAddonsLoading(false);
}
}, [addonMeta, addonsLoading]);
React.useEffect(() => {
void loadAll();
}, [loadAll]);
const hasMoreAddons = React.useMemo(() => {
if (!addonMeta) {
return false;
}
return addonMeta.current_page < addonMeta.last_page;
}, [addonMeta]);
const computedRemainingEvents = React.useMemo(() => {
if (!activePackage) {
return null;
}
const used = activePackage.used_events ?? 0;
if (activePackage.remaining_events !== null && activePackage.remaining_events !== undefined) {
return activePackage.remaining_events;
}
const allowance = activePackage.package_limits?.max_events_per_year ?? 1;
return Math.max(0, allowance - used);
}, [activePackage]);
const normalizedActivePackage = React.useMemo(() => {
if (!activePackage) return null;
return {
...activePackage,
remaining_events: computedRemainingEvents ?? activePackage.remaining_events,
};
}, [activePackage, computedRemainingEvents]);
const topWarning = React.useMemo(() => {
const warnings = buildPackageWarnings(
normalizedActivePackage,
(key, options) => t(key, options),
formatDate,
'billing.sections.overview.warnings',
);
return warnings[0];
}, [formatDate, normalizedActivePackage, t]);
const activeWarnings = React.useMemo(
() => buildPackageWarnings(normalizedActivePackage, t, formatDate, 'billing.sections.overview.warnings'),
[normalizedActivePackage, t, formatDate],
);
const billingStats = React.useMemo(() => {
if (!activePackage) {
return [] as const;
}
const used = activePackage.used_events ?? 0;
const remaining = computedRemainingEvents ?? 0;
return [
{
key: 'events',
label: t('billing.stats.events.label', 'Genutzte Events'),
value: used,
helper: t('billing.stats.events.helper', { count: remaining }),
tone: 'amber' as const,
},
];
}, [activePackage, computedRemainingEvents, t]);
return (
<AdminLayout title={t('billing.title')} subtitle={t('billing.subtitle')}>
{error && (
<Alert variant="destructive">
<AlertTitle>{t('dashboard:alerts.errorTitle')}</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{loading ? (
<BillingSkeleton />
) : (
<>
<BillingWarningBanner warnings={activeWarnings} t={t} />
<SectionCard className="mt-6 space-y-5">
<SectionHeader
eyebrow={t('billing.sections.overview.badge', 'Aktuelles Paket')}
title={t('billing.sections.overview.title')}
description={t('billing.sections.overview.description')}
endSlot={(
<Badge className={activePackage ? 'bg-pink-500/10 text-pink-700 dark:bg-pink-500/20 dark:text-pink-200' : 'bg-slate-200 text-slate-700 dark:bg-slate-800 dark:text-slate-200'}>
{activePackage ? activePackage.package_name : t('billing.sections.overview.emptyBadge')}
</Badge>
)}
/>
{activePackage ? (
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<InfoCard
label={t('billing.sections.overview.cards.package.label')}
value={activePackage.package_name}
tone="pink"
helper={t('billing.sections.overview.cards.package.helper')}
/>
<InfoCard
label={t('billing.sections.overview.cards.used.label')}
value={activePackage.used_events ?? 0}
tone="amber"
helper={t('billing.sections.overview.cards.used.helper', {
count: computedRemainingEvents ?? 0,
})}
/>
<InfoCard
label={t('billing.sections.overview.cards.price.label')}
value={formatCurrency(activePackage.price ?? null, activePackage.currency ?? 'EUR')}
tone="sky"
helper={activePackage.currency ?? 'EUR'}
/>
<InfoCard
label={t('billing.sections.overview.cards.expires.label')}
value={formatDate(activePackage.expires_at)}
tone="emerald"
helper={t('billing.sections.overview.cards.expires.helper')}
/>
</div>
</div>
) : (
<EmptyState message={t('billing.sections.overview.empty')} />
)}
</SectionCard>
<SectionCard className="space-y-4">
<SectionHeader
eyebrow={t('billing.sections.packages.badge', 'Pakete')}
title={t('billing.sections.packages.title')}
description={t('billing.sections.packages.description')}
/>
<div className="space-y-3">
{packages.length === 0 ? (
<EmptyState message={t('billing.sections.packages.empty')} />
) : (
packages.map((pkg) => {
const warnings = buildPackageWarnings(pkg, t, formatDate, 'billing.sections.packages.card.warnings');
return (
<PackageCard
key={pkg.id}
pkg={pkg}
isActive={Boolean(pkg.active)}
labels={packageLabels}
formatDate={formatDate}
formatCurrency={formatCurrency}
warnings={warnings}
/>
);
})
)}
</div>
</SectionCard>
<SectionCard className="space-y-4">
<SectionHeader
eyebrow={t('billing.sections.addOns.badge', 'Add-ons')}
title={t('billing.sections.addOns.title')}
description={t('billing.sections.addOns.description')}
/>
{addonHistory.length === 0 ? (
<EmptyState message={t('billing.sections.addOns.empty')} />
) : (
<AddonHistoryTable
items={addonHistory}
formatCurrency={formatCurrency}
formatDate={formatDate}
resolveEventName={resolveEventName}
locale={locale}
t={t}
/>
)}
{hasMoreAddons && (
<Button variant="outline" onClick={() => void loadMoreAddons()} disabled={addonsLoading}>
{addonsLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('billing.sections.addOns.loadingMore', 'Loading add-ons...')}
</>
) : (
t('billing.sections.addOns.loadMore', 'Load more add-ons')
)}
</Button>
)}
</SectionCard>
<SectionCard className="space-y-4">
<SectionHeader
eyebrow={t('billing.sections.transactions.badge', 'Transaktionen')}
title={t('billing.sections.transactions.title')}
description={t('billing.sections.transactions.description')}
/>
<div className="space-y-3">
{transactions.length === 0 ? (
<EmptyState message={t('billing.sections.transactions.empty')} />
) : (
<TransactionsTable
items={transactions}
formatCurrency={formatCurrency}
formatDate={formatDate}
locale={locale}
t={t}
/>
)}
{transactionsHasMore && (
<Button
variant="outline"
onClick={() => void loadMoreTransactions()}
disabled={transactionsLoading}
>
{transactionsLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('billing.sections.transactions.loadingMore')}
</>
) : (
t('billing.sections.transactions.loadMore')
)}
</Button>
)}
</div>
</SectionCard>
</>
)}
</AdminLayout>
);
}
function AddonHistoryTable({
items,
formatCurrency,
formatDate,
resolveEventName,
locale,
t,
}: {
items: TenantAddonHistoryEntry[];
formatCurrency: (value: number | null | undefined, currency?: string) => string;
formatDate: (value: string | null | undefined) => string;
resolveEventName: (event: TenantAddonHistoryEntry['event']) => string;
locale: string;
t: (key: string, options?: Record<string, unknown>) => string;
}) {
const extrasLabel = (key: 'photos' | 'guests' | 'gallery', count: number) =>
t(`billing.sections.addOns.extras.${key}`, { count });
return (
<FrostedSurface className="overflow-x-auto border border-slate-200/60 p-0 dark:border-slate-800/70">
<table className="min-w-full divide-y divide-slate-200 text-sm dark:divide-slate-800">
<thead className="bg-slate-50/60 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 dark:bg-slate-900/20 dark:text-slate-400">
<tr>
<th className="px-4 py-3">{t('billing.sections.addOns.table.addon')}</th>
<th className="px-4 py-3">{t('billing.sections.addOns.table.event')}</th>
<th className="px-4 py-3">{t('billing.sections.addOns.table.amount')}</th>
<th className="px-4 py-3">{t('billing.sections.addOns.table.status')}</th>
<th className="px-4 py-3">{t('billing.sections.addOns.table.purchased')}</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 dark:divide-slate-800/70">
{items.map((item) => {
const extras: string[] = [];
if (item.extra_photos > 0) {
extras.push(extrasLabel('photos', item.extra_photos));
}
if (item.extra_guests > 0) {
extras.push(extrasLabel('guests', item.extra_guests));
}
if (item.extra_gallery_days > 0) {
extras.push(extrasLabel('gallery', item.extra_gallery_days));
}
const purchasedLabel = item.purchased_at
? new Date(item.purchased_at).toLocaleString(locale, {
year: 'numeric',
month: 'short',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
: formatDate(item.purchased_at);
const statusKey = `billing.sections.addOns.status.${item.status}`;
const statusLabel = t(statusKey, { defaultValue: item.status });
const statusTone: Record<string, string> = {
completed: 'bg-emerald-500/15 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-200',
pending: 'bg-amber-500/15 text-amber-700 dark:bg-amber-500/20 dark:text-amber-200',
failed: 'bg-rose-500/15 text-rose-700 dark:bg-rose-500/20 dark:text-rose-200',
};
return (
<tr key={item.id} className="bg-white even:bg-slate-50/40 dark:bg-slate-950/50 dark:even:bg-slate-900/40">
<td className="px-4 py-3 align-top">
<div className="flex items-center gap-2 text-slate-900 dark:text-slate-100">
<span className="font-semibold">{item.label ?? item.addon_key}</span>
{item.quantity > 1 ? (
<Badge variant="outline" className="border-slate-200/70 text-[11px] font-medium dark:border-slate-700">
×{item.quantity}
</Badge>
) : null}
</div>
{extras.length > 0 ? (
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">{extras.join(' · ')}</p>
) : null}
</td>
<td className="px-4 py-3 align-top">
<p className="font-medium text-slate-800 dark:text-slate-200">{resolveEventName(item.event)}</p>
{item.event?.slug ? (
<p className="text-xs text-slate-500 dark:text-slate-500">{item.event.slug}</p>
) : null}
</td>
<td className="px-4 py-3 align-top">
<p className="font-semibold text-slate-900 dark:text-slate-100">
{formatCurrency(item.amount, item.currency ?? 'EUR')}
</p>
{item.receipt_url ? (
<a
href={item.receipt_url}
target="_blank"
rel="noreferrer"
className="text-xs text-sky-600 hover:text-sky-700 dark:text-sky-300 dark:hover:text-sky-200"
>
{t('billing.sections.transactions.labels.receipt')}
</a>
) : null}
</td>
<td className="px-4 py-3 align-top">
<Badge className={statusTone[item.status] ?? 'bg-slate-200 text-slate-700 dark:bg-slate-800 dark:text-slate-200'}>
{statusLabel}
</Badge>
</td>
<td className="px-4 py-3 align-top text-sm text-slate-600 dark:text-slate-300">{purchasedLabel}</td>
</tr>
);
})}
</tbody>
</table>
</FrostedSurface>
);
}
function TransactionsTable({
items,
formatCurrency,
formatDate,
locale,
t,
}: {
items: PaddleTransactionSummary[];
formatCurrency: (value: number | null | undefined, currency?: string) => string;
formatDate: (value: string | null | undefined) => string;
locale: string;
t: (key: string, options?: Record<string, unknown>) => string;
}) {
const statusTone: Record<string, string> = {
completed: 'bg-emerald-500/15 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-200',
processing: 'bg-amber-500/15 text-amber-700 dark:bg-amber-500/20 dark:text-amber-200',
failed: 'bg-rose-500/15 text-rose-700 dark:bg-rose-500/20 dark:text-rose-200',
cancelled: 'bg-slate-200 text-slate-700 dark:bg-slate-700/40 dark:text-slate-200',
};
return (
<FrostedSurface className="overflow-x-auto border border-slate-200/60 p-0 dark:border-slate-800/70">
<table className="min-w-full divide-y divide-slate-200 text-sm dark:divide-slate-800">
<thead className="bg-slate-50/60 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 dark:bg-slate-900/20 dark:text-slate-400">
<tr>
<th className="px-4 py-3">{t('billing.sections.transactions.table.transaction', 'Transaktion')}</th>
<th className="px-4 py-3">{t('billing.sections.transactions.table.amount', 'Betrag')}</th>
<th className="px-4 py-3">{t('billing.sections.transactions.table.status', 'Status')}</th>
<th className="px-4 py-3">{t('billing.sections.transactions.table.date', 'Datum')}</th>
<th className="px-4 py-3">{t('billing.sections.transactions.table.origin', 'Herkunft')}</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 dark:divide-slate-800/70">
{items.map((transaction) => {
const amount = transaction.grand_total ?? transaction.amount ?? null;
const statusKey = transaction.status ? `billing.sections.transactions.status.${transaction.status}` : 'billing.sections.transactions.status.unknown';
const statusLabel = t(statusKey, { defaultValue: transaction.status ?? 'Unknown' });
const createdAt = transaction.created_at
? new Date(transaction.created_at).toLocaleString(locale, {
year: 'numeric',
month: 'short',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
: formatDate(transaction.created_at);
return (
<tr key={transaction.id ?? Math.random().toString(36).slice(2)} className="bg-white even:bg-slate-50/40 dark:bg-slate-950/50 dark:even:bg-slate-900/40">
<td className="px-4 py-3 align-top">
<p className="font-semibold text-slate-900 dark:text-slate-100">
{t('billing.sections.transactions.labels.transactionId', { id: transaction.id ?? '—' })}
</p>
{transaction.checkout_id ? (
<p className="text-xs text-slate-500 dark:text-slate-500">
{t('billing.sections.transactions.labels.checkoutId', { id: transaction.checkout_id })}
</p>
) : null}
</td>
<td className="px-4 py-3 align-top">
<p className="font-semibold text-slate-900 dark:text-slate-100">{formatCurrency(amount, transaction.currency ?? 'EUR')}</p>
{transaction.tax ? (
<p className="text-xs text-slate-500 dark:text-slate-400">
{t('billing.sections.transactions.labels.tax', { value: formatCurrency(transaction.tax, transaction.currency ?? 'EUR') })}
</p>
) : null}
{transaction.receipt_url ? (
<a
href={transaction.receipt_url}
target="_blank"
rel="noreferrer"
className="text-xs text-sky-600 hover:text-sky-700 dark:text-sky-300 dark:hover:text-sky-200"
>
{t('billing.sections.transactions.labels.receipt', 'Beleg ansehen')}
</a>
) : null}
</td>
<td className="px-4 py-3 align-top">
<Badge className={statusTone[transaction.status ?? ''] ?? 'bg-slate-200 text-slate-700 dark:bg-slate-800 dark:text-slate-200'}>
{statusLabel}
</Badge>
</td>
<td className="px-4 py-3 align-top text-sm text-slate-600 dark:text-slate-300">{createdAt}</td>
<td className="px-4 py-3 align-top">
<p className="text-sm text-slate-700 dark:text-slate-200">{transaction.origin ?? '—'}</p>
</td>
</tr>
);
})}
</tbody>
</table>
</FrostedSurface>
);
}
function BillingStatGrid({
stats,
}: {
stats: Array<{ key: string; label: string; value: string | number | null | undefined; helper?: string; tone: 'pink' | 'amber' | 'sky' | 'emerald' }>;
}) {
return (
<div className="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{stats.map((stat) => (
<InfoCard key={stat.key} label={stat.label} value={stat.value} helper={stat.helper} tone={stat.tone} />
))}
</div>
);
}
function BillingWarningBanner({ warnings, t }: { warnings: PackageWarning[]; t: (key: string, options?: Record<string, unknown>) => string }) {
if (!warnings.length) {
return null;
}
return (
<Alert className="mt-6 border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-500/40 dark:bg-amber-500/20 dark:text-amber-100">
<AlertTitle className="flex items-center gap-2 text-sm font-semibold">
<AlertTriangle className="h-4 w-4" /> {t('billingWarning.title', 'Handlungsbedarf')}
</AlertTitle>
<AlertDescription className="mt-2 space-y-2 text-sm">
<p>{t('billingWarning.description', 'Paketwarnungen und Limits, die du im Blick behalten solltest.')}</p>
<ul className="list-disc space-y-1 pl-4 text-xs">
{warnings.map((warning) => (
<li key={warning.id}>{warning.message}</li>
))}
</ul>
</AlertDescription>
</Alert>
);
}
function InfoCard({
label,
value,
helper,
tone,
}: {
label: string;
value: string | number | null | undefined;
helper?: string;
tone: 'pink' | 'amber' | 'sky' | 'emerald';
}) {
const toneBorders: Record<'pink' | 'amber' | 'sky' | 'emerald', string> = {
pink: 'border-pink-200/60 shadow-rose-200/30',
amber: 'border-amber-200/60 shadow-amber-200/30',
sky: 'border-sky-200/60 shadow-sky-200/30',
emerald: 'border-emerald-200/60 shadow-emerald-200/30',
} as const;
return (
<FrostedSurface className={`border ${toneBorders[tone]} p-5 text-slate-900 shadow-md transition-colors duration-200 dark:text-slate-100`}>
<span className="text-xs uppercase tracking-wide text-slate-500 dark:text-slate-400">{label}</span>
<div className="mt-3 text-xl font-semibold">{value ?? '--'}</div>
{helper ? <p className="mt-2 text-xs text-slate-600 dark:text-slate-400">{helper}</p> : null}
</FrostedSurface>
);
}
function PackageCard({
pkg,
isActive,
labels,
formatDate,
formatCurrency,
warnings = [],
}: {
pkg: TenantPackageSummary;
isActive: boolean;
labels: {
statusActive: string;
statusInactive: string;
used: string;
available: string;
expires: string;
};
formatDate: (value: string | null | undefined) => string;
formatCurrency: (value: number | null | undefined, currency?: string) => string;
warnings?: PackageWarning[];
}) {
return (
<FrostedSurface className={`space-y-4 border ${isActive ? 'border-amber-200/60 shadow-amber-200/20' : 'border-slate-200/60 shadow-slate-200/20'} p-5 text-slate-900 dark:text-slate-100`}>
{warnings.length > 0 && (
<div className="mb-3 space-y-2">
{warnings.map((warning) => (
<Alert
key={warning.id}
variant={warning.tone === 'danger' ? 'destructive' : 'default'}
className={warning.tone === 'warning' ? 'border-amber-400/40 bg-amber-50 text-amber-900 dark:border-amber-500/50 dark:bg-amber-500/15 dark:text-amber-200' : 'dark:border-slate-800/70 dark:bg-slate-950/80'}
>
<AlertDescription className="flex items-center gap-2 text-xs">
<AlertTriangle className="h-4 w-4" />
{warning.message}
</AlertDescription>
</Alert>
))}
</div>
)}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<h3 className="text-sm font-semibold text-slate-900 dark:text-slate-100">{pkg.package_name}</h3>
<p className="text-xs text-slate-600 dark:text-slate-400">
{formatDate(pkg.purchased_at)} · {formatCurrency(pkg.price, pkg.currency ?? 'EUR')}
</p>
</div>
<Badge className={isActive ? 'bg-amber-500/15 text-amber-700 dark:bg-amber-500/20 dark:text-amber-200' : 'bg-slate-200 text-slate-700 dark:bg-slate-800 dark:text-slate-200'}>
{isActive ? labels.statusActive : labels.statusInactive}
</Badge>
</div>
<Separator className="my-3 dark:border-slate-800/70" />
<div className="grid gap-2 text-xs text-slate-600 dark:text-slate-400 sm:grid-cols-3">
<span>
{labels.used}: {pkg.used_events}
</span>
<span>
{labels.available}: {pkg.remaining_events ?? '--'}
</span>
<span>
{labels.expires}: {formatDate(pkg.expires_at)}
</span>
</div>
</FrostedSurface>
);
}
function EmptyState({ message }: { message: string }) {
return (
<FrostedSurface className="flex flex-col items-center justify-center gap-3 border border-dashed border-slate-200/70 p-8 text-center shadow-inner dark:border-slate-700/60">
<div className="rounded-full bg-pink-100 p-3 text-pink-600 shadow-inner shadow-pink-200/80">
<Sparkles className="h-5 w-5" />
</div>
<p className="text-sm text-slate-600 dark:text-slate-400">{message}</p>
</FrostedSurface>
);
}
function buildPackageWarnings(
pkg: TenantPackageSummary | null | undefined,
translate: (key: string, options?: Record<string, unknown>) => string,
formatDate: (value: string | null | undefined) => string,
keyPrefix: string,
): PackageWarning[] {
if (!pkg) {
return [];
}
const warnings: PackageWarning[] = [];
const remaining = typeof pkg.remaining_events === 'number' ? pkg.remaining_events : null;
const allowance = pkg.package_limits?.max_events_per_year;
const used = pkg.used_events ?? 0;
const totalEvents = allowance ?? (remaining !== null ? remaining + used : null);
// Warnungen nur, wenn das Paket tatsächlich mehr als 1 Event umfasst oder das Limit unbekannt ist.
const shouldWarn = totalEvents === null ? true : totalEvents > 1;
if (remaining !== null && shouldWarn) {
if (remaining <= 0) {
warnings.push({
id: `${pkg.id}-no-events`,
tone: 'danger',
message: translate(`${keyPrefix}.noEvents`),
});
} else if (remaining <= 2) {
warnings.push({
id: `${pkg.id}-low-events`,
tone: 'warning',
message: translate(`${keyPrefix}.lowEvents`, { remaining }),
});
}
}
const expiresAt = pkg.expires_at ? new Date(pkg.expires_at) : null;
if (expiresAt && !Number.isNaN(expiresAt.getTime())) {
const now = new Date();
const diffMillis = expiresAt.getTime() - now.getTime();
const diffDays = Math.ceil(diffMillis / (1000 * 60 * 60 * 24));
const formatted = formatDate(pkg.expires_at);
if (diffDays < 0) {
warnings.push({
id: `${pkg.id}-expired`,
tone: 'danger',
message: translate(`${keyPrefix}.expired`, { date: formatted }),
});
} else if (diffDays <= 14) {
warnings.push({
id: `${pkg.id}-expires`,
tone: 'warning',
message: translate(`${keyPrefix}.expiresSoon`, { date: formatted }),
});
}
}
return warnings;
}
function BillingSkeleton() {
return (
<div className="grid gap-6">
{Array.from({ length: 3 }).map((_, index) => (
<FrostedSurface
key={index}
className="space-y-4 border border-white/20 p-6 shadow-md shadow-rose-200/10 dark:border-slate-800/70 dark:bg-slate-950/80"
>
<div className="h-6 w-48 animate-pulse rounded bg-gradient-to-r from-white/30 via-white/60 to-white/30 dark:from-slate-700 dark:via-slate-600 dark:to-slate-700" />
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((__, placeholderIndex) => (
<div
key={placeholderIndex}
className="h-24 animate-pulse rounded-2xl bg-gradient-to-r from-white/30 via-white/55 to-white/30 dark:from-slate-800 dark:via-slate-700 dark:to-slate-800"
/>
))}
</div>
</FrostedSurface>
))}
</div>
);
}