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