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,

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
AlertTriangle,
@@ -16,6 +16,7 @@ import {
RefreshCw,
Smile,
Sparkles,
ShoppingCart,
Users,
} from 'lucide-react';
import toast from 'react-hot-toast';
@@ -36,6 +37,7 @@ import {
toggleEvent,
submitTenantFeedback,
updatePhotoVisibility,
createEventAddonCheckout,
} from '../api';
import { buildLimitWarnings } from '../lib/limitWarnings';
import { getApiErrorMessage } from '../lib/apiError';
@@ -54,6 +56,9 @@ import {
ActionGrid,
TenantHeroCard,
} from '../components/tenant';
import { AddonsPicker } from '../components/Addons/AddonsPicker';
import { AddonSummaryList } from '../components/Addons/AddonSummaryList';
import { EventAddonCatalogItem, getAddonCatalog } from '../api';
import { GuestBroadcastCard } from '../components/GuestBroadcastCard';
type EventDetailPageProps = {
@@ -76,6 +81,7 @@ type WorkspaceState = {
export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProps) {
const { slug: slugParam } = useParams<{ slug?: string }>();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const { t } = useTranslation('management');
const { t: tCommon } = useTranslation('common');
@@ -91,6 +97,9 @@ export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProp
});
const [toolkit, setToolkit] = React.useState<ToolkitState>({ data: null, loading: true, error: null });
const [addonBusyId, setAddonBusyId] = React.useState<string | null>(null);
const [addonRefreshCount, setAddonRefreshCount] = React.useState(0);
const [addonsCatalog, setAddonsCatalog] = React.useState<EventAddonCatalogItem[]>([]);
const load = React.useCallback(async () => {
if (!slug) {
@@ -103,8 +112,9 @@ export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProp
setToolkit((prev) => ({ ...prev, loading: true, error: null }));
try {
const [eventData, statsData] = await Promise.all([getEvent(slug), getEventStats(slug)]);
const [eventData, statsData, addonOptions] = await Promise.all([getEvent(slug), getEventStats(slug), getAddonCatalog()]);
setState((prev) => ({ ...prev, event: eventData, stats: statsData, loading: false }));
setAddonsCatalog(addonOptions);
} catch (error) {
if (!isAuthError(error)) {
setState((prev) => ({
@@ -181,7 +191,43 @@ export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProp
[event?.limits, tCommon],
);
const shownWarningToasts = React.useRef<Set<string>>(new Set());
const shownWarningToasts = React.useRef<Set<string>>(new Set());
const [addonBusyId, setAddonBusyId] = React.useState<string | null>(null);
const handleAddonPurchase = React.useCallback(
async (scope: 'photos' | 'guests' | 'gallery', addonKeyOverride?: string) => {
if (!slug) return;
const defaultAddons: Record<typeof scope, string> = {
photos: 'extra_photos_500',
guests: 'extra_guests_100',
gallery: 'extend_gallery_30d',
};
const addonKey = addonKeyOverride ?? defaultAddons[scope];
setAddonBusyId(scope);
try {
const currentUrl = window.location.origin + window.location.pathname;
const successUrl = `${currentUrl}?addon_success=1`;
const checkout = await createEventAddonCheckout(slug, {
addon_key: addonKey,
quantity: 1,
success_url: successUrl,
cancel_url: currentUrl,
});
if (checkout.checkout_url) {
window.location.href = checkout.checkout_url;
} else {
toast(t('events.errors.checkoutMissing', 'Checkout konnte nicht gestartet werden.'));
}
} catch (err) {
toast(getApiErrorMessage(err, t('events.errors.checkoutFailed', 'Add-on Checkout fehlgeschlagen.')));
} finally {
setAddonBusyId(null);
}
},
[slug, t],
);
React.useEffect(() => {
limitWarnings.forEach((warning) => {
@@ -198,6 +244,30 @@ export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProp
});
}, [limitWarnings]);
React.useEffect(() => {
const success = searchParams.get('addon_success');
if (success && slug) {
toast(t('events.success.addonApplied', 'Add-on angewendet. Limits aktualisieren sich in Kürze.'));
void load();
setAddonRefreshCount(3);
const params = new URLSearchParams(window.location.search);
params.delete('addon_success');
const search = params.toString();
navigate(search ? `${window.location.pathname}?${search}` : window.location.pathname, { replace: true });
}
}, [searchParams, slug, load, navigate, t]);
React.useEffect(() => {
if (addonRefreshCount <= 0) {
return;
}
const timer = setTimeout(() => {
void load();
setAddonRefreshCount((count) => count - 1);
}, 8000);
return () => clearTimeout(timer);
}, [addonRefreshCount, load]);
if (!slug) {
return (
<AdminLayout
@@ -230,10 +300,39 @@ export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProp
variant={warning.tone === 'danger' ? 'destructive' : 'default'}
className={warning.tone === 'warning' ? 'border-amber-400/40 bg-amber-50 text-amber-900' : undefined}
>
<AlertDescription className="flex items-center gap-2 text-sm">
<AlertTriangle className="h-4 w-4" />
{warning.message}
</AlertDescription>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<AlertDescription className="flex items-center gap-2 text-sm">
<AlertTriangle className="h-4 w-4" />
{warning.message}
</AlertDescription>
{(['photos', 'guests', 'gallery'] as const).includes(warning.scope) ? (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
<Button
variant="outline"
size="sm"
onClick={() => { void handleAddonPurchase(warning.scope as 'photos' | 'guests' | 'gallery'); }}
disabled={addonBusyId === warning.scope}
className="justify-start"
>
<ShoppingCart className="mr-2 h-4 w-4" />
{warning.scope === 'photos'
? t('events.actions.buyMorePhotos', 'Mehr Fotos freischalten')
: warning.scope === 'guests'
? t('events.actions.buyMoreGuests', 'Mehr Gäste freischalten')
: t('events.actions.extendGallery', 'Galerie verlängern')}
</Button>
{addonsCatalog.length > 0 ? (
<AddonsPicker
addons={addonsCatalog}
scope={warning.scope as 'photos' | 'guests' | 'gallery'}
onCheckout={(key) => { void handleAddonPurchase(warning.scope as 'photos' | 'guests' | 'gallery', key); }}
busy={addonBusyId === warning.scope}
t={(key, fallback) => t(key as any, fallback)}
/>
) : null}
</div>
) : null}
</div>
</Alert>
))}
</div>
@@ -257,7 +356,17 @@ export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProp
navigate={navigate}
/>
{(toolkitData?.alerts?.length ?? 0) > 0 && <AlertList alerts={toolkitData?.alerts ?? []} />}
{(toolkitData?.alerts?.length ?? 0) > 0 && <AlertList alerts={toolkitData?.alerts ?? []} />}
{state.event?.addons?.length ? (
<SectionCard>
<SectionHeader
title={t('events.sections.addons.title', 'Add-ons & Upgrades')}
description={t('events.sections.addons.description', 'Zusätzliche Kontingente für dieses Event.')}
/>
<AddonSummaryList addons={state.event.addons} t={(key, fallback) => t(key, fallback)} />
</SectionCard>
) : null}
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.6fr)_minmax(0,0.6fr)]">
<StatusCard event={event} stats={stats} busy={busy} onToggle={handleToggle} />

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { AlertTriangle, ArrowLeft, Copy, Download, Loader2, Printer, QrCode, RefreshCw, Share2, X } from 'lucide-react';
import { AlertTriangle, ArrowLeft, Copy, Download, Loader2, Printer, QrCode, RefreshCw, Share2, X, ShoppingCart } from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
@@ -9,6 +9,7 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import toast from 'react-hot-toast';
import { AdminLayout } from '../components/AdminLayout';
import {
@@ -20,6 +21,9 @@ import {
TenantEvent,
updateEventQrInvite,
EventQrInviteLayout,
createEventAddonCheckout,
getAddonCatalog,
type EventAddonCatalogItem,
} from '../api';
import { isAuthError } from '../auth/tokens';
import {
@@ -29,6 +33,8 @@ import {
ADMIN_EVENT_PHOTOS_PATH,
} from '../constants';
import { buildLimitWarnings } from '../lib/limitWarnings';
import { AddonsPicker } from '../components/Addons/AddonsPicker';
import { AddonSummaryList } from '../components/Addons/AddonSummaryList';
import { InviteLayoutCustomizerPanel, QrLayoutCustomization } from './components/InviteLayoutCustomizerPanel';
import { buildDownloadFilename, normalizeEventDateSegment } from './components/invite-layout/fileNames';
import { DesignerCanvas } from './components/invite-layout/DesignerCanvas';
@@ -191,9 +197,14 @@ export default function EventInvitesPage(): React.ReactElement {
setState((prev) => ({ ...prev, loading: true, error: null }));
try {
const [eventData, invitesData] = await Promise.all([getEvent(slug), getEventQrInvites(slug)]);
const [eventData, invitesData, catalog] = await Promise.all([
getEvent(slug),
getEventQrInvites(slug),
getAddonCatalog(),
]);
setState({ event: eventData, invites: invitesData, loading: false, error: null });
setSelectedInviteId((current) => current ?? invitesData[0]?.id ?? null);
setAddonsCatalog(catalog);
} catch (error) {
if (!isAuthError(error)) {
setState({ event: null, invites: [], loading: false, error: 'QR-Einladungen konnten nicht geladen werden.' });
@@ -765,6 +776,36 @@ export default function EventInvitesPage(): React.ReactElement {
[state.event?.limits, tLimits]
);
const [addonBusy, setAddonBusy] = React.useState<string | null>(null);
const [addonsCatalog, setAddonsCatalog] = React.useState<EventAddonCatalogItem[]>([]);
const [searchParams] = useSearchParams();
const handleAddonPurchase = React.useCallback(
async (addonKey?: string) => {
if (!slug) return;
setAddonBusy('guests');
const key = addonKey ?? 'extra_guests_100';
try {
const currentUrl = window.location.origin + window.location.pathname;
const successUrl = `${currentUrl}?addon_success=1`;
const checkout = await createEventAddonCheckout(slug, {
addon_key: key,
quantity: 1,
success_url: successUrl,
cancel_url: currentUrl,
});
if (checkout.checkout_url) {
window.location.href = checkout.checkout_url;
}
} catch (err) {
toast(getApiErrorMessage(err, 'Checkout fehlgeschlagen.'));
} finally {
setAddonBusy(null);
}
},
[slug],
);
const limitScopeLabels = React.useMemo(
() => ({
photos: tLimits('photosTitle'),
@@ -774,6 +815,16 @@ export default function EventInvitesPage(): React.ReactElement {
[tLimits]
);
React.useEffect(() => {
const success = searchParams.get('addon_success');
if (success && slug) {
toast(t('events.success.addonApplied', 'Add-on angewendet. Limits aktualisieren sich in Kürze.'));
void load();
searchParams.delete('addon_success');
navigate(window.location.pathname, { replace: true });
}
}, [searchParams, slug, load, navigate, t]);
return (
<AdminLayout
title={eventName}
@@ -788,18 +839,54 @@ export default function EventInvitesPage(): React.ReactElement {
variant={warning.tone === 'danger' ? 'destructive' : 'default'}
className={warning.tone === 'warning' ? 'border-amber-400/50 bg-amber-50 text-amber-900' : undefined}
>
<AlertTitle className="flex items-center gap-2 text-sm font-semibold">
<AlertTriangle className="h-4 w-4" />
{limitScopeLabels[warning.scope]}
</AlertTitle>
<AlertDescription className="text-sm">
{warning.message}
</AlertDescription>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<AlertTitle className="flex items-center gap-2 text-sm font-semibold">
<AlertTriangle className="h-4 w-4" />
{limitScopeLabels[warning.scope]}
</AlertTitle>
<AlertDescription className="text-sm">
{warning.message}
</AlertDescription>
</div>
{warning.scope === 'guests' ? (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
<Button
variant="outline"
size="sm"
onClick={() => { void handleAddonPurchase(); }}
disabled={addonBusy === 'guests'}
>
<ShoppingCart className="mr-2 h-4 w-4" />
{t('invites.actions.buyMoreGuests', 'Mehr Gäste freischalten')}
</Button>
<AddonsPicker
addons={addonsCatalog}
scope="guests"
onCheckout={(key) => { void handleAddonPurchase(key); }}
busy={addonBusy === 'guests'}
t={(key, fallback) => t(key as any, fallback)}
/>
</div>
) : null}
</div>
</Alert>
))}
</div>
)}
{state.event?.addons?.length ? (
<Card className="mb-6 border-0 bg-white/85 shadow-lg shadow-slate-100/50">
<CardHeader>
<CardTitle className="text-base font-semibold text-slate-900">{t('events.sections.addons.title', 'Add-ons & Upgrades')}</CardTitle>
<CardDescription>{t('events.sections.addons.description', 'Zusätzliche Kontingente für dieses Event.')}</CardDescription>
</CardHeader>
<CardContent>
<AddonSummaryList addons={state.event.addons} t={(key, fallback) => t(key as any, fallback)} />
</CardContent>
</Card>
) : null}
<Tabs value={activeTab} onValueChange={handleTabChange} className="space-y-6">
<TabsList className="grid w-full max-w-2xl grid-cols-3 gap-1 rounded-full border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-strong)] p-1 text-sm">
<TabsTrigger value="layout" className="rounded-full px-4 py-1.5 data-[state=active]:bg-primary data-[state=active]:text-primary-foreground">

View File

@@ -1,13 +1,17 @@
import React from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { Camera, Loader2, Sparkles, Trash2, AlertTriangle } from 'lucide-react';
import { Camera, Loader2, Sparkles, Trash2, AlertTriangle, ShoppingCart } from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import toast from 'react-hot-toast';
import { AddonsPicker } from '../components/Addons/AddonsPicker';
import { AddonSummaryList } from '../components/Addons/AddonSummaryList';
import { getAddonCatalog, getEvent, type EventAddonCatalogItem, type EventAddonSummary } from '../api';
import { AdminLayout } from '../components/AdminLayout';
import { deletePhoto, featurePhoto, getEventPhotos, TenantPhoto, unfeaturePhoto } from '../api';
import { createEventAddonCheckout, deletePhoto, featurePhoto, getEventPhotos, TenantPhoto, unfeaturePhoto } from '../api';
import { isAuthError } from '../auth/tokens';
import { getApiErrorMessage, isApiError } from '../lib/apiError';
import { buildLimitWarnings, type EventLimitSummary } from '../lib/limitWarnings';
@@ -31,6 +35,10 @@ export default function EventPhotosPage() {
const [error, setError] = React.useState<string | undefined>(undefined);
const [busyId, setBusyId] = React.useState<number | null>(null);
const [limits, setLimits] = React.useState<EventLimitSummary | null>(null);
const [addons, setAddons] = React.useState<EventAddonCatalogItem[]>([]);
const [catalogError, setCatalogError] = React.useState<string | undefined>(undefined);
const [searchParams, setSearchParams] = React.useState(() => new URLSearchParams(window.location.search));
const [eventAddons, setEventAddons] = React.useState<EventAddonSummary[]>([]);
const load = React.useCallback(async () => {
if (!slug) {
@@ -40,9 +48,16 @@ export default function EventPhotosPage() {
setLoading(true);
setError(undefined);
try {
const result = await getEventPhotos(slug);
setPhotos(result.photos);
setLimits(result.limits ?? null);
const [photoResult, eventData, catalog] = await Promise.all([
getEventPhotos(slug),
getEvent(slug),
getAddonCatalog(),
]);
setPhotos(photoResult.photos);
setLimits(photoResult.limits ?? null);
setEventAddons(eventData.addons ?? []);
setAddons(catalog);
setCatalogError(undefined);
} catch (err) {
if (!isAuthError(err)) {
setError(getApiErrorMessage(err, 'Fotos konnten nicht geladen werden.'));
@@ -56,6 +71,18 @@ export default function EventPhotosPage() {
load();
}, [load]);
React.useEffect(() => {
const success = searchParams.get('addon_success');
if (success && slug) {
toast(translateLimits('addonApplied', { defaultValue: 'Add-on angewendet. Limits aktualisieren sich in Kürze.' }));
void load();
const params = new URLSearchParams(searchParams);
params.delete('addon_success');
setSearchParams(params);
navigate(window.location.pathname, { replace: true });
}
}, [searchParams, slug, load, navigate, translateLimits]);
async function handleToggleFeature(photo: TenantPhoto) {
if (!slug) return;
setBusyId(photo.id);
@@ -126,7 +153,19 @@ export default function EventPhotosPage() {
</Alert>
)}
<LimitWarningsBanner limits={limits} translate={translateLimits} />
<LimitWarningsBanner limits={limits} translate={translateLimits} eventSlug={slug} addons={addons} />
{eventAddons.length > 0 && (
<Card className="mb-6 border-0 bg-white/85 shadow-lg shadow-slate-100/50">
<CardHeader>
<CardTitle className="text-base font-semibold text-slate-900">{t('events.sections.addons.title', 'Add-ons & Upgrades')}</CardTitle>
<CardDescription>{t('events.sections.addons.description', 'Zusätzliche Kontingente für dieses Event.')}</CardDescription>
</CardHeader>
<CardContent>
<AddonSummaryList addons={eventAddons} t={(key, fallback) => t(key as any, fallback)} />
</CardContent>
</Card>
)}
<Card className="border-0 bg-white/85 shadow-xl shadow-sky-100/60">
<CardHeader>
@@ -197,11 +236,49 @@ export default function EventPhotosPage() {
function LimitWarningsBanner({
limits,
translate,
eventSlug,
addons,
}: {
limits: EventLimitSummary | null;
translate: (key: string, options?: Record<string, unknown>) => string;
eventSlug: string | null;
addons: EventAddonCatalogItem[];
}) {
const warnings = React.useMemo(() => buildLimitWarnings(limits, translate), [limits, translate]);
const [busyScope, setBusyScope] = React.useState<string | null>(null);
const handleCheckout = React.useCallback(
async (scopeOrKey: 'photos' | 'gallery' | string) => {
if (!eventSlug) return;
const scope = scopeOrKey === 'gallery' || scopeOrKey === 'photos' ? scopeOrKey : (scopeOrKey.includes('gallery') ? 'gallery' : 'photos');
setBusyScope(scope);
const addonKey = scopeOrKey === 'photos' || scopeOrKey === 'gallery'
? (() => {
const fallbackKey = scope === 'photos' ? 'extra_photos_500' : 'extend_gallery_30d';
const candidates = addons.filter((addon) => addon.price_id && addon.key.includes(scope === 'photos' ? 'photos' : 'gallery'));
return candidates[0]?.key ?? fallbackKey;
})()
: scopeOrKey;
try {
const currentUrl = window.location.origin + window.location.pathname;
const successUrl = `${currentUrl}?addon_success=1`;
const checkout = await createEventAddonCheckout(eventSlug, {
addon_key: addonKey,
quantity: 1,
success_url: successUrl,
cancel_url: currentUrl,
});
if (checkout.checkout_url) {
window.location.href = checkout.checkout_url;
}
} catch (err) {
toast(getApiErrorMessage(err, 'Checkout fehlgeschlagen.'));
} finally {
setBusyScope(null);
}
},
[eventSlug, addons],
);
if (!warnings.length) {
return null;
@@ -215,10 +292,36 @@ function LimitWarningsBanner({
variant={warning.tone === 'danger' ? 'destructive' : 'default'}
className={warning.tone === 'warning' ? 'border-amber-400/40 bg-amber-50 text-amber-900' : undefined}
>
<AlertDescription className="flex items-center gap-2 text-sm">
<AlertTriangle className="h-4 w-4" />
{warning.message}
</AlertDescription>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<AlertDescription className="flex items-center gap-2 text-sm">
<AlertTriangle className="h-4 w-4" />
{warning.message}
</AlertDescription>
{warning.scope === 'photos' || warning.scope === 'gallery' ? (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
<Button
variant="outline"
size="sm"
onClick={() => { void handleCheckout(warning.scope as 'photos' | 'gallery'); }}
disabled={busyScope === warning.scope}
>
<ShoppingCart className="mr-2 h-4 w-4" />
{warning.scope === 'photos'
? translate('buyMorePhotos', { defaultValue: 'Mehr Fotos freischalten' })
: translate('extendGallery', { defaultValue: 'Galerie verlängern' })}
</Button>
<div className="text-xs text-slate-500">
<AddonsPicker
addons={addons}
scope={warning.scope as 'photos' | 'gallery'}
onCheckout={(key) => { void handleCheckout(key); }}
busy={busyScope === warning.scope}
t={(key, fallback) => translate(key, { defaultValue: fallback })}
/>
</div>
</div>
) : null}
</div>
</Alert>
))}
</div>