removed the old event admin components and pages
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
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';
|
||||
@@ -7,13 +7,28 @@ 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 } from '../api';
|
||||
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';
|
||||
|
||||
@@ -22,6 +37,7 @@ export default function MobileEventPhotosPage() {
|
||||
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[]>([]);
|
||||
@@ -38,6 +54,10 @@ export default function MobileEventPhotosPage() {
|
||||
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');
|
||||
@@ -82,8 +102,15 @@ export default function MobileEventPhotosPage() {
|
||||
});
|
||||
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.')));
|
||||
@@ -91,12 +118,24 @@ export default function MobileEventPhotosPage() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [slug, filter, t, page]);
|
||||
}, [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]);
|
||||
@@ -215,6 +254,15 @@ export default function MobileEventPhotosPage() {
|
||||
</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>
|
||||
@@ -358,6 +406,15 @@ export default function MobileEventPhotosPage() {
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -372,3 +429,197 @@ function Field({ label, color, children }: { label: string; color: string; child
|
||||
</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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user