implemented event package addons with filament resource, event-admin purchase path and notifications, showing up in purchase history
This commit is contained in:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user