Files
fotospiel-app/resources/js/admin/mobile/EventPhotosPage.tsx
Codex Agent b780d82d62 Added Phase‑1 continuation work across deep links, offline moderation queue, and admin push.
resources/js/admin/mobile/lib.
  - Admin push is end‑to‑end: new backend model/migration/service/job + API endpoints, admin runtime config, push‑aware
    service worker, and a settings toggle via useAdminPushSubscription. Notifications now auto‑refresh on push.
  - New PHP/JS tests: admin push API feature test and queue/haptics unit tests
  Added admin-specific PWA icon assets and wired them into the admin manifest, service worker, and admin shell, plus a
  new “Device & permissions” card in mobile Settings with a persistent storage action and translations.
  Details: public/manifest.json, public/admin-sw.js, resources/views/admin.blade.php, new icons in public/; new hook
  resources/js/admin/mobile/hooks/useDevicePermissions.ts, helpers/tests in resources/js/admin/mobile/lib/
  devicePermissions.ts + resources/js/admin/mobile/lib/devicePermissions.test.ts, and Settings UI updates in resources/
  js/admin/mobile/SettingsPage.tsx with copy in resources/js/admin/i18n/locales/en/management.json and resources/js/
admin/i18n/locales/de/management.json.
2025-12-28 15:00:47 +01:00

1233 lines
43 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React from 'react';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Image as ImageIcon, RefreshCcw, Filter, Check } 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 } 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 {
enqueuePhotoAction,
loadPhotoQueue,
removePhotoAction,
replacePhotoQueue,
type PhotoModerationAction,
} from './lib/photoModerationQueue';
import { ADMIN_EVENT_PHOTOS_PATH } from '../constants';
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 [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 [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 basePhotosPath = slug ? ADMIN_EVENT_PHOTOS_PATH(slug) : adminPath('/mobile/events');
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 (parsedPhotoId === null) {
if (!photoIdParam) {
setLightboxId(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, options?: { replace?: boolean }) => {
setLightboxId(photoId);
if (!slug) {
return;
}
const nextPath = photoId ? `${basePhotosPath}/${photoId}` : basePhotosPath;
if (location.pathname !== nextPath) {
navigate(`${nextPath}${location.search}`, { replace: options?.replace ?? false });
}
},
[basePhotosPath, location.pathname, location.search, navigate, slug],
);
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, { replace: true });
return;
}
if (absX > swipeThreshold) {
const nextIndex = x < 0 ? lightboxIndex + 1 : lightboxIndex - 1;
if (nextIndex >= 0 && nextIndex < photos.length) {
setLightboxWithUrl(photos[nextIndex]?.id ?? null, { replace: true });
}
}
},
[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={() => navigate(-1)}
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>
</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" color={muted}>
{t('mobilePhotos.empty', 'No photos found.')}
</Text>
</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);
return (
<Pressable
key={photo.id}
onPress={() => (selectionMode ? toggleSelection(photo.id) : setLightboxWithUrl(photo.id))}
>
<YStack
borderRadius={10}
overflow="hidden"
borderWidth={1}
borderColor={isSelected ? infoBorder : border}
>
<motion.img
layoutId={`photo-${photo.id}`}
src={photo.thumbnail_url ?? photo.url ?? undefined}
alt={photo.caption ?? 'Photo'}
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}
</YStack>
</Pressable>
);
})}
</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={lightbox.url ?? lightbox.thumbnail_url ?? undefined}
alt={lightbox.caption ?? 'Photo'}
style={{ width: '100%', maxHeight: '60vh', objectFit: 'contain', background: backdrop }}
/>
</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, { replace: true })}
/>
</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 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>
);
}