app.
What’s done
locales/en/mobile.json and resources/js/admin/i18n/locales/de/mobile.json.
- Error recovery CTAs on Photos, Notifications, Tasks, and QR screens so users can retry without a full reload in resources/js/admin/mobile/EventPhotosPage.tsx, resources/js/admin/mobile/NotificationsPage.tsx, resources/js/admin/
mobile/EventTasksPage.tsx, resources/js/admin/mobile/QrPrintPage.tsx.
- QR share uses native share sheet when available, with clipboard fallback in resources/js/admin/mobile/
QrPrintPage.tsx.
- Lazy‑loaded photo grid thumbnails for better performance in resources/js/admin/mobile/EventPhotosPage.tsx.
- New helper + tests for queue count logic in resources/js/admin/mobile/lib/queueStatus.ts and resources/js/admin/
mobile/lib/queueStatus.test.ts.
1506 lines
52 KiB
TypeScript
1506 lines
52 KiB
TypeScript
import React from 'react';
|
||
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
||
import { useTranslation } from 'react-i18next';
|
||
import { Image as ImageIcon, RefreshCcw, Filter, Check, Eye, EyeOff, Sparkles } from 'lucide-react';
|
||
import { YStack, XStack } from '@tamagui/stacks';
|
||
import { SizableText as Text } from '@tamagui/text';
|
||
import { Pressable } from '@tamagui/react-native-web-lite';
|
||
import { AnimatePresence, motion, useAnimationControls, type PanInfo } from 'framer-motion';
|
||
import { MobileShell, HeaderActionButton } from './components/MobileShell';
|
||
import { MobileCard, PillBadge, CTAButton, SkeletonCard } from './components/Primitives';
|
||
import { MobileField, MobileInput, MobileSelect } from './components/FormControls';
|
||
import {
|
||
getEventPhotos,
|
||
getEventPhoto,
|
||
updatePhotoVisibility,
|
||
featurePhoto,
|
||
unfeaturePhoto,
|
||
updatePhotoStatus,
|
||
TenantPhoto,
|
||
EventAddonCatalogItem,
|
||
createEventAddonCheckout,
|
||
getAddonCatalog,
|
||
EventAddonSummary,
|
||
EventLimitSummary,
|
||
getEvent,
|
||
} from '../api';
|
||
import toast from 'react-hot-toast';
|
||
import { isAuthError } from '../auth/tokens';
|
||
import { getApiErrorMessage } from '../lib/apiError';
|
||
import { MobileSheet } from './components/Sheet';
|
||
import { useEventContext } from '../context/EventContext';
|
||
import { useTheme } from '@tamagui/core';
|
||
import { buildLimitWarnings } from '../lib/limitWarnings';
|
||
import { adminPath } from '../constants';
|
||
import { scopeDefaults, selectAddonKeyForScope } from './addons';
|
||
import { LegalConsentSheet } from './components/LegalConsentSheet';
|
||
import { triggerHaptic } from './lib/haptics';
|
||
import { useOnlineStatus } from './hooks/useOnlineStatus';
|
||
import { useBackNavigation } from './hooks/useBackNavigation';
|
||
import {
|
||
enqueuePhotoAction,
|
||
loadPhotoQueue,
|
||
removePhotoAction,
|
||
replacePhotoQueue,
|
||
type PhotoModerationAction,
|
||
} from './lib/photoModerationQueue';
|
||
import { resolvePhotoSwipeAction, type SwipeModerationAction } from './lib/photoModerationSwipe';
|
||
import { resolveLightboxSources } from './lib/lightboxImage';
|
||
|
||
type FilterKey = 'all' | 'featured' | 'hidden' | 'pending';
|
||
|
||
export default function MobileEventPhotosPage() {
|
||
const { slug: slugParam, photoId: photoIdParam } = useParams<{ slug?: string; photoId?: string }>();
|
||
const { activeEvent, selectEvent } = useEventContext();
|
||
const slug = slugParam ?? activeEvent?.slug ?? null;
|
||
const navigate = useNavigate();
|
||
const location = useLocation();
|
||
const { t } = useTranslation('management');
|
||
|
||
const [photos, setPhotos] = React.useState<TenantPhoto[]>([]);
|
||
const [filter, setFilter] = React.useState<FilterKey>('all');
|
||
const [page, setPage] = React.useState(1);
|
||
const [loading, setLoading] = React.useState(true);
|
||
const [error, setError] = React.useState<string | null>(null);
|
||
const [busyId, setBusyId] = React.useState<number | null>(null);
|
||
const [totalCount, setTotalCount] = React.useState<number>(0);
|
||
const [hasMore, setHasMore] = React.useState(false);
|
||
const [search, setSearch] = React.useState('');
|
||
const [showFilters, setShowFilters] = React.useState(false);
|
||
const [uploaderFilter, setUploaderFilter] = React.useState('');
|
||
const [onlyFeatured, setOnlyFeatured] = React.useState(false);
|
||
const [onlyHidden, setOnlyHidden] = React.useState(false);
|
||
const [lightboxId, setLightboxId] = React.useState<number | null>(null);
|
||
const [lightboxImageSrc, setLightboxImageSrc] = React.useState<string | null>(null);
|
||
const [pendingPhotoId, setPendingPhotoId] = React.useState<number | null>(null);
|
||
const [syncingQueue, setSyncingQueue] = React.useState(false);
|
||
const [selectionMode, setSelectionMode] = React.useState(false);
|
||
const [selectedIds, setSelectedIds] = React.useState<number[]>([]);
|
||
const [bulkBusy, setBulkBusy] = React.useState(false);
|
||
const [limits, setLimits] = React.useState<EventLimitSummary | null>(null);
|
||
const [catalogAddons, setCatalogAddons] = React.useState<EventAddonCatalogItem[]>([]);
|
||
const [eventAddons, setEventAddons] = React.useState<EventAddonSummary[]>([]);
|
||
const [busyScope, setBusyScope] = React.useState<string | null>(null);
|
||
const back = useBackNavigation(slug ? adminPath(`/mobile/events/${slug}`) : adminPath('/mobile/events'));
|
||
const [consentOpen, setConsentOpen] = React.useState(false);
|
||
const [consentTarget, setConsentTarget] = React.useState<{ scope: 'photos' | 'gallery' | 'guests'; addonKey: string } | null>(null);
|
||
const [consentBusy, setConsentBusy] = React.useState(false);
|
||
const [queuedActions, setQueuedActions] = React.useState<PhotoModerationAction[]>(() => loadPhotoQueue());
|
||
const online = useOnlineStatus();
|
||
const syncingQueueRef = React.useRef(false);
|
||
const theme = useTheme();
|
||
const text = String(theme.color?.val ?? '#111827');
|
||
const muted = String(theme.gray?.val ?? '#4b5563');
|
||
const border = String(theme.borderColor?.val ?? '#e5e7eb');
|
||
const infoBg = String(theme.blue3?.val ?? '#e8f1ff');
|
||
const infoBorder = String(theme.blue6?.val ?? '#bfdbfe');
|
||
const danger = String(theme.red10?.val ?? '#b91c1c');
|
||
const surface = String(theme.surface?.val ?? '#ffffff');
|
||
const backdrop = String(theme.gray12?.val ?? '#0f172a');
|
||
|
||
const lightboxIndex = React.useMemo(() => {
|
||
if (lightboxId === null) {
|
||
return -1;
|
||
}
|
||
return photos.findIndex((photo) => photo.id === lightboxId);
|
||
}, [photos, lightboxId]);
|
||
const lightbox = lightboxIndex >= 0 ? photos[lightboxIndex] : null;
|
||
const parsedPhotoId = React.useMemo(() => {
|
||
if (!photoIdParam) {
|
||
return null;
|
||
}
|
||
const parsed = Number(photoIdParam);
|
||
return Number.isFinite(parsed) ? parsed : null;
|
||
}, [photoIdParam]);
|
||
|
||
React.useEffect(() => {
|
||
if (lightboxId !== null && lightboxIndex === -1 && !loading && pendingPhotoId !== lightboxId) {
|
||
setLightboxId(null);
|
||
}
|
||
}, [lightboxId, lightboxIndex, loading, pendingPhotoId]);
|
||
|
||
React.useEffect(() => {
|
||
if (lightboxId !== null) {
|
||
setSelectionMode(false);
|
||
setSelectedIds([]);
|
||
}
|
||
}, [lightboxId]);
|
||
|
||
React.useEffect(() => {
|
||
if (!lightbox) {
|
||
setLightboxImageSrc(null);
|
||
return;
|
||
}
|
||
const sources = resolveLightboxSources(lightbox);
|
||
setLightboxImageSrc(sources.initial);
|
||
|
||
if (!sources.full) {
|
||
return;
|
||
}
|
||
|
||
const loader = new Image();
|
||
loader.onload = () => setLightboxImageSrc(sources.full);
|
||
loader.src = sources.full;
|
||
}, [lightbox]);
|
||
|
||
React.useEffect(() => {
|
||
if (!photoIdParam) {
|
||
setPendingPhotoId(null);
|
||
return;
|
||
}
|
||
|
||
if (parsedPhotoId === null) {
|
||
setPendingPhotoId(null);
|
||
return;
|
||
}
|
||
|
||
setLightboxId(parsedPhotoId);
|
||
setPendingPhotoId(parsedPhotoId);
|
||
}, [parsedPhotoId, photoIdParam]);
|
||
|
||
React.useEffect(() => {
|
||
if (slugParam && activeEvent?.slug !== slugParam) {
|
||
selectEvent(slugParam);
|
||
}
|
||
}, [slugParam, activeEvent?.slug, selectEvent]);
|
||
|
||
const load = React.useCallback(async () => {
|
||
if (!slug) return;
|
||
setLoading(true);
|
||
setError(null);
|
||
try {
|
||
const status =
|
||
filter === 'hidden' || onlyHidden
|
||
? 'hidden'
|
||
: filter === 'pending'
|
||
? 'pending'
|
||
: undefined;
|
||
const result = await getEventPhotos(slug, {
|
||
page,
|
||
perPage: 20,
|
||
sort: 'desc',
|
||
featured: filter === 'featured' || onlyFeatured,
|
||
status,
|
||
search: search || undefined,
|
||
});
|
||
setPhotos((prev) => (page === 1 ? result.photos : [...prev, ...result.photos]));
|
||
setTotalCount(result.meta?.total ?? result.photos.length);
|
||
setLimits(result.limits ?? null);
|
||
const lastPage = result.meta?.last_page ?? 1;
|
||
setHasMore(page < lastPage);
|
||
const [addons, event] = await Promise.all([
|
||
getAddonCatalog().catch(() => [] as EventAddonCatalogItem[]),
|
||
getEvent(slug).catch(() => null),
|
||
]);
|
||
setCatalogAddons(addons ?? []);
|
||
setEventAddons(event?.addons ?? []);
|
||
} catch (err) {
|
||
if (!isAuthError(err)) {
|
||
setError(getApiErrorMessage(err, t('mobilePhotos.loadFailed', 'Fotos konnten nicht geladen werden.')));
|
||
}
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [slug, filter, t, page, onlyFeatured, onlyHidden, search]);
|
||
|
||
React.useEffect(() => {
|
||
void load();
|
||
}, [load]);
|
||
|
||
React.useEffect(() => {
|
||
if (!location.search || !slug) return;
|
||
const params = new URLSearchParams(location.search);
|
||
if (params.get('addon_success')) {
|
||
toast.success(t('mobileBilling.addonApplied', 'Add-on applied. Limits update shortly.'));
|
||
setPage(1);
|
||
void load();
|
||
params.delete('addon_success');
|
||
navigate({ pathname: location.pathname, search: params.toString() ? `?${params.toString()}` : '' }, { replace: true });
|
||
}
|
||
}, [location.search, slug, load, navigate, t, location.pathname]);
|
||
|
||
React.useEffect(() => {
|
||
setPage(1);
|
||
}, [filter, slug]);
|
||
|
||
const updateQueueState = React.useCallback((queue: PhotoModerationAction[]) => {
|
||
replacePhotoQueue(queue);
|
||
setQueuedActions(queue);
|
||
}, []);
|
||
|
||
const applyOptimisticUpdate = React.useCallback((photoId: number, action: PhotoModerationAction['action']) => {
|
||
setPhotos((prev) =>
|
||
prev.map((photo) => {
|
||
if (photo.id !== photoId) {
|
||
return photo;
|
||
}
|
||
|
||
if (action === 'approve') {
|
||
return { ...photo, status: 'approved' };
|
||
}
|
||
|
||
if (action === 'hide') {
|
||
return { ...photo, status: 'hidden' };
|
||
}
|
||
|
||
if (action === 'show') {
|
||
return { ...photo, status: 'approved' };
|
||
}
|
||
|
||
if (action === 'feature') {
|
||
return { ...photo, is_featured: true };
|
||
}
|
||
|
||
if (action === 'unfeature') {
|
||
return { ...photo, is_featured: false };
|
||
}
|
||
|
||
return photo;
|
||
}),
|
||
);
|
||
}, []);
|
||
|
||
const enqueueModerationAction = React.useCallback(
|
||
(action: PhotoModerationAction['action'], photoId: number) => {
|
||
if (!slug) {
|
||
return;
|
||
}
|
||
const nextQueue = enqueuePhotoAction({ eventSlug: slug, photoId, action });
|
||
setQueuedActions(nextQueue);
|
||
applyOptimisticUpdate(photoId, action);
|
||
toast.success(t('mobilePhotos.queued', 'Aktion gespeichert. Wird synchronisiert, sobald du online bist.'));
|
||
triggerHaptic('selection');
|
||
},
|
||
[applyOptimisticUpdate, slug, t],
|
||
);
|
||
|
||
const syncQueuedActions = React.useCallback(
|
||
async (options?: { silent?: boolean }) => {
|
||
if (!online || syncingQueueRef.current) {
|
||
return;
|
||
}
|
||
|
||
const queue = loadPhotoQueue();
|
||
if (queue.length === 0) {
|
||
return;
|
||
}
|
||
|
||
syncingQueueRef.current = true;
|
||
setSyncingQueue(true);
|
||
|
||
let remaining = queue;
|
||
|
||
for (const entry of queue) {
|
||
try {
|
||
let updated: TenantPhoto | null = null;
|
||
if (entry.action === 'approve') {
|
||
updated = await updatePhotoStatus(entry.eventSlug, entry.photoId, 'approved');
|
||
} else if (entry.action === 'hide') {
|
||
updated = await updatePhotoVisibility(entry.eventSlug, entry.photoId, true);
|
||
} else if (entry.action === 'show') {
|
||
updated = await updatePhotoVisibility(entry.eventSlug, entry.photoId, false);
|
||
} else if (entry.action === 'feature') {
|
||
updated = await featurePhoto(entry.eventSlug, entry.photoId);
|
||
} else if (entry.action === 'unfeature') {
|
||
updated = await unfeaturePhoto(entry.eventSlug, entry.photoId);
|
||
}
|
||
|
||
remaining = removePhotoAction(remaining, entry.id);
|
||
|
||
if (updated && entry.eventSlug === slug) {
|
||
setPhotos((prev) => prev.map((photo) => (photo.id === updated!.id ? updated! : photo)));
|
||
}
|
||
} catch (err) {
|
||
if (!options?.silent) {
|
||
toast.error(t('mobilePhotos.syncFailed', 'Synchronisierung fehlgeschlagen. Bitte später erneut versuchen.'));
|
||
}
|
||
if (isAuthError(err)) {
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
updateQueueState(remaining);
|
||
setSyncingQueue(false);
|
||
syncingQueueRef.current = false;
|
||
},
|
||
[online, slug, t, updateQueueState],
|
||
);
|
||
|
||
React.useEffect(() => {
|
||
if (online) {
|
||
void syncQueuedActions({ silent: true });
|
||
}
|
||
}, [online, syncQueuedActions]);
|
||
|
||
const setLightboxWithUrl = React.useCallback((photoId: number | null) => {
|
||
setLightboxId(photoId);
|
||
}, []);
|
||
|
||
const handleModerationAction = React.useCallback(
|
||
async (action: PhotoModerationAction['action'], photo: TenantPhoto) => {
|
||
if (!slug) {
|
||
return;
|
||
}
|
||
if (!online) {
|
||
enqueueModerationAction(action, photo.id);
|
||
return;
|
||
}
|
||
|
||
setBusyId(photo.id);
|
||
|
||
const successMessage = () => {
|
||
if (action === 'approve') {
|
||
return t('mobilePhotos.approveSuccess', 'Photo approved');
|
||
}
|
||
if (action === 'hide') {
|
||
return t('mobilePhotos.hideSuccess', 'Photo hidden');
|
||
}
|
||
if (action === 'show') {
|
||
return t('mobilePhotos.showSuccess', 'Photo shown');
|
||
}
|
||
if (action === 'feature') {
|
||
return t('mobilePhotos.featureSuccess', 'Als Highlight markiert');
|
||
}
|
||
return t('mobilePhotos.unfeatureSuccess', 'Highlight entfernt');
|
||
};
|
||
|
||
const errorMessage = () => {
|
||
if (action === 'approve') {
|
||
return t('mobilePhotos.approveFailed', 'Freigabe fehlgeschlagen.');
|
||
}
|
||
if (action === 'hide' || action === 'show') {
|
||
return t('mobilePhotos.visibilityFailed', 'Sichtbarkeit konnte nicht geändert werden.');
|
||
}
|
||
return t('mobilePhotos.featureFailed', 'Feature konnte nicht geändert werden.');
|
||
};
|
||
|
||
try {
|
||
let updated: TenantPhoto;
|
||
if (action === 'approve') {
|
||
updated = await updatePhotoStatus(slug, photo.id, 'approved');
|
||
} else if (action === 'hide') {
|
||
updated = await updatePhotoVisibility(slug, photo.id, true);
|
||
} else if (action === 'show') {
|
||
updated = await updatePhotoVisibility(slug, photo.id, false);
|
||
} else if (action === 'feature') {
|
||
updated = await featurePhoto(slug, photo.id);
|
||
} else {
|
||
updated = await unfeaturePhoto(slug, photo.id);
|
||
}
|
||
|
||
setPhotos((prev) => prev.map((p) => (p.id === photo.id ? updated : p)));
|
||
toast.success(successMessage());
|
||
triggerHaptic(action === 'approve' ? 'success' : 'medium');
|
||
} catch (err) {
|
||
if (!isAuthError(err)) {
|
||
setError(getApiErrorMessage(err, errorMessage()));
|
||
toast.error(errorMessage());
|
||
}
|
||
} finally {
|
||
setBusyId(null);
|
||
}
|
||
},
|
||
[enqueueModerationAction, online, slug, t],
|
||
);
|
||
|
||
React.useEffect(() => {
|
||
if (!slug || pendingPhotoId === null) {
|
||
return;
|
||
}
|
||
|
||
if (photos.some((photo) => photo.id === pendingPhotoId)) {
|
||
setPendingPhotoId(null);
|
||
return;
|
||
}
|
||
|
||
if (loading) {
|
||
return;
|
||
}
|
||
|
||
let active = true;
|
||
void (async () => {
|
||
try {
|
||
const fetched = await getEventPhoto(slug, pendingPhotoId);
|
||
if (!active) {
|
||
return;
|
||
}
|
||
setPhotos((prev) => {
|
||
if (prev.some((photo) => photo.id === fetched.id)) {
|
||
return prev;
|
||
}
|
||
return [fetched, ...prev];
|
||
});
|
||
} catch (err) {
|
||
if (!isAuthError(err)) {
|
||
toast.error(t('mobilePhotos.loadFailed', 'Fotos konnten nicht geladen werden.'));
|
||
}
|
||
if (active) {
|
||
setLightboxWithUrl(null, { replace: true });
|
||
}
|
||
} finally {
|
||
if (active) {
|
||
setPendingPhotoId(null);
|
||
}
|
||
}
|
||
})();
|
||
|
||
return () => {
|
||
active = false;
|
||
};
|
||
}, [pendingPhotoId, slug, photos, loading, t, setLightboxWithUrl]);
|
||
|
||
async function toggleVisibility(photo: TenantPhoto) {
|
||
const action = photo.status === 'hidden' ? 'show' : 'hide';
|
||
await handleModerationAction(action, photo);
|
||
}
|
||
|
||
async function toggleFeature(photo: TenantPhoto) {
|
||
const action = photo.is_featured ? 'unfeature' : 'feature';
|
||
await handleModerationAction(action, photo);
|
||
}
|
||
|
||
async function approvePhoto(photo: TenantPhoto) {
|
||
await handleModerationAction('approve', photo);
|
||
}
|
||
|
||
const selectedPhotos = React.useMemo(
|
||
() => photos.filter((photo) => selectedIds.includes(photo.id)),
|
||
[photos, selectedIds],
|
||
);
|
||
const hasPendingSelection = selectedPhotos.some((photo) => photo.status === 'pending');
|
||
const hasHiddenSelection = selectedPhotos.some((photo) => photo.status === 'hidden');
|
||
const hasVisibleSelection = selectedPhotos.some((photo) => photo.status !== 'hidden');
|
||
const hasFeaturedSelection = selectedPhotos.some((photo) => photo.is_featured);
|
||
const hasUnfeaturedSelection = selectedPhotos.some((photo) => !photo.is_featured);
|
||
const queuedEventCount = React.useMemo(() => {
|
||
if (!slug) {
|
||
return queuedActions.length;
|
||
}
|
||
return queuedActions.filter((action) => action.eventSlug === slug).length;
|
||
}, [queuedActions, slug]);
|
||
|
||
function toggleSelection(id: number) {
|
||
setSelectedIds((prev) => (prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id]));
|
||
}
|
||
|
||
function clearSelection() {
|
||
setSelectedIds([]);
|
||
setSelectionMode(false);
|
||
}
|
||
|
||
const handleLightboxDragEnd = React.useCallback(
|
||
(_event: PointerEvent, info: { offset: { x: number; y: number } }) => {
|
||
if (lightboxIndex < 0) {
|
||
return;
|
||
}
|
||
const { x, y } = info.offset;
|
||
const absX = Math.abs(x);
|
||
const absY = Math.abs(y);
|
||
const swipeThreshold = 80;
|
||
const dismissThreshold = 90;
|
||
|
||
if (absY > absX && y > dismissThreshold) {
|
||
setLightboxWithUrl(null);
|
||
return;
|
||
}
|
||
|
||
if (absX > swipeThreshold) {
|
||
const nextIndex = x < 0 ? lightboxIndex + 1 : lightboxIndex - 1;
|
||
if (nextIndex >= 0 && nextIndex < photos.length) {
|
||
setLightboxWithUrl(photos[nextIndex]?.id ?? null);
|
||
}
|
||
}
|
||
},
|
||
[lightboxIndex, photos, setLightboxWithUrl],
|
||
);
|
||
|
||
async function applyBulkAction(action: 'approve' | 'hide' | 'show' | 'feature' | 'unfeature') {
|
||
if (!slug || bulkBusy || selectedPhotos.length === 0) return;
|
||
setBulkBusy(true);
|
||
const targets = selectedPhotos.filter((photo) => {
|
||
if (action === 'approve') return photo.status === 'pending';
|
||
if (action === 'hide') return photo.status !== 'hidden';
|
||
if (action === 'show') return photo.status === 'hidden';
|
||
if (action === 'feature') return !photo.is_featured;
|
||
if (action === 'unfeature') return photo.is_featured;
|
||
return false;
|
||
});
|
||
if (targets.length === 0) {
|
||
setBulkBusy(false);
|
||
return;
|
||
}
|
||
|
||
if (!online) {
|
||
let nextQueue: PhotoModerationAction[] = [];
|
||
targets.forEach((photo) => {
|
||
nextQueue = enqueuePhotoAction({ eventSlug: slug, photoId: photo.id, action });
|
||
applyOptimisticUpdate(photo.id, action);
|
||
});
|
||
setQueuedActions(nextQueue);
|
||
toast.success(t('mobilePhotos.queued', 'Aktion gespeichert. Wird synchronisiert, sobald du online bist.'));
|
||
triggerHaptic('selection');
|
||
setBulkBusy(false);
|
||
return;
|
||
}
|
||
try {
|
||
const results = await Promise.allSettled(
|
||
targets.map(async (photo) => {
|
||
if (action === 'approve') {
|
||
return await updatePhotoStatus(slug, photo.id, 'approved');
|
||
}
|
||
if (action === 'hide') {
|
||
return await updatePhotoVisibility(slug, photo.id, true);
|
||
}
|
||
if (action === 'show') {
|
||
return await updatePhotoVisibility(slug, photo.id, false);
|
||
}
|
||
if (action === 'feature') {
|
||
return await featurePhoto(slug, photo.id);
|
||
}
|
||
return await unfeaturePhoto(slug, photo.id);
|
||
}),
|
||
);
|
||
|
||
const updates = results
|
||
.filter((result): result is PromiseFulfilledResult<TenantPhoto> => result.status === 'fulfilled')
|
||
.map((result) => result.value);
|
||
|
||
if (updates.length) {
|
||
setPhotos((prev) => prev.map((photo) => updates.find((update) => update.id === photo.id) ?? photo));
|
||
toast.success(t('mobilePhotos.bulkUpdated', 'Bulk update applied'));
|
||
triggerHaptic('success');
|
||
}
|
||
} catch {
|
||
toast.error(t('mobilePhotos.bulkFailed', 'Bulk update failed'));
|
||
} finally {
|
||
setBulkBusy(false);
|
||
}
|
||
}
|
||
|
||
function resolveScopeAndAddonKey(scopeOrKey: 'photos' | 'gallery' | 'guests' | string) {
|
||
const scope =
|
||
scopeOrKey === 'photos' || scopeOrKey === 'gallery' || scopeOrKey === 'guests'
|
||
? scopeOrKey
|
||
: scopeOrKey.includes('gallery')
|
||
? 'gallery'
|
||
: scopeOrKey.includes('guest')
|
||
? 'guests'
|
||
: 'photos';
|
||
|
||
const addonKey =
|
||
scopeOrKey === 'photos' || scopeOrKey === 'gallery' || scopeOrKey === 'guests'
|
||
? selectAddonKeyForScope(catalogAddons, scope)
|
||
: scopeOrKey;
|
||
|
||
return { scope, addonKey };
|
||
}
|
||
|
||
function startAddonCheckout(scopeOrKey: 'photos' | 'gallery' | 'guests' | string) {
|
||
if (!slug) return;
|
||
const { scope, addonKey } = resolveScopeAndAddonKey(scopeOrKey);
|
||
setConsentTarget({ scope, addonKey });
|
||
setConsentOpen(true);
|
||
}
|
||
|
||
async function confirmAddonCheckout(consents: { acceptedTerms: boolean; acceptedWaiver: boolean }) {
|
||
if (!slug || !consentTarget) return;
|
||
|
||
const currentUrl = typeof window !== 'undefined' ? `${window.location.origin}${adminPath(`/mobile/events/${slug}/photos`)}` : '';
|
||
const successUrl = `${currentUrl}?addon_success=1`;
|
||
setBusyScope(consentTarget.scope);
|
||
setConsentBusy(true);
|
||
try {
|
||
const checkout = await createEventAddonCheckout(slug, {
|
||
addon_key: consentTarget.addonKey,
|
||
quantity: 1,
|
||
success_url: successUrl,
|
||
cancel_url: currentUrl,
|
||
accepted_terms: consents.acceptedTerms,
|
||
accepted_waiver: consents.acceptedWaiver,
|
||
});
|
||
if (checkout.checkout_url) {
|
||
window.location.href = checkout.checkout_url;
|
||
} else {
|
||
toast.error(t('events.errors.checkoutMissing', 'Checkout could not be started.'));
|
||
}
|
||
} catch (err) {
|
||
toast.error(getApiErrorMessage(err, t('events.errors.checkoutFailed', 'Add-on checkout failed.')));
|
||
} finally {
|
||
setConsentBusy(false);
|
||
setConsentOpen(false);
|
||
setConsentTarget(null);
|
||
setBusyScope(null);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<MobileShell
|
||
activeTab="uploads"
|
||
title={t('mobilePhotos.title', 'Photo moderation')}
|
||
onBack={back}
|
||
headerActions={
|
||
<XStack space="$3">
|
||
<HeaderActionButton
|
||
onPress={() => {
|
||
if (selectionMode) {
|
||
clearSelection();
|
||
} else {
|
||
setSelectionMode(true);
|
||
}
|
||
}}
|
||
ariaLabel={selectionMode ? t('common.done', 'Done') : t('common.select', 'Select')}
|
||
>
|
||
<Text fontSize="$xs" fontWeight="800" color={text}>
|
||
{selectionMode ? t('common.done', 'Done') : t('common.select', 'Select')}
|
||
</Text>
|
||
</HeaderActionButton>
|
||
<HeaderActionButton onPress={() => setShowFilters(true)} ariaLabel={t('mobilePhotos.filtersTitle', 'Filter')}>
|
||
<Filter size={18} color={text} />
|
||
</HeaderActionButton>
|
||
<HeaderActionButton onPress={() => load()} ariaLabel={t('common.refresh', 'Refresh')}>
|
||
<RefreshCcw size={18} color={text} />
|
||
</HeaderActionButton>
|
||
</XStack>
|
||
}
|
||
>
|
||
{error ? (
|
||
<MobileCard>
|
||
<Text fontWeight="700" color={danger}>
|
||
{error}
|
||
</Text>
|
||
<CTAButton
|
||
label={t('common.retry', 'Retry')}
|
||
tone="ghost"
|
||
fullWidth={false}
|
||
onPress={() => load()}
|
||
/>
|
||
</MobileCard>
|
||
) : null}
|
||
|
||
{queuedEventCount > 0 ? (
|
||
<MobileCard>
|
||
<XStack alignItems="center" justifyContent="space-between" space="$2">
|
||
<YStack space="$1" flex={1}>
|
||
<Text fontSize="$sm" fontWeight="700" color={text}>
|
||
{t('mobilePhotos.queueTitle', 'Änderungen warten auf Sync')}
|
||
</Text>
|
||
<Text fontSize="$xs" color={muted}>
|
||
{online
|
||
? t('mobilePhotos.queueOnline', '{{count}} Aktionen bereit zum Synchronisieren.', {
|
||
count: queuedEventCount,
|
||
})
|
||
: t('mobilePhotos.queueOffline', '{{count}} Aktionen gespeichert – offline.', {
|
||
count: queuedEventCount,
|
||
})}
|
||
</Text>
|
||
</YStack>
|
||
<CTAButton
|
||
label={online ? t('mobilePhotos.queueSync', 'Sync') : t('mobilePhotos.queueWaiting', 'Offline')}
|
||
onPress={() => syncQueuedActions()}
|
||
tone="ghost"
|
||
fullWidth={false}
|
||
disabled={!online}
|
||
loading={syncingQueue}
|
||
/>
|
||
</XStack>
|
||
</MobileCard>
|
||
) : null}
|
||
|
||
<MobileInput
|
||
type="search"
|
||
value={search}
|
||
onChange={(e) => {
|
||
setSearch(e.target.value);
|
||
setPage(1);
|
||
}}
|
||
placeholder={t('photos.filters.search', 'Search uploads …')}
|
||
compact
|
||
style={{ marginBottom: 12 }}
|
||
/>
|
||
|
||
<XStack space="$2" flexWrap="wrap">
|
||
{(['all', 'featured', 'pending', 'hidden'] as FilterKey[]).map((key) => (
|
||
<Pressable key={key} onPress={() => setFilter(key)} style={{ flex: 1 }}>
|
||
<MobileCard
|
||
backgroundColor={filter === key ? infoBg : surface}
|
||
borderColor={filter === key ? infoBorder : border}
|
||
padding="$2.5"
|
||
>
|
||
<Text fontSize="$sm" fontWeight="700" textAlign="center" color={text}>
|
||
{key === 'all'
|
||
? t('common.all', 'All')
|
||
: key === 'featured'
|
||
? t('photos.filters.featured', 'Featured')
|
||
: key === 'pending'
|
||
? t('photos.filters.pending', 'Pending')
|
||
: t('photos.filters.hidden', 'Hidden')}
|
||
</Text>
|
||
</MobileCard>
|
||
</Pressable>
|
||
))}
|
||
</XStack>
|
||
|
||
{!loading ? (
|
||
<LimitWarnings
|
||
limits={limits}
|
||
addons={catalogAddons}
|
||
onCheckout={startAddonCheckout}
|
||
busyScope={busyScope}
|
||
translate={translateLimits(t)}
|
||
textColor={text}
|
||
borderColor={border}
|
||
/>
|
||
) : null}
|
||
|
||
{loading ? (
|
||
<YStack space="$2">
|
||
{Array.from({ length: 4 }).map((_, idx) => (
|
||
<SkeletonCard key={`ph-${idx}`} height={100} />
|
||
))}
|
||
</YStack>
|
||
) : photos.length === 0 ? (
|
||
<MobileCard alignItems="center" justifyContent="center" space="$2">
|
||
<ImageIcon size={28} color={muted} />
|
||
<Text fontSize="$sm" fontWeight="700" color={text}>
|
||
{t('mobilePhotos.emptyTitle', 'No uploads yet')}
|
||
</Text>
|
||
<Text fontSize="$xs" color={muted} textAlign="center">
|
||
{t('mobilePhotos.emptyBody', 'Share the QR code so guests can start uploading photos.')}
|
||
</Text>
|
||
{slug ? (
|
||
<CTAButton
|
||
label={t('mobilePhotos.emptyAction', 'Share QR code')}
|
||
tone="ghost"
|
||
fullWidth={false}
|
||
onPress={() => navigate(adminPath(`/mobile/events/${slug}/qr`))}
|
||
/>
|
||
) : null}
|
||
</MobileCard>
|
||
) : (
|
||
<YStack space="$3">
|
||
<Text fontSize="$sm" color={muted}>
|
||
{t('mobilePhotos.count', '{{count}} photos', { count: totalCount })}
|
||
</Text>
|
||
<div
|
||
style={{
|
||
display: 'grid',
|
||
gridTemplateColumns: 'repeat(3, minmax(0, 1fr))',
|
||
gap: 8,
|
||
}}
|
||
>
|
||
{photos.map((photo) => {
|
||
const isSelected = selectedIds.includes(photo.id);
|
||
const swipeDisabled = selectionMode || busyId === photo.id;
|
||
return (
|
||
<PhotoSwipeCard
|
||
key={photo.id}
|
||
photo={photo}
|
||
disabled={swipeDisabled}
|
||
onOpen={() => (selectionMode ? toggleSelection(photo.id) : setLightboxWithUrl(photo.id))}
|
||
onModerate={(action) => handleModerationAction(action, photo)}
|
||
>
|
||
<YStack
|
||
borderRadius={10}
|
||
overflow="hidden"
|
||
borderWidth={1}
|
||
borderColor={isSelected ? infoBorder : border}
|
||
position="relative"
|
||
>
|
||
<motion.img
|
||
layoutId={`photo-${photo.id}`}
|
||
src={photo.thumbnail_url ?? photo.url ?? undefined}
|
||
alt={photo.caption ?? 'Photo'}
|
||
loading="lazy"
|
||
decoding="async"
|
||
style={{ width: '100%', height: 110, objectFit: 'cover' }}
|
||
/>
|
||
<XStack position="absolute" top={6} left={6} space="$1">
|
||
{photo.is_featured ? <PillBadge tone="warning">{t('photos.filters.featured', 'Featured')}</PillBadge> : null}
|
||
{photo.status === 'pending' ? (
|
||
<PillBadge tone="warning">{t('photos.filters.pending', 'Pending')}</PillBadge>
|
||
) : null}
|
||
{photo.status === 'hidden' ? <PillBadge tone="muted">{t('photos.filters.hidden', 'Hidden')}</PillBadge> : null}
|
||
</XStack>
|
||
{selectionMode ? (
|
||
<XStack
|
||
position="absolute"
|
||
top={6}
|
||
right={6}
|
||
width={24}
|
||
height={24}
|
||
borderRadius={999}
|
||
alignItems="center"
|
||
justifyContent="center"
|
||
backgroundColor={isSelected ? String(theme.primary?.val ?? '#007AFF') : 'rgba(255,255,255,0.85)'}
|
||
borderWidth={1}
|
||
borderColor={isSelected ? String(theme.primary?.val ?? '#007AFF') : border}
|
||
>
|
||
{isSelected ? <Check size={14} color="white" /> : null}
|
||
</XStack>
|
||
) : null}
|
||
{!selectionMode ? (
|
||
<PhotoQuickActions
|
||
photo={photo}
|
||
disabled={busyId === photo.id}
|
||
onAction={(action) => handleModerationAction(action, photo)}
|
||
muted={muted}
|
||
surface={surface}
|
||
/>
|
||
) : null}
|
||
</YStack>
|
||
</PhotoSwipeCard>
|
||
);
|
||
})}
|
||
</div>
|
||
{hasMore ? (
|
||
<CTAButton label={t('common.loadMore', 'Mehr laden')} onPress={() => setPage((prev) => prev + 1)} />
|
||
) : null}
|
||
</YStack>
|
||
)}
|
||
|
||
{selectionMode ? (
|
||
<YStack
|
||
position="fixed"
|
||
left={12}
|
||
right={12}
|
||
bottom="calc(env(safe-area-inset-bottom, 0px) + 96px)"
|
||
padding="$3"
|
||
borderRadius={18}
|
||
backgroundColor={surface}
|
||
borderWidth={1}
|
||
borderColor={border}
|
||
shadowColor="#0f172a"
|
||
shadowOpacity={0.18}
|
||
shadowRadius={16}
|
||
shadowOffset={{ width: 0, height: 8 }}
|
||
zIndex={60}
|
||
space="$2"
|
||
>
|
||
<XStack alignItems="center" justifyContent="space-between">
|
||
<Text fontSize="$sm" fontWeight="800" color={text}>
|
||
{t('mobilePhotos.selectedCount', '{{count}} selected', { count: selectedIds.length })}
|
||
</Text>
|
||
<Pressable onPress={() => clearSelection()}>
|
||
<Text fontSize="$sm" color={String(theme.primary?.val ?? '#007AFF')} fontWeight="700">
|
||
{t('common.clear', 'Clear')}
|
||
</Text>
|
||
</Pressable>
|
||
</XStack>
|
||
<XStack space="$2" flexWrap="wrap">
|
||
{hasPendingSelection ? (
|
||
<CTAButton
|
||
label={t('photos.actions.approve', 'Approve')}
|
||
onPress={() => applyBulkAction('approve')}
|
||
fullWidth={false}
|
||
disabled={bulkBusy}
|
||
loading={bulkBusy}
|
||
/>
|
||
) : null}
|
||
{hasVisibleSelection ? (
|
||
<CTAButton
|
||
label={t('photos.actions.hide', 'Hide')}
|
||
onPress={() => applyBulkAction('hide')}
|
||
tone="ghost"
|
||
fullWidth={false}
|
||
disabled={bulkBusy}
|
||
loading={bulkBusy}
|
||
/>
|
||
) : null}
|
||
{hasHiddenSelection ? (
|
||
<CTAButton
|
||
label={t('photos.actions.show', 'Show')}
|
||
onPress={() => applyBulkAction('show')}
|
||
tone="ghost"
|
||
fullWidth={false}
|
||
disabled={bulkBusy}
|
||
loading={bulkBusy}
|
||
/>
|
||
) : null}
|
||
{hasUnfeaturedSelection ? (
|
||
<CTAButton
|
||
label={t('photos.actions.feature', 'Set highlight')}
|
||
onPress={() => applyBulkAction('feature')}
|
||
tone="ghost"
|
||
fullWidth={false}
|
||
disabled={bulkBusy}
|
||
loading={bulkBusy}
|
||
/>
|
||
) : null}
|
||
{hasFeaturedSelection ? (
|
||
<CTAButton
|
||
label={t('photos.actions.unfeature', 'Remove highlight')}
|
||
onPress={() => applyBulkAction('unfeature')}
|
||
tone="ghost"
|
||
fullWidth={false}
|
||
disabled={bulkBusy}
|
||
loading={bulkBusy}
|
||
/>
|
||
) : null}
|
||
</XStack>
|
||
</YStack>
|
||
) : null}
|
||
|
||
<AnimatePresence>
|
||
{lightbox ? (
|
||
<motion.div
|
||
className="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-center justify-center"
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
exit={{ opacity: 0 }}
|
||
>
|
||
<motion.div
|
||
initial={{ y: 12, scale: 0.98, opacity: 0 }}
|
||
animate={{ y: 0, scale: 1, opacity: 1 }}
|
||
exit={{ y: 12, scale: 0.98, opacity: 0 }}
|
||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||
style={{
|
||
width: '100%',
|
||
maxWidth: 520,
|
||
margin: '0 16px',
|
||
background: surface,
|
||
borderRadius: 20,
|
||
overflow: 'hidden',
|
||
boxShadow: '0 10px 30px rgba(0,0,0,0.25)',
|
||
}}
|
||
>
|
||
<motion.div
|
||
drag
|
||
dragElastic={0.2}
|
||
dragConstraints={{ left: -120, right: 120, top: -120, bottom: 120 }}
|
||
dragSnapToOrigin
|
||
onDragEnd={handleLightboxDragEnd}
|
||
style={{ touchAction: 'none' }}
|
||
>
|
||
<motion.img
|
||
layoutId={`photo-${lightbox.id}`}
|
||
src={lightboxImageSrc ?? undefined}
|
||
alt={lightbox.caption ?? 'Photo'}
|
||
loading="eager"
|
||
style={{ width: '100%', maxHeight: '60vh', objectFit: 'contain', background: backdrop }}
|
||
onError={() => {
|
||
if (lightbox?.thumbnail_url && lightboxImageSrc !== lightbox.thumbnail_url) {
|
||
setLightboxImageSrc(lightbox.thumbnail_url);
|
||
}
|
||
}}
|
||
/>
|
||
</motion.div>
|
||
<YStack padding="$3" space="$2">
|
||
<XStack space="$2" alignItems="center">
|
||
<PillBadge tone="muted">{lightbox.uploader_name || t('events.members.roles.guest', 'Guest')}</PillBadge>
|
||
<PillBadge tone="muted">❤️ {lightbox.likes_count ?? 0}</PillBadge>
|
||
{lightbox.status === 'pending' ? (
|
||
<PillBadge tone="warning">{t('photos.filters.pending', 'Pending')}</PillBadge>
|
||
) : null}
|
||
{lightbox.status === 'hidden' ? (
|
||
<PillBadge tone="muted">{t('photos.filters.hidden', 'Hidden')}</PillBadge>
|
||
) : null}
|
||
</XStack>
|
||
<XStack space="$2" flexWrap="wrap">
|
||
{lightbox.status === 'pending' ? (
|
||
<CTAButton
|
||
label={
|
||
busyId === lightbox.id
|
||
? t('common.processing', '...')
|
||
: t('photos.actions.approve', 'Approve')
|
||
}
|
||
onPress={() => approvePhoto(lightbox)}
|
||
style={{ flex: 1, minWidth: 140 }}
|
||
/>
|
||
) : null}
|
||
<CTAButton
|
||
label={
|
||
busyId === lightbox.id
|
||
? t('common.processing', '...')
|
||
: lightbox.is_featured
|
||
? t('photos.actions.unfeature', 'Remove highlight')
|
||
: t('photos.actions.feature', 'Set highlight')
|
||
}
|
||
onPress={() => toggleFeature(lightbox)}
|
||
style={{ flex: 1, minWidth: 140 }}
|
||
/>
|
||
<CTAButton
|
||
label={
|
||
busyId === lightbox.id
|
||
? t('common.processing', '...')
|
||
: lightbox.status === 'hidden'
|
||
? t('photos.actions.show', 'Show')
|
||
: t('photos.actions.hide', 'Hide')
|
||
}
|
||
onPress={() => toggleVisibility(lightbox)}
|
||
style={{ flex: 1, minWidth: 140 }}
|
||
/>
|
||
</XStack>
|
||
<CTAButton
|
||
label={t('common.close', 'Close')}
|
||
tone="ghost"
|
||
onPress={() => setLightboxWithUrl(null)}
|
||
/>
|
||
</YStack>
|
||
</motion.div>
|
||
</motion.div>
|
||
) : null}
|
||
</AnimatePresence>
|
||
|
||
<MobileSheet
|
||
open={showFilters}
|
||
onClose={() => setShowFilters(false)}
|
||
title={t('mobilePhotos.filtersTitle', 'Filter')}
|
||
footer={
|
||
<CTAButton
|
||
label={t('mobilePhotos.applyFilters', 'Apply filters')}
|
||
onPress={() => {
|
||
setPage(1);
|
||
setShowFilters(false);
|
||
void load();
|
||
}}
|
||
/>
|
||
}
|
||
>
|
||
<YStack space="$2">
|
||
<MobileField label={t('mobilePhotos.uploader', 'Uploader')}>
|
||
<MobileInput
|
||
type="text"
|
||
value={uploaderFilter}
|
||
onChange={(e) => setUploaderFilter(e.target.value)}
|
||
placeholder={t('mobilePhotos.uploaderPlaceholder', 'Name or email')}
|
||
compact
|
||
/>
|
||
</MobileField>
|
||
<XStack space="$2" alignItems="center">
|
||
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||
<input
|
||
type="checkbox"
|
||
checked={onlyFeatured}
|
||
onChange={(e) => setOnlyFeatured(e.target.checked)}
|
||
/>
|
||
<Text fontSize="$sm" color={text}>
|
||
{t('mobilePhotos.onlyFeatured', 'Only featured')}
|
||
</Text>
|
||
</label>
|
||
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||
<input
|
||
type="checkbox"
|
||
checked={onlyHidden}
|
||
onChange={(e) => setOnlyHidden(e.target.checked)}
|
||
/>
|
||
<Text fontSize="$sm" color={text}>
|
||
{t('mobilePhotos.onlyHidden', 'Only hidden')}
|
||
</Text>
|
||
</label>
|
||
</XStack>
|
||
<CTAButton
|
||
label={t('common.reset', 'Reset')}
|
||
tone="ghost"
|
||
onPress={() => {
|
||
setUploaderFilter('');
|
||
setOnlyFeatured(false);
|
||
setOnlyHidden(false);
|
||
}}
|
||
/>
|
||
</YStack>
|
||
</MobileSheet>
|
||
|
||
{eventAddons.length ? (
|
||
<YStack marginTop="$3" space="$2">
|
||
<Text fontSize="$sm" color={text} fontWeight="700">
|
||
{t('events.sections.addons.title', 'Add-ons & Upgrades')}
|
||
</Text>
|
||
<EventAddonList addons={eventAddons} textColor={text} mutedColor={muted} />
|
||
</YStack>
|
||
) : null}
|
||
|
||
<LegalConsentSheet
|
||
open={consentOpen}
|
||
onClose={() => {
|
||
if (consentBusy) return;
|
||
setConsentOpen(false);
|
||
setConsentTarget(null);
|
||
}}
|
||
onConfirm={confirmAddonCheckout}
|
||
busy={consentBusy}
|
||
t={t}
|
||
/>
|
||
</MobileShell>
|
||
);
|
||
}
|
||
|
||
type PhotoSwipeCardProps = {
|
||
photo: TenantPhoto;
|
||
disabled?: boolean;
|
||
onOpen: () => void;
|
||
onModerate: (action: PhotoModerationAction['action']) => void;
|
||
children: React.ReactNode;
|
||
};
|
||
|
||
type SwipeActionConfig = {
|
||
label: string;
|
||
bg: string;
|
||
text: string;
|
||
icon: typeof Eye;
|
||
};
|
||
|
||
function PhotoSwipeCard({ photo, disabled = false, onOpen, onModerate, children }: PhotoSwipeCardProps) {
|
||
const { t } = useTranslation('management');
|
||
const theme = useTheme();
|
||
const controls = useAnimationControls();
|
||
const dragged = React.useRef(false);
|
||
const leftAction = resolvePhotoSwipeAction(photo, 'left');
|
||
const rightAction = resolvePhotoSwipeAction(photo, 'right');
|
||
const canSwipe = !disabled && (leftAction || rightAction);
|
||
|
||
const resolveActionConfig = (action: SwipeModerationAction): SwipeActionConfig | null => {
|
||
if (!action) {
|
||
return null;
|
||
}
|
||
if (action === 'approve') {
|
||
return {
|
||
label: t('photos.actions.approve', 'Approve'),
|
||
bg: String(theme.green3?.val ?? '#dcfce7'),
|
||
text: String(theme.green11?.val ?? '#166534'),
|
||
icon: Check,
|
||
};
|
||
}
|
||
if (action === 'hide') {
|
||
return {
|
||
label: t('photos.actions.hide', 'Hide'),
|
||
bg: String(theme.red3?.val ?? '#fee2e2'),
|
||
text: String(theme.red11?.val ?? '#b91c1c'),
|
||
icon: EyeOff,
|
||
};
|
||
}
|
||
return {
|
||
label: t('photos.actions.show', 'Show'),
|
||
bg: String(theme.blue3?.val ?? '#dbeafe'),
|
||
text: String(theme.blue11?.val ?? '#1d4ed8'),
|
||
icon: Eye,
|
||
};
|
||
};
|
||
|
||
const leftConfig = resolveActionConfig(leftAction);
|
||
const rightConfig = resolveActionConfig(rightAction);
|
||
|
||
const handleDrag = (_event: PointerEvent, info: PanInfo) => {
|
||
if (!canSwipe) {
|
||
return;
|
||
}
|
||
dragged.current = Math.abs(info.offset.x) > 6;
|
||
};
|
||
|
||
const handleDragEnd = (_event: PointerEvent, info: PanInfo) => {
|
||
if (!canSwipe) {
|
||
return;
|
||
}
|
||
const swipeThreshold = 64;
|
||
if (info.offset.x > swipeThreshold && rightAction) {
|
||
onModerate(rightAction);
|
||
} else if (info.offset.x < -swipeThreshold && leftAction) {
|
||
onModerate(leftAction);
|
||
}
|
||
dragged.current = false;
|
||
void controls.start({ x: 0, transition: { type: 'spring', stiffness: 320, damping: 26 } });
|
||
};
|
||
|
||
const handlePress = () => {
|
||
if (dragged.current) {
|
||
dragged.current = false;
|
||
return;
|
||
}
|
||
onOpen();
|
||
};
|
||
|
||
return (
|
||
<div style={{ position: 'relative' }}>
|
||
{leftConfig || rightConfig ? (
|
||
<XStack
|
||
alignItems="center"
|
||
justifyContent="space-between"
|
||
paddingHorizontal="$2"
|
||
borderRadius="$3"
|
||
pointerEvents="none"
|
||
style={{ position: 'absolute', inset: 0 }}
|
||
>
|
||
<XStack flex={1} alignItems="center" justifyContent="flex-start">
|
||
{rightConfig ? (
|
||
<XStack
|
||
alignItems="center"
|
||
space="$1"
|
||
paddingHorizontal="$2"
|
||
paddingVertical="$1"
|
||
borderRadius={999}
|
||
backgroundColor={rightConfig.bg}
|
||
>
|
||
<rightConfig.icon size={12} color={rightConfig.text} />
|
||
<Text fontSize="$xs" fontWeight="700" color={rightConfig.text}>
|
||
{rightConfig.label}
|
||
</Text>
|
||
</XStack>
|
||
) : null}
|
||
</XStack>
|
||
<XStack flex={1} alignItems="center" justifyContent="flex-end">
|
||
{leftConfig ? (
|
||
<XStack
|
||
alignItems="center"
|
||
space="$1"
|
||
paddingHorizontal="$2"
|
||
paddingVertical="$1"
|
||
borderRadius={999}
|
||
backgroundColor={leftConfig.bg}
|
||
>
|
||
<leftConfig.icon size={12} color={leftConfig.text} />
|
||
<Text fontSize="$xs" fontWeight="700" color={leftConfig.text}>
|
||
{leftConfig.label}
|
||
</Text>
|
||
</XStack>
|
||
) : null}
|
||
</XStack>
|
||
</XStack>
|
||
) : null}
|
||
<motion.div
|
||
drag={canSwipe ? 'x' : false}
|
||
dragElastic={0.2}
|
||
dragConstraints={{ left: -80, right: 80 }}
|
||
onDrag={handleDrag}
|
||
onDragEnd={handleDragEnd}
|
||
animate={controls}
|
||
initial={{ x: 0 }}
|
||
style={{ touchAction: 'pan-y', position: 'relative', zIndex: 1 }}
|
||
>
|
||
<Pressable onPress={handlePress}>{children}</Pressable>
|
||
</motion.div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
type PhotoQuickActionsProps = {
|
||
photo: TenantPhoto;
|
||
disabled?: boolean;
|
||
muted: string;
|
||
surface: string;
|
||
onAction: (action: PhotoModerationAction['action']) => void;
|
||
};
|
||
|
||
function PhotoQuickActions({ photo, disabled = false, muted, surface, onAction }: PhotoQuickActionsProps) {
|
||
const { t } = useTranslation('management');
|
||
const actionButtons: Array<{ key: PhotoModerationAction['action']; icon: typeof Check; label: string }> = [];
|
||
|
||
if (photo.status === 'pending') {
|
||
actionButtons.push({ key: 'approve', icon: Check, label: t('photos.actions.approve', 'Approve') });
|
||
actionButtons.push({ key: 'hide', icon: EyeOff, label: t('photos.actions.hide', 'Hide') });
|
||
} else if (photo.status === 'hidden') {
|
||
actionButtons.push({ key: 'show', icon: Eye, label: t('photos.actions.show', 'Show') });
|
||
} else {
|
||
actionButtons.push({ key: 'hide', icon: EyeOff, label: t('photos.actions.hide', 'Hide') });
|
||
}
|
||
|
||
if (photo.status !== 'hidden') {
|
||
actionButtons.push({
|
||
key: photo.is_featured ? 'unfeature' : 'feature',
|
||
icon: Sparkles,
|
||
label: photo.is_featured ? t('photos.actions.unfeature', 'Remove highlight') : t('photos.actions.feature', 'Set highlight'),
|
||
});
|
||
}
|
||
|
||
if (actionButtons.length === 0) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<XStack
|
||
position="absolute"
|
||
bottom={6}
|
||
left={6}
|
||
right={6}
|
||
paddingHorizontal="$1"
|
||
paddingVertical="$1"
|
||
borderRadius={12}
|
||
backgroundColor="rgba(15, 23, 42, 0.45)"
|
||
alignItems="center"
|
||
justifyContent="flex-start"
|
||
space="$1"
|
||
>
|
||
{actionButtons.map((action) => (
|
||
<Pressable
|
||
key={action.key}
|
||
disabled={disabled}
|
||
aria-label={action.label}
|
||
onPress={(event) => {
|
||
event.stopPropagation();
|
||
if (!disabled) {
|
||
onAction(action.key);
|
||
}
|
||
}}
|
||
>
|
||
<XStack
|
||
alignItems="center"
|
||
justifyContent="center"
|
||
width={28}
|
||
height={28}
|
||
borderRadius={999}
|
||
backgroundColor={surface}
|
||
>
|
||
<action.icon size={14} color={muted} />
|
||
</XStack>
|
||
</Pressable>
|
||
))}
|
||
</XStack>
|
||
);
|
||
}
|
||
|
||
type LimitTranslator = (key: string, options?: Record<string, unknown>) => string;
|
||
|
||
function translateLimits(t: (key: string, defaultValue?: string, options?: Record<string, unknown>) => string): LimitTranslator {
|
||
const defaults: Record<string, string> = {
|
||
photosBlocked: 'Upload limit reached. Buy more photos to continue.',
|
||
photosWarning: '{{remaining}} of {{limit}} photos remaining.',
|
||
guestsBlocked: 'Guest limit reached.',
|
||
guestsWarning: '{{remaining}} of {{limit}} guests remaining.',
|
||
galleryExpired: 'Gallery expired. Extend to keep it online.',
|
||
galleryWarningDay: 'Gallery expires in {{days}} day.',
|
||
galleryWarningDays: 'Gallery expires in {{days}} days.',
|
||
buyMorePhotos: 'Buy more photos',
|
||
extendGallery: 'Extend gallery',
|
||
buyMoreGuests: 'Add more guests',
|
||
};
|
||
return (key, options) => t(`limits.${key}`, defaults[key] ?? key, options);
|
||
}
|
||
|
||
function LimitWarnings({
|
||
limits,
|
||
addons,
|
||
onCheckout,
|
||
busyScope,
|
||
translate,
|
||
textColor,
|
||
borderColor,
|
||
}: {
|
||
limits: EventLimitSummary | null;
|
||
addons: EventAddonCatalogItem[];
|
||
onCheckout: (scopeOrKey: 'photos' | 'gallery' | string) => void;
|
||
busyScope: string | null;
|
||
translate: LimitTranslator;
|
||
textColor: string;
|
||
borderColor: string;
|
||
}) {
|
||
const warnings = React.useMemo(() => buildLimitWarnings(limits, translate), [limits, translate]);
|
||
|
||
if (!warnings.length) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<YStack space="$2">
|
||
{warnings.map((warning) => (
|
||
<MobileCard key={warning.id} borderColor={borderColor} space="$2">
|
||
<Text fontSize="$sm" color={textColor} fontWeight="700">
|
||
{warning.message}
|
||
</Text>
|
||
{(warning.scope === 'photos' || warning.scope === 'gallery' || warning.scope === 'guests') && addons.length ? (
|
||
<MobileAddonsPicker
|
||
scope={warning.scope}
|
||
addons={addons}
|
||
busy={busyScope === warning.scope}
|
||
onCheckout={onCheckout}
|
||
translate={translate}
|
||
/>
|
||
) : null}
|
||
<CTAButton
|
||
label={
|
||
warning.scope === 'photos'
|
||
? translate('buyMorePhotos')
|
||
: warning.scope === 'gallery'
|
||
? translate('extendGallery')
|
||
: translate('buyMoreGuests')
|
||
}
|
||
onPress={() => onCheckout(warning.scope)}
|
||
loading={busyScope === warning.scope}
|
||
/>
|
||
</MobileCard>
|
||
))}
|
||
</YStack>
|
||
);
|
||
}
|
||
|
||
function MobileAddonsPicker({
|
||
scope,
|
||
addons,
|
||
busy,
|
||
onCheckout,
|
||
translate,
|
||
}: {
|
||
scope: 'photos' | 'gallery' | 'guests';
|
||
addons: EventAddonCatalogItem[];
|
||
busy: boolean;
|
||
onCheckout: (addonKey: string) => void;
|
||
translate: LimitTranslator;
|
||
}) {
|
||
const options = React.useMemo(() => {
|
||
const whitelist = scopeDefaults[scope];
|
||
const filtered = addons.filter((addon) => addon.price_id && whitelist.includes(addon.key));
|
||
return filtered.length ? filtered : addons.filter((addon) => addon.price_id);
|
||
}, [addons, scope]);
|
||
|
||
const [selected, setSelected] = React.useState<string>(() => options[0]?.key ?? selectAddonKeyForScope(addons, scope));
|
||
|
||
React.useEffect(() => {
|
||
if (options[0]?.key) {
|
||
setSelected(options[0].key);
|
||
}
|
||
}, [options]);
|
||
|
||
if (!options.length) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<XStack space="$2" alignItems="center">
|
||
<MobileSelect value={selected} onChange={(event) => setSelected(event.target.value)} style={{ flex: 1 }} compact>
|
||
{options.map((addon) => (
|
||
<option key={addon.key} value={addon.key}>
|
||
{addon.label ?? addon.key}
|
||
</option>
|
||
))}
|
||
</MobileSelect>
|
||
<CTAButton
|
||
label={
|
||
scope === 'gallery'
|
||
? translate('extendGallery')
|
||
: scope === 'guests'
|
||
? translate('buyMoreGuests')
|
||
: translate('buyMorePhotos')
|
||
}
|
||
disabled={!selected || busy}
|
||
onPress={() => selected && onCheckout(selected)}
|
||
loading={busy}
|
||
/>
|
||
</XStack>
|
||
);
|
||
}
|
||
|
||
function EventAddonList({ addons, textColor, mutedColor }: { addons: EventAddonSummary[]; textColor: string; mutedColor: string }) {
|
||
return (
|
||
<YStack space="$2">
|
||
{addons.map((addon) => (
|
||
<MobileCard key={addon.id} borderColor="#e5e7eb" space="$1.5">
|
||
<XStack alignItems="center" justifyContent="space-between">
|
||
<Text fontSize="$sm" fontWeight="700" color={textColor}>
|
||
{addon.label ?? addon.key}
|
||
</Text>
|
||
<PillBadge tone={addon.status === 'completed' ? 'success' : addon.status === 'pending' ? 'warning' : 'muted'}>
|
||
{addon.status}
|
||
</PillBadge>
|
||
</XStack>
|
||
<Text fontSize="$xs" color={mutedColor}>
|
||
{addon.purchased_at ? new Date(addon.purchased_at).toLocaleString() : '—'}
|
||
</Text>
|
||
<XStack space="$2" marginTop="$1" flexWrap="wrap">
|
||
{addon.extra_photos ? <PillBadge tone="muted">+{addon.extra_photos} photos</PillBadge> : null}
|
||
{addon.extra_guests ? <PillBadge tone="muted">+{addon.extra_guests} guests</PillBadge> : null}
|
||
{addon.extra_gallery_days ? <PillBadge tone="muted">+{addon.extra_gallery_days} days</PillBadge> : null}
|
||
</XStack>
|
||
</MobileCard>
|
||
))}
|
||
</YStack>
|
||
);
|
||
}
|