rework of the event admin UI
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user