443 lines
17 KiB
TypeScript
443 lines
17 KiB
TypeScript
import React from 'react';
|
|
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { YStack, XStack } from '@tamagui/stacks';
|
|
import { SizableText as Text } from '@tamagui/text';
|
|
import { Pressable } from '@tamagui/react-native-web-lite';
|
|
import { Sparkles, Camera, Link2, QrCode, RefreshCcw, Shield, Archive, ShoppingCart, Clock3, Share2 } from 'lucide-react';
|
|
import toast from 'react-hot-toast';
|
|
import {
|
|
getEvent,
|
|
getEventStats,
|
|
getEventQrInvites,
|
|
toggleEvent,
|
|
updateEvent,
|
|
createEventAddonCheckout,
|
|
getAddonCatalog,
|
|
submitTenantFeedback,
|
|
type TenantEvent,
|
|
type EventStats,
|
|
type EventQrInvite,
|
|
type EventAddonCatalogItem,
|
|
} from '../api';
|
|
import { isAuthError } from '../auth/tokens';
|
|
import { getApiErrorMessage } from '../lib/apiError';
|
|
import { MobileShell } from './components/MobileShell';
|
|
import { MobileCard, CTAButton, PillBadge } from './components/Primitives';
|
|
import { adminPath } from '../constants';
|
|
import { selectAddonKeyForScope } from './addons';
|
|
|
|
export default function MobileEventRecapPage() {
|
|
const { slug } = useParams<{ slug?: string }>();
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
const { t } = useTranslation('management');
|
|
|
|
const [event, setEvent] = React.useState<TenantEvent | null>(null);
|
|
const [stats, setStats] = React.useState<EventStats | null>(null);
|
|
const [invites, setInvites] = React.useState<EventQrInvite[]>([]);
|
|
const [addons, setAddons] = React.useState<EventAddonCatalogItem[]>([]);
|
|
const [loading, setLoading] = React.useState(true);
|
|
const [error, setError] = React.useState<string | null>(null);
|
|
const [busy, setBusy] = React.useState(false);
|
|
const [archiveBusy, setArchiveBusy] = React.useState(false);
|
|
const [checkoutBusy, setCheckoutBusy] = React.useState(false);
|
|
|
|
const load = React.useCallback(async () => {
|
|
if (!slug) return;
|
|
setLoading(true);
|
|
try {
|
|
const [eventData, statsData, inviteData, addonData] = await Promise.all([
|
|
getEvent(slug),
|
|
getEventStats(slug),
|
|
getEventQrInvites(slug),
|
|
getAddonCatalog(),
|
|
]);
|
|
setEvent(eventData);
|
|
setStats(statsData);
|
|
setInvites(inviteData ?? []);
|
|
setAddons(addonData ?? []);
|
|
setError(null);
|
|
} catch (err) {
|
|
if (!isAuthError(err)) {
|
|
setError(getApiErrorMessage(err, t('events.errors.loadFailed', 'Event konnte nicht geladen werden.')));
|
|
}
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [slug, t]);
|
|
|
|
React.useEffect(() => {
|
|
void load();
|
|
}, [load]);
|
|
|
|
React.useEffect(() => {
|
|
if (!location.search) return;
|
|
const params = new URLSearchParams(location.search);
|
|
if (params.get('addon_success')) {
|
|
toast.success(t('events.success.addonApplied', 'Add-on angewendet. Limits aktualisieren sich in Kürze.'));
|
|
params.delete('addon_success');
|
|
navigate({ pathname: location.pathname, search: params.toString() ? `?${params.toString()}` : '' }, { replace: true });
|
|
void load();
|
|
}
|
|
}, [location.search, location.pathname, t, navigate, load]);
|
|
|
|
if (!slug) {
|
|
return (
|
|
<MobileShell activeTab="home" title={t('events.errors.notFoundTitle', 'Event nicht gefunden')} onBack={() => navigate(-1)}>
|
|
<MobileCard>
|
|
<Text color="#b91c1c">{t('events.errors.notFoundBody', 'Kehre zur Eventliste zurück und wähle dort ein Event aus.')}</Text>
|
|
</MobileCard>
|
|
</MobileShell>
|
|
);
|
|
}
|
|
|
|
const activeInvite = invites.find((invite) => invite.is_active);
|
|
const guestLink = event?.public_url ?? activeInvite?.url ?? (activeInvite?.token ? `/g/${activeInvite.token}` : null);
|
|
const galleryCounts = {
|
|
photos: stats?.uploads_total ?? stats?.total ?? 0,
|
|
pending: stats?.pending_photos ?? 0,
|
|
likes: stats?.likes_total ?? stats?.likes ?? 0,
|
|
};
|
|
|
|
async function toggleGallery() {
|
|
if (!slug) return;
|
|
setBusy(true);
|
|
try {
|
|
const updated = await toggleEvent(slug);
|
|
setEvent(updated);
|
|
} catch (err) {
|
|
if (!isAuthError(err)) {
|
|
setError(getApiErrorMessage(err, t('events.errors.toggleFailed', 'Status konnte nicht angepasst werden.')));
|
|
}
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
async function archiveEvent() {
|
|
if (!slug || !event) return;
|
|
setArchiveBusy(true);
|
|
try {
|
|
const updated = await updateEvent(slug, { status: 'archived', is_active: false });
|
|
setEvent(updated);
|
|
toast.success(t('events.recap.archivedSuccess', 'Event archiviert. Galerie ist geschlossen.'));
|
|
} catch (err) {
|
|
if (!isAuthError(err)) {
|
|
setError(getApiErrorMessage(err, t('events.errors.archiveFailed', 'Archivierung fehlgeschlagen.')));
|
|
}
|
|
} finally {
|
|
setArchiveBusy(false);
|
|
}
|
|
}
|
|
|
|
async function checkoutAddon() {
|
|
if (!slug) return;
|
|
const addonKey = selectAddonKeyForScope(addons, 'gallery');
|
|
const currentUrl = typeof window !== 'undefined' ? `${window.location.origin}${adminPath(`/mobile/events/${slug}/recap`)}` : '';
|
|
const successUrl = `${currentUrl}?addon_success=1`;
|
|
setCheckoutBusy(true);
|
|
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('events.errors.checkoutMissing', 'Checkout konnte nicht gestartet werden.'));
|
|
}
|
|
} catch (err) {
|
|
if (!isAuthError(err)) {
|
|
toast.error(getApiErrorMessage(err, t('events.errors.checkoutFailed', 'Add-on Checkout fehlgeschlagen.')));
|
|
}
|
|
} finally {
|
|
setCheckoutBusy(false);
|
|
}
|
|
}
|
|
|
|
async function submitFeedback(sentiment: 'positive' | 'neutral' | 'negative') {
|
|
if (!event) return;
|
|
try {
|
|
await submitTenantFeedback({
|
|
category: 'event_workspace_after_event',
|
|
event_slug: event.slug,
|
|
sentiment,
|
|
metadata: {
|
|
event_name: resolveName(event.name),
|
|
guest_link: guestLink,
|
|
},
|
|
});
|
|
toast.success(t('events.feedback.submitted', 'Danke!'));
|
|
} catch (err) {
|
|
toast.error(getApiErrorMessage(err, t('events.feedback.genericError', 'Feedback konnte nicht gesendet werden.')));
|
|
}
|
|
}
|
|
|
|
return (
|
|
<MobileShell
|
|
activeTab="home"
|
|
title={event ? resolveName(event.name) : t('events.placeholders.untitled', 'Unbenanntes Event')}
|
|
subtitle={event?.event_date ? formatDate(event.event_date) : undefined}
|
|
onBack={() => navigate(-1)}
|
|
headerActions={
|
|
<Pressable onPress={() => load()}>
|
|
<RefreshCcw size={18} color="#0f172a" />
|
|
</Pressable>
|
|
}
|
|
>
|
|
{error ? (
|
|
<MobileCard>
|
|
<Text color="#b91c1c">{error}</Text>
|
|
</MobileCard>
|
|
) : null}
|
|
|
|
{loading ? (
|
|
<YStack space="$2">
|
|
{Array.from({ length: 4 }).map((_, idx) => (
|
|
<MobileCard key={`sk-${idx}`} height={90} opacity={0.5} />
|
|
))}
|
|
</YStack>
|
|
) : event && stats ? (
|
|
<YStack space="$3">
|
|
<MobileCard space="$2">
|
|
<XStack alignItems="center" justifyContent="space-between">
|
|
<YStack space="$1">
|
|
<Text fontSize="$xs" color="#6b7280" fontWeight="700" letterSpacing={1.2}>
|
|
{t('events.recap.badge', 'Nachbereitung')}
|
|
</Text>
|
|
<Text fontSize="$lg" fontWeight="800" color="#0f172a">{resolveName(event.name)}</Text>
|
|
<Text fontSize="$sm" color="#6b7280">
|
|
{t('events.recap.subtitle', 'Abschluss, Export und Galerie-Laufzeit verwalten.')}
|
|
</Text>
|
|
</YStack>
|
|
<PillBadge tone={event.is_active ? 'success' : 'muted'}>
|
|
{event.is_active ? t('events.recap.galleryOpen', 'Galerie geöffnet') : t('events.recap.galleryClosed', 'Galerie geschlossen')}
|
|
</PillBadge>
|
|
</XStack>
|
|
<XStack space="$2" marginTop="$2" flexWrap="wrap">
|
|
<CTAButton
|
|
label={event.is_active ? t('events.recap.closeGallery', 'Galerie schließen') : t('events.recap.openGallery', 'Galerie öffnen')}
|
|
onPress={toggleGallery}
|
|
loading={busy}
|
|
/>
|
|
<CTAButton label={t('events.recap.moderate', 'Uploads ansehen')} tone="ghost" onPress={() => navigate(adminPath(`/mobile/events/${slug}/photos`))} />
|
|
<CTAButton label={t('events.actions.edit', 'Bearbeiten')} tone="ghost" onPress={() => navigate(adminPath(`/mobile/events/${slug}/edit`))} />
|
|
</XStack>
|
|
</MobileCard>
|
|
|
|
<MobileCard space="$2">
|
|
<XStack alignItems="center" justifyContent="space-between">
|
|
<Text fontSize="$md" fontWeight="800" color="#0f172a">
|
|
{t('events.recap.galleryTitle', 'Galerie-Status')}
|
|
</Text>
|
|
<PillBadge tone="muted">{t('events.recap.galleryCounts', '{{photos}} Fotos, {{pending}} offen, {{likes}} Likes', galleryCounts)}</PillBadge>
|
|
</XStack>
|
|
<XStack space="$2" marginTop="$2" flexWrap="wrap">
|
|
<Stat pill label={t('events.stats.uploads', 'Uploads')} value={String(galleryCounts.photos)} />
|
|
<Stat pill label={t('events.stats.pending', 'Offen')} value={String(galleryCounts.pending)} />
|
|
<Stat pill label={t('events.stats.likes', 'Likes')} value={String(galleryCounts.likes)} />
|
|
</XStack>
|
|
</MobileCard>
|
|
|
|
<MobileCard space="$2">
|
|
<XStack alignItems="center" space="$2">
|
|
<Link2 size={16} color="#0f172a" />
|
|
<Text fontSize="$md" fontWeight="800" color="#0f172a">
|
|
{t('events.recap.shareLink', 'Gäste-Link')}
|
|
</Text>
|
|
</XStack>
|
|
{guestLink ? (
|
|
<Text fontSize="$sm" color="#111827" selectable>
|
|
{guestLink}
|
|
</Text>
|
|
) : (
|
|
<Text fontSize="$sm" color="#6b7280">
|
|
{t('events.recap.noPublicUrl', 'Kein Gäste-Link gesetzt. Lege den öffentlichen Link im Event-Setup fest.')}
|
|
</Text>
|
|
)}
|
|
<XStack space="$2" marginTop="$2">
|
|
<CTAButton label={t('events.recap.copy', 'Kopieren')} tone="ghost" onPress={() => guestLink && copyToClipboard(guestLink, t)} />
|
|
{guestLink ? (
|
|
<CTAButton label={t('events.recap.share', 'Teilen')} tone="ghost" onPress={() => shareLink(guestLink, event, t)} />
|
|
) : null}
|
|
</XStack>
|
|
{activeInvite?.qr_code_data_url ? (
|
|
<XStack space="$2" alignItems="center" marginTop="$2">
|
|
<img src={activeInvite.qr_code_data_url} alt="QR" style={{ width: 96, height: 96 }} />
|
|
<CTAButton label={t('events.recap.downloadQr', 'QR herunterladen')} tone="ghost" onPress={() => downloadQr(activeInvite.qr_code_data_url)} />
|
|
</XStack>
|
|
) : null}
|
|
</MobileCard>
|
|
|
|
<MobileCard space="$2">
|
|
<XStack alignItems="center" space="$2">
|
|
<ShoppingCart size={16} color="#0f172a" />
|
|
<Text fontSize="$md" fontWeight="800" color="#0f172a">
|
|
{t('events.sections.addons.title', 'Add-ons & Upgrades')}
|
|
</Text>
|
|
</XStack>
|
|
<Text fontSize="$sm" color="#6b7280">
|
|
{t('events.sections.addons.description', 'Zusätzliche Kontingente freischalten.')}
|
|
</Text>
|
|
<CTAButton
|
|
label={t('events.recap.extendGallery', 'Galerie verlängern')}
|
|
onPress={() => {
|
|
void checkoutAddon();
|
|
}}
|
|
loading={checkoutBusy}
|
|
/>
|
|
</MobileCard>
|
|
|
|
<MobileCard space="$2">
|
|
<XStack alignItems="center" space="$2">
|
|
<Shield size={16} color="#0f172a" />
|
|
<Text fontSize="$md" fontWeight="800" color="#0f172a">
|
|
{t('events.recap.settingsTitle', 'Gast-Einstellungen')}
|
|
</Text>
|
|
</XStack>
|
|
<ToggleRow
|
|
label={t('events.recap.downloads', 'Downloads erlauben')}
|
|
value={Boolean(event.settings?.guest_downloads_enabled)}
|
|
onToggle={(value) => updateSetting(event, setEvent, slug, 'guest_downloads_enabled', value, setError, t)}
|
|
/>
|
|
<ToggleRow
|
|
label={t('events.recap.sharing', 'Sharing erlauben')}
|
|
value={Boolean(event.settings?.guest_sharing_enabled)}
|
|
onToggle={(value) => updateSetting(event, setEvent, slug, 'guest_sharing_enabled', value, setError, t)}
|
|
/>
|
|
</MobileCard>
|
|
|
|
<MobileCard space="$2">
|
|
<XStack alignItems="center" space="$2">
|
|
<Archive size={16} color="#0f172a" />
|
|
<Text fontSize="$md" fontWeight="800" color="#0f172a">
|
|
{t('events.recap.archiveTitle', 'Event archivieren')}
|
|
</Text>
|
|
</XStack>
|
|
<Text fontSize="$sm" color="#6b7280">
|
|
{t('events.recap.archiveCopy', 'Schließt die Galerie und markiert das Event als abgeschlossen.')}
|
|
</Text>
|
|
<CTAButton label={t('events.recap.archive', 'Archivieren')} onPress={() => archiveEvent()} loading={archiveBusy} />
|
|
</MobileCard>
|
|
|
|
<MobileCard space="$2">
|
|
<XStack alignItems="center" space="$2">
|
|
<Sparkles size={16} color="#0f172a" />
|
|
<Text fontSize="$md" fontWeight="800" color="#0f172a">
|
|
{t('events.recap.feedbackTitle', 'Wie lief das Event?')}
|
|
</Text>
|
|
</XStack>
|
|
<XStack space="$2" marginTop="$2" flexWrap="wrap">
|
|
<CTAButton label={t('events.feedback.positive', 'War super')} tone="ghost" onPress={() => void submitFeedback('positive')} />
|
|
<CTAButton label={t('events.feedback.neutral', 'In Ordnung')} tone="ghost" onPress={() => void submitFeedback('neutral')} />
|
|
<CTAButton label={t('events.feedback.negative', 'Brauch(t)e Unterstützung')} tone="ghost" onPress={() => void submitFeedback('negative')} />
|
|
</XStack>
|
|
</MobileCard>
|
|
</YStack>
|
|
) : null}
|
|
</MobileShell>
|
|
);
|
|
}
|
|
|
|
function Stat({ label, value }: { label: string; value: string }) {
|
|
return (
|
|
<MobileCard borderColor="#e5e7eb" space="$1.5">
|
|
<Text fontSize="$xs" color="#6b7280">
|
|
{label}
|
|
</Text>
|
|
<Text fontSize="$md" fontWeight="800" color="#0f172a">
|
|
{value}
|
|
</Text>
|
|
</MobileCard>
|
|
);
|
|
}
|
|
|
|
function ToggleRow({ label, value, onToggle }: { label: string; value: boolean; onToggle: (value: boolean) => void }) {
|
|
return (
|
|
<XStack alignItems="center" justifyContent="space-between" marginTop="$1.5">
|
|
<Text fontSize="$sm" color="#0f172a">
|
|
{label}
|
|
</Text>
|
|
<input
|
|
type="checkbox"
|
|
checked={value}
|
|
onChange={(e) => onToggle(e.target.checked)}
|
|
style={{ width: 20, height: 20 }}
|
|
/>
|
|
</XStack>
|
|
);
|
|
}
|
|
|
|
async function updateSetting(
|
|
event: TenantEvent,
|
|
setEvent: (event: TenantEvent) => void,
|
|
slug: string | undefined,
|
|
key: 'guest_downloads_enabled' | 'guest_sharing_enabled',
|
|
value: boolean,
|
|
setError: (message: string | null) => void,
|
|
t: (key: string, fallback?: string) => string,
|
|
): Promise<void> {
|
|
if (!slug) return;
|
|
try {
|
|
const updated = await updateEvent(slug, {
|
|
settings: {
|
|
...(event.settings ?? {}),
|
|
[key]: value,
|
|
},
|
|
});
|
|
setEvent(updated);
|
|
} catch (err) {
|
|
if (!isAuthError(err)) {
|
|
setError(getApiErrorMessage(err, t('events.errors.toggleFailed', 'Einstellung konnte nicht gespeichert werden.')));
|
|
}
|
|
}
|
|
}
|
|
|
|
function copyToClipboard(value: string, t: (key: string, fallback?: string) => string) {
|
|
navigator.clipboard
|
|
.writeText(value)
|
|
.then(() => toast.success(t('events.recap.copySuccess', 'Link kopiert')))
|
|
.catch(() => toast.error(t('events.recap.copyError', 'Link konnte nicht kopiert werden.')));
|
|
}
|
|
|
|
async function shareLink(value: string, event: TenantEvent, t: (key: string, fallback?: string) => string) {
|
|
if (navigator.share) {
|
|
try {
|
|
await navigator.share({
|
|
title: resolveName(event.name),
|
|
text: t('events.recap.shareGuests', 'Gäste-Galerie teilen'),
|
|
url: value,
|
|
});
|
|
return;
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
copyToClipboard(value, t);
|
|
}
|
|
|
|
function downloadQr(dataUrl: string) {
|
|
const link = document.createElement('a');
|
|
link.href = dataUrl;
|
|
link.download = 'guest-gallery-qr.png';
|
|
link.click();
|
|
}
|
|
|
|
function resolveName(name: TenantEvent['name']): string {
|
|
if (typeof name === 'string') return name;
|
|
if (name && typeof name === 'object') {
|
|
return name.de ?? name.en ?? Object.values(name)[0] ?? 'Event';
|
|
}
|
|
return 'Event';
|
|
}
|
|
|
|
function formatDate(iso?: string | null): string {
|
|
if (!iso) return '';
|
|
const date = new Date(iso);
|
|
if (Number.isNaN(date.getTime())) return '';
|
|
return date.toLocaleDateString(undefined, { day: '2-digit', month: 'short', year: 'numeric' });
|
|
}
|