implemented event package addons with filament resource, event-admin purchase path and notifications, showing up in purchase history

This commit is contained in:
Codex Agent
2025-11-21 11:25:45 +01:00
parent 07fe049b8a
commit 7a8d22a238
58 changed files with 3339 additions and 60 deletions

View File

@@ -8,7 +8,15 @@ import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { AdminLayout } from '../components/AdminLayout';
import { getTenantPackagesOverview, getTenantPaddleTransactions, PaddleTransactionSummary, TenantPackageSummary } from '../api';
import {
getTenantPackagesOverview,
getTenantPaddleTransactions,
getTenantAddonHistory,
PaddleTransactionSummary,
TenantAddonHistoryEntry,
TenantPackageSummary,
PaginationMeta,
} from '../api';
import { isAuthError } from '../auth/tokens';
import {
TenantHeroCard,
@@ -34,6 +42,9 @@ export default function BillingPage() {
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);
@@ -55,6 +66,33 @@ export default function BillingPage() {
[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'),
@@ -70,18 +108,24 @@ export default function BillingPage() {
setLoading(true);
setError(null);
try {
const [packagesResult, paddleTransactions] = await Promise.all([
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'));
@@ -110,6 +154,24 @@ export default function BillingPage() {
}
}, [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]);
@@ -118,6 +180,12 @@ export default function BillingPage() {
() => buildPackageWarnings(activePackage, t, formatDate, 'billing.sections.overview.warnings'),
[activePackage, t, formatDate],
);
const hasMoreAddons = React.useMemo(() => {
if (!addonMeta) {
return false;
}
return addonMeta.current_page < addonMeta.last_page;
}, [addonMeta]);
const heroBadge = t('billing.hero.badge', 'Abrechnung');
const heroDescription = t('billing.hero.description', 'Behalte Laufzeiten, Rechnungen und Limits deiner Pakete im Blick.');
@@ -288,6 +356,38 @@ export default function BillingPage() {
</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')}
@@ -336,6 +436,118 @@ export default function BillingPage() {
);
}
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 TransactionCard({
transaction,
formatCurrency,