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

@@ -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} />