implemented event package addons with filament resource, event-admin purchase path and notifications, showing up in purchase history
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user