// @ts-nocheck import React from 'react'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { AlertTriangle, Camera, Copy, Eye, EyeOff, Filter, Loader2, Search, ShoppingCart, Star, Trash2, X, } from 'lucide-react'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import toast from 'react-hot-toast'; import { AddonsPicker } from '../components/Addons/AddonsPicker'; import { AddonSummaryList } from '../components/Addons/AddonSummaryList'; import { getAddonCatalog, type EventAddonCatalogItem, type EventAddonSummary } from '../api'; import { AdminLayout } from '../components/AdminLayout'; import { createEventAddonCheckout, deletePhoto, featurePhoto, getEvent, getEventPhotos, TenantEvent, TenantPhoto, unfeaturePhoto } from '../api'; import { isAuthError } from '../auth/tokens'; import { getApiErrorMessage } from '../lib/apiError'; import { buildLimitWarnings, type EventLimitSummary } from '../lib/limitWarnings'; import { useTranslation } from 'react-i18next'; import { ADMIN_EVENTS_PATH, ADMIN_EVENT_VIEW_PATH, ADMIN_EVENT_PHOTOBOOTH_PATH } from '../constants'; import { buildEventTabs } from '../lib/eventTabs'; export default function EventPhotosPage() { const params = useParams<{ slug?: string }>(); const [searchParams, setSearchParams] = useSearchParams(); const slug = params.slug ?? searchParams.get('slug') ?? null; const navigate = useNavigate(); const { t } = useTranslation('management'); const { t: tCommon } = useTranslation('common'); const translateLimits = React.useCallback( (key: string, options?: Record) => tCommon(`limits.${key}`, options), [tCommon] ); const [photos, setPhotos] = React.useState([]); const [loading, setLoading] = React.useState(true); const [error, setError] = React.useState(undefined); const [busyId, setBusyId] = React.useState(null); const [limits, setLimits] = React.useState(null); const [addons, setAddons] = React.useState([]); const [eventAddons, setEventAddons] = React.useState([]); const [event, setEvent] = React.useState(null); const [search, setSearch] = React.useState(''); const [statusFilter, setStatusFilter] = React.useState<'all' | 'featured' | 'hidden' | 'photobooth'>('all'); const [selectedIds, setSelectedIds] = React.useState([]); const [bulkBusy, setBulkBusy] = React.useState(false); const photoboothUploads = React.useMemo( () => photos.filter((photo) => photo.ingest_source === 'photobooth').length, [photos], ); const eventTabs = React.useMemo(() => { if (!event || !slug) { return []; } const translateMenu = (key: string, fallback: string) => t(key, { defaultValue: fallback }); return buildEventTabs(event, translateMenu, { photos: photos.length, tasks: event.tasks_count ?? 0, invites: event.active_invites_count ?? event.total_invites_count ?? 0, }); }, [event, photos.length, slug, t]); const load = React.useCallback(async () => { if (!slug) { setLoading(false); return; } setLoading(true); setError(undefined); try { const [photoResult, eventData, catalog] = await Promise.all([ getEventPhotos(slug), getEvent(slug), getAddonCatalog(), ]); setPhotos(photoResult.photos); setLimits(photoResult.limits ?? null); setEventAddons(eventData.addons ?? []); setEvent(eventData); setAddons(catalog); } catch (err) { if (!isAuthError(err)) { setError(getApiErrorMessage(err, 'Fotos konnten nicht geladen werden.')); } } finally { setLoading(false); } }, [slug]); React.useEffect(() => { 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, setSearchParams]); async function handleToggleFeature(photo: TenantPhoto) { if (!slug) return; setBusyId(photo.id); try { const updated = photo.is_featured ? await unfeaturePhoto(slug, photo.id) : await featurePhoto(slug, photo.id); setPhotos((prev) => prev.map((entry) => (entry.id === photo.id ? updated : entry))); } catch (err) { if (!isAuthError(err)) { setError(getApiErrorMessage(err, 'Feature-Aktion fehlgeschlagen.')); } } finally { setBusyId(null); } } async function handleDelete(photo: TenantPhoto) { if (!slug) return; setBusyId(photo.id); try { await deletePhoto(slug, photo.id); setPhotos((prev) => prev.filter((entry) => entry.id !== photo.id)); } catch (err) { if (!isAuthError(err)) { setError(getApiErrorMessage(err, 'Foto konnte nicht entfernt werden.')); } } finally { setBusyId(null); } } async function handleToggleVisibility(photo: TenantPhoto, visible: boolean) { // No dedicated visibility endpoint available; emulate by filtering locally. setPhotos((prev) => prev.map((entry) => entry.id === photo.id ? { ...entry, status: visible ? 'visible' : 'hidden' } : entry, ), ); setSelectedIds((prev) => prev.filter((id) => id !== photo.id)); } const filteredPhotos = React.useMemo(() => { const term = search.trim().toLowerCase(); return photos.filter((photo) => { const matchesSearch = term.length === 0 || (photo.original_name ?? '').toLowerCase().includes(term) || (photo.filename ?? '').toLowerCase().includes(term); if (!matchesSearch) { return false; } if (statusFilter === 'featured') { return Boolean(photo.is_featured); } if (statusFilter === 'hidden') { return photo.status === 'hidden'; } if (statusFilter === 'photobooth') { return photo.ingest_source === 'photobooth'; } return true; }); }, [photos, search, statusFilter]); const toggleSelect = React.useCallback((photoId: number) => { setSelectedIds((prev) => (prev.includes(photoId) ? prev.filter((id) => id !== photoId) : [...prev, photoId])); }, []); const selectAllVisible = React.useCallback(() => { setSelectedIds(filteredPhotos.map((photo) => photo.id)); }, [filteredPhotos]); const clearSelection = React.useCallback(() => { setSelectedIds([]); }, []); const selectedPhotos = React.useMemo( () => photos.filter((photo) => selectedIds.includes(photo.id)), [photos, selectedIds], ); const handleBulkVisibility = React.useCallback( async (visible: boolean) => { if (!selectedPhotos.length) return; setBulkBusy(true); await Promise.all(selectedPhotos.map((photo) => handleToggleVisibility(photo, visible))); setBulkBusy(false); }, [selectedPhotos], ); const handleBulkFeature = React.useCallback( async (featured: boolean) => { if (!slug || !selectedPhotos.length) return; setBulkBusy(true); for (const photo of selectedPhotos) { setBusyId(photo.id); try { const updated = featured ? await featurePhoto(slug, photo.id) : await unfeaturePhoto(slug, photo.id); setPhotos((prev) => prev.map((entry) => (entry.id === photo.id ? updated : entry))); } catch (err) { if (!isAuthError(err)) { setError(getApiErrorMessage(err, 'Feature-Aktion fehlgeschlagen.')); } } finally { setBusyId(null); } } setSelectedIds([]); setBulkBusy(false); }, [selectedPhotos, slug], ); if (!slug) { return ( Kein Slug in der URL gefunden. Kehre zur Event-Liste zurück und wähle dort ein Event aus. ); } const actions = ( ); return ( {error && ( {t('photos.alerts.errorTitle', 'Aktion fehlgeschlagen')} {error} )} {eventAddons.length > 0 && ( {t('events.sections.addons.title', 'Add-ons & Upgrades')} {t('events.sections.addons.description', 'Zusätzliche Kontingente für dieses Event.')} t(key, fallback)} /> )}
{t('photos.gallery.title', 'Galerie')} {t('photos.gallery.description', 'Klick auf ein Foto, um es hervorzuheben oder zu löschen.')}
{t('photos.gallery.photoboothCount', '{{count}} Photobooth-Uploads', { count: photoboothUploads })}
{ void handleBulkVisibility(false); }} onBulkShow={() => { void handleBulkVisibility(true); }} onBulkFeature={() => { void handleBulkFeature(true); }} onBulkUnfeature={() => { void handleBulkFeature(false); }} busy={bulkBusy} /> {loading ? ( ) : filteredPhotos.length === 0 ? ( ) : ( { void handleToggleFeature(photo); }} onToggleVisibility={(photo, visible) => { void handleToggleVisibility(photo, visible); }} onDelete={(photo) => { void handleDelete(photo); }} busyId={busyId} /> )}
); } const LIMIT_WARNING_DISMISS_KEY = 'tenant-admin:dismissed-limit-warnings'; function readDismissedLimitWarnings(): Set { if (typeof window === 'undefined') { return new Set(); } try { const raw = window.localStorage.getItem(LIMIT_WARNING_DISMISS_KEY); if (!raw) { return new Set(); } const parsed = JSON.parse(raw) as string[]; return new Set(parsed); } catch (error) { console.warn('[LimitWarnings] Failed to parse dismissed warnings', error); return new Set(); } } function persistDismissedLimitWarnings(ids: Set) { if (typeof window === 'undefined') { return; } try { window.localStorage.setItem(LIMIT_WARNING_DISMISS_KEY, JSON.stringify(Array.from(ids))); } catch (error) { console.warn('[LimitWarnings] Failed to persist dismissed warnings', error); } } function LimitWarningsBanner({ limits, translate, eventSlug, addons, }: { limits: EventLimitSummary | null; translate: (key: string, options?: Record) => string; eventSlug: string | null; addons: EventAddonCatalogItem[]; }) { const warnings = React.useMemo(() => buildLimitWarnings(limits, translate), [limits, translate]); const [busyScope, setBusyScope] = React.useState(null); const [dismissedIds, setDismissedIds] = React.useState>(() => readDismissedLimitWarnings()); const { t: tCommon } = useTranslation('common'); const dismissLabel = tCommon('actions.dismiss', { defaultValue: 'Hinweis ausblenden' }); 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], ); const handleDismiss = React.useCallback((warningId: string) => { setDismissedIds((prev) => { const next = new Set(prev); next.add(warningId); persistDismissedLimitWarnings(next); return next; }); }, []); const visibleWarnings = warnings.filter((warning) => !dismissedIds.has(warning.id)); if (!visibleWarnings.length) { return null; } return (
{visibleWarnings.map((warning) => (
{warning.message}
{warning.scope === 'photos' || warning.scope === 'gallery' ? (
{warning.scope !== 'guests' ? ( { void handleCheckout(key); }} busy={busyScope === warning.scope} t={(key, fallback) => translate(key, { defaultValue: fallback })} /> ) : null}
) : null}
))}
); } function GallerySkeleton() { return (
{Array.from({ length: 6 }).map((_, index) => (
))}
); } function EmptyGallery({ title, description }: { title: string; description: string }) { return (

{title}

{description}

); } function GalleryToolbar({ search, onSearch, statusFilter, onStatusFilterChange, totalCount, selectionCount, onSelectAll, onClearSelection, onBulkHide, onBulkShow, onBulkFeature, onBulkUnfeature, busy, }: { search: string; onSearch: (value: string) => void; statusFilter: 'all' | 'featured' | 'hidden' | 'photobooth'; onStatusFilterChange: (value: 'all' | 'featured' | 'hidden' | 'photobooth') => void; totalCount: number; selectionCount: number; onSelectAll: () => void; onClearSelection: () => void; onBulkHide: () => void; onBulkShow: () => void; onBulkFeature: () => void; onBulkUnfeature: () => void; busy: boolean; }) { const { t } = useTranslation('management'); const filters = [ { key: 'all', label: t('photos.filters.all', 'Alle') }, { key: 'featured', label: t('photos.filters.featured', 'Highlights') }, { key: 'hidden', label: t('photos.filters.hidden', 'Versteckt') }, { key: 'photobooth', label: t('photos.filters.photobooth', 'Photobooth') }, ] as const; return (
onSearch(event.target.value)} placeholder={t('photos.filters.search', 'Uploads durchsuchen …')} className="h-8 border-0 bg-transparent text-sm focus-visible:ring-0" />
{filters.map((filter) => ( ))}
{t('photos.filters.count', '{{count}} Uploads', { count: totalCount })} {selectionCount > 0 ? ( <> {t('photos.filters.selected', '{{count}} ausgewählt', { count: selectionCount })}
) : ( )}
); } function PhotoGrid({ photos, selectedIds, onToggleSelect, onToggleFeature, onToggleVisibility, onDelete, busyId, }: { photos: TenantPhoto[]; selectedIds: number[]; onToggleSelect: (id: number) => void; onToggleFeature: (photo: TenantPhoto) => void; onToggleVisibility: (photo: TenantPhoto, visible: boolean) => void; onDelete: (photo: TenantPhoto) => void; busyId: number | null; }) { return (
{photos.map((photo) => ( onToggleSelect(photo.id)} onToggleFeature={() => onToggleFeature(photo)} onToggleVisibility={(visible) => onToggleVisibility(photo, visible)} onDelete={() => onDelete(photo)} busy={busyId === photo.id} /> ))}
); } function PhotoCard({ photo, selected, onToggleSelect, onToggleFeature, onToggleVisibility, onDelete, busy, }: { photo: TenantPhoto; selected: boolean; onToggleSelect: () => void; onToggleFeature: () => void; onToggleVisibility: (visible: boolean) => void; onDelete: () => void; busy: boolean; }) { const { t } = useTranslation('management'); const hidden = photo.status === 'hidden'; return (
{photo.original_name {photo.is_featured && ( Highlight )}
{t('photos.gallery.likes', 'Likes: {{count}}', { count: photo.likes_count })} {t('photos.gallery.uploader', 'Uploader: {{name}}', { name: photo.uploader_name ?? 'Unbekannt' })}
); }