Files
fotospiel-app/resources/js/admin/mobile/EventPhotosPage.tsx
2025-12-12 13:38:06 +01:00

626 lines
22 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.
import React from 'react';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Image as ImageIcon, RefreshCcw, Search, Filter } 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 { MobileShell } from './components/MobileShell';
import { MobileCard, PillBadge, CTAButton } from './components/Primitives';
import {
getEventPhotos,
updatePhotoVisibility,
featurePhoto,
unfeaturePhoto,
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';
type FilterKey = 'all' | 'featured' | 'hidden';
export default function MobileEventPhotosPage() {
const { slug: slugParam } = useParams<{ slug?: 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 [lightbox, setLightbox] = React.useState<TenantPhoto | null>(null);
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 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 baseInputStyle = React.useMemo<React.CSSProperties>(
() => ({
width: '100%',
height: 38,
borderRadius: 10,
border: `1px solid ${border}`,
padding: '0 12px',
fontSize: 13,
background: surface,
color: text,
}),
[border, surface, text],
);
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 result = await getEventPhotos(slug, {
page,
perPage: 20,
sort: 'desc',
featured: filter === 'featured' || onlyFeatured,
status: filter === 'hidden' || onlyHidden ? 'hidden' : undefined,
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]);
async function toggleVisibility(photo: TenantPhoto) {
if (!slug) return;
setBusyId(photo.id);
try {
const updated = await updatePhotoVisibility(slug, photo.id, photo.status === 'hidden');
setPhotos((prev) => prev.map((p) => (p.id === photo.id ? updated : p)));
setLightbox((prev) => (prev && prev.id === photo.id ? updated : prev));
toast.success(
updated.status === 'hidden'
? t('mobilePhotos.hideSuccess', 'Photo hidden')
: t('mobilePhotos.showSuccess', 'Photo shown'),
);
} catch (err) {
if (!isAuthError(err)) {
setError(getApiErrorMessage(err, t('mobilePhotos.visibilityFailed', 'Sichtbarkeit konnte nicht geändert werden.')));
toast.error(t('mobilePhotos.visibilityFailed', 'Sichtbarkeit konnte nicht geändert werden.'));
}
} finally {
setBusyId(null);
}
}
async function toggleFeature(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((p) => (p.id === photo.id ? updated : p)));
setLightbox((prev) => (prev && prev.id === photo.id ? updated : prev));
toast.success(
updated.is_featured
? t('mobilePhotos.featureSuccess', 'Als Highlight markiert')
: t('mobilePhotos.unfeatureSuccess', 'Highlight entfernt'),
);
} catch (err) {
if (!isAuthError(err)) {
setError(getApiErrorMessage(err, t('mobilePhotos.featureFailed', 'Feature konnte nicht geändert werden.')));
toast.error(t('mobilePhotos.featureFailed', 'Feature konnte nicht geändert werden.'));
}
} finally {
setBusyId(null);
}
}
return (
<MobileShell
activeTab="uploads"
title={t('mobilePhotos.title', 'Photo moderation')}
onBack={() => navigate(-1)}
headerActions={
<XStack space="$3">
<Pressable onPress={() => setShowFilters(true)}>
<Filter size={18} color={text} />
</Pressable>
<Pressable onPress={() => load()}>
<RefreshCcw size={18} color={text} />
</Pressable>
</XStack>
}
>
{error ? (
<MobileCard>
<Text fontWeight="700" color={danger}>
{error}
</Text>
</MobileCard>
) : null}
<input
type="search"
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
placeholder={t('photos.filters.search', 'Search uploads …')}
style={{ ...baseInputStyle, marginBottom: 12 }}
/>
<XStack space="$2">
{(['all', 'featured', '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')
: t('photos.filters.hidden', 'Hidden')}
</Text>
</MobileCard>
</Pressable>
))}
</XStack>
{loading ? (
<YStack space="$2">
{Array.from({ length: 4 }).map((_, idx) => (
<MobileCard key={`ph-${idx}`} height={100} opacity={0.6} />
))}
</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">
<LimitWarnings
limits={limits}
addons={catalogAddons}
onCheckout={(scopeOrKey) => { void handleCheckout(scopeOrKey, slug, catalogAddons, setBusyScope, t); }}
busyScope={busyScope}
translate={translateLimits(t)}
textColor={text}
borderColor={border}
/>
<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) => (
<Pressable key={photo.id} onPress={() => setLightbox(photo)}>
<YStack borderRadius={10} overflow="hidden" borderWidth={1} borderColor={border}>
<img
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 === 'hidden' ? <PillBadge tone="muted">{t('photos.filters.hidden', 'Hidden')}</PillBadge> : null}
</XStack>
</YStack>
</Pressable>
))}
</div>
{hasMore ? (
<CTAButton label={t('common.loadMore', 'Mehr laden')} onPress={() => setPage((prev) => prev + 1)} />
) : null}
</YStack>
)}
{lightbox ? (
<div className="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-center justify-center">
<div
style={{
width: '100%',
maxWidth: 520,
margin: '0 16px',
background: surface,
borderRadius: 20,
overflow: 'hidden',
boxShadow: '0 10px 30px rgba(0,0,0,0.25)',
}}
>
<img
src={lightbox.url ?? lightbox.thumbnail_url ?? undefined}
alt={lightbox.caption ?? 'Photo'}
style={{ width: '100%', maxHeight: '60vh', objectFit: 'contain', background: backdrop }}
/>
<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>
</XStack>
<XStack space="$2" flexWrap="wrap">
<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={() => setLightbox(null)} />
</YStack>
</div>
</div>
) : null}
<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">
<Field label={t('mobilePhotos.uploader', 'Uploader')} color={text}>
<input
type="text"
value={uploaderFilter}
onChange={(e) => setUploaderFilter(e.target.value)}
placeholder={t('mobilePhotos.uploaderPlaceholder', 'Name or email')}
style={baseInputStyle}
/>
</Field>
<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}
</MobileShell>
);
}
function Field({ label, color, children }: { label: string; color: string; children: React.ReactNode }) {
return (
<YStack space="$1">
<Text fontSize="$sm" color={color}>
{label}
</Text>
{children}
</YStack>
);
}
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',
};
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') && 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('buyMorePhotos')
}
onPress={() => onCheckout(warning.scope)}
loading={busyScope === warning.scope}
/>
</MobileCard>
))}
</YStack>
);
}
function MobileAddonsPicker({
scope,
addons,
busy,
onCheckout,
translate,
}: {
scope: 'photos' | 'gallery';
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">
<select
value={selected}
onChange={(event) => setSelected(event.target.value)}
style={{
flex: 1,
height: 40,
borderRadius: 10,
border: '1px solid #e5e7eb',
padding: '0 12px',
fontSize: 13,
background: '#fff',
}}
>
{options.map((addon) => (
<option key={addon.key} value={addon.key}>
{addon.label ?? addon.key}
</option>
))}
</select>
<CTAButton
label={scope === 'gallery' ? translate('extendGallery') : 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>
);
}
async function handleCheckout(
scopeOrKey: 'photos' | 'gallery' | string,
slug: string | null,
addons: EventAddonCatalogItem[],
setBusyScope: (scope: string | null) => void,
t: (key: string, defaultValue?: string) => string,
): Promise<void> {
if (!slug) return;
const scope = scopeOrKey === 'photos' || scopeOrKey === 'gallery' ? scopeOrKey : scopeOrKey.includes('gallery') ? 'gallery' : 'photos';
const addonKey = scopeOrKey === 'photos' || scopeOrKey === 'gallery' ? selectAddonKeyForScope(addons, scope) : scopeOrKey;
const currentUrl = typeof window !== 'undefined' ? `${window.location.origin}${adminPath(`/mobile/events/${slug}/photos`)}` : '';
const successUrl = `${currentUrl}?addon_success=1`;
setBusyScope(scope);
try {
const checkout = await createEventAddonCheckout(slug, {
addon_key: addonKey,
quantity: 1,
success_url: successUrl,
cancel_url: currentUrl,
});
if (checkout.checkout_url) {
window.location.href = checkout.checkout_url;
} else {
toast.error(t('mobileBilling.checkoutUnavailable', 'Checkout unavailable right now.'));
}
} catch (err) {
toast.error(getApiErrorMessage(err, t('mobileBilling.checkoutFailed', 'Checkout failed.')));
} finally {
setBusyScope(null);
}
}