rework of the event admin UI

This commit is contained in:
Codex Agent
2025-11-24 17:17:39 +01:00
parent 4667ec8073
commit 8947a37261
37 changed files with 4381 additions and 874 deletions

View File

@@ -1,4 +1,5 @@
import React from 'react';
// @ts-nocheck
import React from 'react';
import { AlertTriangle, Loader2, RefreshCw, Sparkles, ArrowUpRight } from 'lucide-react';
import { useTranslation } from 'react-i18next';
@@ -219,6 +220,41 @@ export default function BillingPage() {
);
const nextRenewalLabel = t('billing.hero.nextRenewal', 'Verlängerung am');
const topWarning = activeWarnings[0];
const billingStats = React.useMemo(
() => [
{
key: 'package',
label: t('billing.stats.package.label', 'Aktives Paket'),
value: activePackage?.package_name ?? t('billing.stats.package.empty', 'Keines'),
helper: activePackage?.expires_at
? t('billing.stats.package.helper', { date: formatDate(activePackage.expires_at) })
: t('billing.stats.package.helper', { date: '—' }),
tone: 'pink' as const,
},
{
key: 'events',
label: t('billing.stats.events.label', 'Genutzte Events'),
value: activePackage?.used_events ?? 0,
helper: t('billing.stats.events.helper', { count: activePackage?.remaining_events ?? 0 }),
tone: 'amber' as const,
},
{
key: 'addons',
label: t('billing.stats.addons.label', 'Add-ons'),
value: addonHistory.length,
helper: t('billing.stats.addons.helper', 'Historie insgesamt'),
tone: 'sky' as const,
},
{
key: 'transactions',
label: t('billing.stats.transactions.label', 'Transaktionen'),
value: transactions.length,
helper: t('billing.stats.transactions.helper', 'Synchronisierte Zahlungen'),
tone: 'emerald' as const,
},
],
[activePackage, addonHistory.length, transactions.length, formatDate, t]
);
const heroAside = (
<FrostedSurface className="space-y-4 border-white/25 p-5 text-slate-900 shadow-lg shadow-rose-300/20 dark:border-slate-800/70 dark:bg-slate-950/85">
<div>
@@ -264,6 +300,8 @@ export default function BillingPage() {
<BillingSkeleton />
) : (
<>
<BillingStatGrid stats={billingStats} />
<BillingWarningBanner warnings={activeWarnings} t={t} />
<SectionCard className="mt-6 space-y-5">
<SectionHeader
eyebrow={t('billing.sections.overview.badge', 'Aktuelles Paket')}
@@ -277,23 +315,6 @@ export default function BillingPage() {
/>
{activePackage ? (
<div className="space-y-4">
{activeWarnings.length > 0 && (
<div className="space-y-2">
{activeWarnings.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/60 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-sm">
<AlertTriangle className="h-4 w-4" />
{warning.message}
</AlertDescription>
</Alert>
))}
</div>
)}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<InfoCard
label={t('billing.sections.overview.cards.package.label')}
@@ -360,8 +381,8 @@ export default function BillingPage() {
<SectionHeader
eyebrow={t('billing.sections.addOns.badge', 'Add-ons')}
title={t('billing.sections.addOns.title')}
description={t('billing.sections.addOns.description')}
/>
description={t('billing.sections.addOns.description')}
/>
{addonHistory.length === 0 ? (
<EmptyState message={t('billing.sections.addOns.empty')} />
) : (
@@ -398,18 +419,13 @@ export default function BillingPage() {
{transactions.length === 0 ? (
<EmptyState message={t('billing.sections.transactions.empty')} />
) : (
<div className="grid gap-3">
{transactions.map((transaction) => (
<TransactionCard
key={transaction.id ?? Math.random().toString(36).slice(2)}
transaction={transaction}
formatCurrency={formatCurrency}
formatDate={formatDate}
locale={locale}
t={t}
/>
))}
</div>
<TransactionsTable
items={transactions}
formatCurrency={formatCurrency}
formatDate={formatDate}
locale={locale}
t={t}
/>
)}
{transactionsHasMore && (
<Button
@@ -548,82 +564,137 @@ function AddonHistoryTable({
);
}
function TransactionCard({
transaction,
function TransactionsTable({
items,
formatCurrency,
formatDate,
locale,
t,
}: {
transaction: PaddleTransactionSummary;
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 amount = transaction.grand_total ?? transaction.amount ?? null;
const currency = transaction.currency ?? 'EUR';
const createdAtIso = transaction.created_at ?? null;
const createdAt = createdAtIso ? new Date(createdAtIso) : null;
const createdLabel = createdAt
? createdAt.toLocaleString(locale, {
year: 'numeric',
month: 'short',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
: formatDate(createdAtIso);
const statusKey = transaction.status ? `billing.sections.transactions.status.${transaction.status}` : 'billing.sections.transactions.status.unknown';
const statusText = t(statusKey, {
defaultValue: (transaction.status ?? 'unknown').replace(/_/g, ' '),
});
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="flex flex-col gap-3 border border-slate-200/60 p-4 text-slate-900 shadow-md shadow-slate-200/10 transition-colors duration-200 dark:border-slate-800/70 dark:bg-slate-950/80 md:flex-row md:items-center md:justify-between">
<div className="space-y-1">
<p className="text-sm font-semibold text-slate-800 dark:text-slate-100">
{t('billing.sections.transactions.labels.transactionId', { id: transaction.id ?? '—' })}
</p>
<p className="text-xs uppercase tracking-wide text-slate-500 dark:text-slate-400">{createdLabel}</p>
{transaction.checkout_id ? (
<p className="text-xs text-slate-500 dark:text-slate-400">
{t('billing.sections.transactions.labels.checkoutId', { id: transaction.checkout_id })}
</p>
) : null}
{transaction.origin ? (
<p className="text-xs text-slate-500 dark:text-slate-400">
{t('billing.sections.transactions.labels.origin', { origin: transaction.origin })}
</p>
) : null}
</div>
<div className="flex flex-col items-start gap-2 text-sm font-medium text-slate-700 dark:text-slate-300 md:flex-row md:items-center md:gap-4">
<Badge className="bg-sky-100 text-sky-700 dark:bg-sky-500/20 dark:text-sky-200">
{statusText}
</Badge>
<div className="text-base font-semibold text-slate-900 dark:text-slate-100">
{formatCurrency(amount, currency)}
</div>
{transaction.tax !== undefined && transaction.tax !== null ? (
<span className="text-xs text-slate-500 dark:text-slate-400">
{t('billing.sections.transactions.labels.tax', { value: formatCurrency(transaction.tax, currency) })}
</span>
) : null}
{transaction.receipt_url ? (
<a
href={transaction.receipt_url}
target="_blank"
rel="noreferrer"
className="text-xs font-medium text-sky-600 transition hover:text-sky-700 dark:text-sky-300 dark:hover:text-sky-200"
>
{t('billing.sections.transactions.labels.receipt')}
</a>
) : null}
</div>
<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,