der tenant admin hat eine neue, mobil unterstützende UI, login redirect funktioniert, typescript fehler wurden bereinigt. Neue Blog Posts von ChatGPT eingebaut, übersetzt von Gemini 2.5

This commit is contained in:
Codex Agent
2025-11-05 19:27:10 +01:00
parent adb93b5f9d
commit c6ac04eb15
44 changed files with 1995 additions and 1949 deletions

View File

@@ -1,16 +1,17 @@
import React from 'react';
import { AlertTriangle, Loader2, RefreshCw, Sparkles } from 'lucide-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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { AdminLayout } from '../components/AdminLayout';
import { getTenantPackagesOverview, getTenantPaddleTransactions, PaddleTransactionSummary, TenantPackageSummary } from '../api';
import { isAuthError } from '../auth/tokens';
import { TenantHeroCard, FrostedCard, FrostedSurface } from '../components/tenant';
type PackageWarning = { id: string; tone: 'warning' | 'danger'; message: string };
@@ -107,24 +108,78 @@ export default function BillingPage() {
void loadAll();
}, [loadAll]);
const actions = (
<Button variant="outline" onClick={() => void loadAll()} disabled={loading}>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
{t('billing.actions.refresh')}
</Button>
);
const activeWarnings = React.useMemo(
() => buildPackageWarnings(activePackage, t, formatDate, 'billing.sections.overview.warnings'),
[activePackage, t, formatDate],
);
return (
<AdminLayout
title={t('billing.title')}
subtitle={t('billing.subtitle')}
actions={actions}
const heroBadge = t('billing.hero.badge', 'Abrechnung');
const heroDescription = t('billing.hero.description', 'Behalte Laufzeiten, Rechnungen und Limits deiner Pakete im Blick.');
const heroSupporting: string[] = [
activePackage
? t('billing.hero.summary.active', 'Aktives Paket: {{name}}', { name: activePackage.package_name })
: t('billing.hero.summary.inactive', 'Noch kein aktives Paket wählt ein Kontingent, das zu euch passt.'),
t('billing.hero.summary.transactions', '{{count}} Zahlungen synchronisiert', { count: transactions.length })
];
const packagesHref = `/${i18n.language?.split('-')[0] ?? 'de'}/packages`;
const heroPrimaryAction = (
<Button
size="sm"
className="rounded-full bg-gradient-to-r from-[#ff5f87] via-[#ec4899] to-[#6366f1] px-6 text-white shadow-md shadow-rose-400/30 transition hover:from-[#ff4470] hover:via-[#ec4899] hover:to-[#4f46e5]"
onClick={() => void loadAll()}
disabled={loading}
>
{loading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <RefreshCw className="mr-2 h-4 w-4" />}
{t('billing.actions.refresh')}
</Button>
);
const heroSecondaryAction = (
<Button
size="sm"
variant="outline"
className="rounded-full border-white/70 bg-white/80 px-6 text-slate-900 shadow-sm hover:bg-white"
onClick={() => window.location.assign(packagesHref)}
>
{t('billing.actions.explorePackages', 'Pakete vergleichen')}
<ArrowUpRight className="ml-2 h-4 w-4" />
</Button>
);
const nextRenewalLabel = t('billing.hero.nextRenewal', 'Verlängerung am');
const topWarning = activeWarnings[0];
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>
<p className="text-xs uppercase tracking-wide text-slate-500 dark:text-slate-400">
{t('billing.hero.activePackage', 'Aktuelles Paket')}
</p>
<p className="mt-1 text-lg font-semibold text-slate-900 dark:text-slate-100">
{activePackage?.package_name ?? t('billing.hero.activeFallback', 'Noch nicht ausgewählt')}
</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-slate-500 dark:text-slate-400">{nextRenewalLabel}</p>
<p className="mt-1 text-sm font-medium text-slate-800 dark:text-slate-200">{formatDate(activePackage?.expires_at)}</p>
</div>
{topWarning ? (
<div className="rounded-xl border border-amber-200/60 bg-amber-50/80 p-3 text-xs text-amber-800 shadow-inner shadow-amber-200/40 dark:border-amber-500/50 dark:bg-amber-500/15 dark:text-amber-200">
{topWarning.message}
</div>
) : null}
</FrostedSurface>
);
return (
<AdminLayout title={t('billing.title')} subtitle={t('billing.subtitle')}>
<TenantHeroCard
badge={heroBadge}
title={t('billing.title')}
description={heroDescription}
supporting={heroSupporting}
primaryAction={heroPrimaryAction}
secondaryAction={heroSecondaryAction}
aside={heroAside}
/>
{error && (
<Alert variant="destructive">
<AlertTitle>{t('dashboard:alerts.errorTitle')}</AlertTitle>
@@ -136,7 +191,7 @@ export default function BillingPage() {
<BillingSkeleton />
) : (
<>
<Card className="border-0 bg-white/85 shadow-xl shadow-pink-100/60">
<FrostedCard className="mt-6 border border-white/20">
<CardHeader className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
@@ -147,7 +202,7 @@ export default function BillingPage() {
{t('billing.sections.overview.description')}
</CardDescription>
</div>
<Badge className={activePackage ? 'bg-pink-500/10 text-pink-700' : 'bg-slate-200 text-slate-700'}>
<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>
</CardHeader>
@@ -160,7 +215,7 @@ export default function BillingPage() {
<Alert
key={warning.id}
variant={warning.tone === 'danger' ? 'destructive' : 'default'}
className={warning.tone === 'warning' ? 'border-amber-400/40 bg-amber-50 text-amber-900' : undefined}
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" />
@@ -204,9 +259,9 @@ export default function BillingPage() {
<EmptyState message={t('billing.sections.overview.empty')} />
)}
</CardContent>
</Card>
</FrostedCard>
<Card className="border-0 bg-white/85 shadow-xl shadow-amber-100/60">
<FrostedCard className="border border-white/20">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
<Sparkles className="h-5 w-5 text-amber-500" />
@@ -236,9 +291,9 @@ export default function BillingPage() {
})
)}
</CardContent>
</Card>
</FrostedCard>
<Card className="border-0 bg-white/85 shadow-xl shadow-sky-100/60">
<FrostedCard className="border border-white/20">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
<Sparkles className="h-5 w-5 text-sky-500" />
@@ -282,7 +337,7 @@ export default function BillingPage() {
</Button>
)}
</CardContent>
</Card>
</FrostedCard>
</>
)}
@@ -322,47 +377,47 @@ function TransactionCard({
});
return (
<div className="flex flex-col gap-3 rounded-2xl border border-slate-200 bg-white/80 p-4 shadow-sm md:flex-row md:items-center md:justify-between">
<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">
<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">{createdLabel}</p>
{transaction.checkout_id && (
<p className="text-xs text-slate-500">
<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>
)}
{transaction.origin && (
<p className="text-xs text-slate-500">
) : 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 md:flex-row md:items-center md:gap-4">
<Badge className="bg-sky-100 text-sky-700">
<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">
<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">
{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>
)}
{transaction.receipt_url && (
) : null}
{transaction.receipt_url ? (
<a
href={transaction.receipt_url}
target="_blank"
rel="noreferrer"
className="text-xs font-medium text-sky-600 hover:text-sky-700"
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>
</div>
</FrostedSurface>
);
}
@@ -377,19 +432,19 @@ function InfoCard({
helper?: string;
tone: 'pink' | 'amber' | 'sky' | 'emerald';
}) {
const toneClass = {
pink: 'from-pink-50 to-rose-100 text-pink-700',
amber: 'from-amber-50 to-yellow-100 text-amber-700',
sky: 'from-sky-50 to-blue-100 text-sky-700',
emerald: 'from-emerald-50 to-green-100 text-emerald-700',
}[tone];
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 (
<div className={`rounded-2xl border border-white/60 bg-gradient-to-br ${toneClass} p-5 shadow-sm`}>
<span className="text-xs uppercase tracking-wide text-slate-600/90">{label}</span>
<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/80">{helper}</p>}
</div>
{helper ? <p className="mt-2 text-xs text-slate-600 dark:text-slate-400">{helper}</p> : null}
</FrostedSurface>
);
}
@@ -415,14 +470,14 @@ function PackageCard({
warnings?: PackageWarning[];
}) {
return (
<div className="rounded-2xl border border-amber-100 bg-white/90 p-4 shadow-sm">
<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' : undefined}
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" />
@@ -434,17 +489,17 @@ function PackageCard({
)}
<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">{pkg.package_name}</h3>
<p className="text-xs text-slate-600">
<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/10 text-amber-700' : 'bg-slate-200 text-slate-700'}>
<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" />
<div className="grid gap-2 text-xs text-slate-600 sm:grid-cols-3">
<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>
@@ -455,18 +510,18 @@ function PackageCard({
{labels.expires}: {formatDate(pkg.expires_at)}
</span>
</div>
</div>
</FrostedSurface>
);
}
function EmptyState({ message }: { message: string }) {
return (
<div className="flex flex-col items-center justify-center gap-3 rounded-2xl border border-dashed border-slate-200 bg-white/70 p-8 text-center">
<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">{message}</p>
</div>
<p className="text-sm text-slate-600 dark:text-slate-400">{message}</p>
</FrostedSurface>
);
}
@@ -528,17 +583,20 @@ function BillingSkeleton() {
return (
<div className="grid gap-6">
{Array.from({ length: 3 }).map((_, index) => (
<div key={index} className="space-y-4 rounded-2xl border border-white/60 bg-white/70 p-6 shadow-sm">
<div className="h-6 w-48 animate-pulse rounded bg-gradient-to-r from-white/40 via-white/60 to-white/40" />
<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/40 via-white/60 to-white/40"
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>
</div>
</FrostedSurface>
))}
</div>
);