Update admin PWA events, branding, and packages
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

This commit is contained in:
Codex Agent
2026-01-19 11:35:38 +01:00
parent 926bc7d070
commit fbff2afa3e
43 changed files with 6846 additions and 6323 deletions

View File

@@ -7,7 +7,7 @@ import { useTheme } from '@tamagui/core';
const DEV_TENANT_KEYS = [
{ key: 'cust-standard-empty', label: 'Endkunde Starter (kein Event)' },
{ key: 'cust-starter-wedding', label: 'Endkunde Starter (Hochzeit)' },
{ key: 'cust-starter-wedding', label: 'Endkunde Standard (Hochzeit)' },
{ key: 'reseller-s-active', label: 'Reseller S 3 aktive Events' },
{ key: 'reseller-s-full', label: 'Reseller S voll belegt (5/5)' },
] as const;

View File

@@ -180,6 +180,63 @@ export type EventStats = {
pending_photos?: number;
};
export type EventEngagementSummary = {
totalPhotos: number;
uniqueGuests: number;
tasksSolved: number;
likesTotal: number;
};
export type EventEngagementLeaderboardEntry = {
guest: string;
photos: number;
likes: number;
};
export type EventEngagementTopPhoto = {
photoId: number;
guest: string;
likes: number;
task?: string | null;
createdAt: string;
thumbnail: string | null;
};
export type EventEngagementTrendingEmotion = {
emotionId: number;
name: string;
count: number;
};
export type EventEngagementTimelinePoint = {
date: string;
photos: number;
guests: number;
};
export type EventEngagementFeedEntry = {
photoId: number;
guest: string;
task?: string | null;
likes: number;
createdAt: string;
thumbnail: string | null;
};
export type EventEngagement = {
summary: EventEngagementSummary;
leaderboards: {
uploads: EventEngagementLeaderboardEntry[];
likes: EventEngagementLeaderboardEntry[];
};
highlights: {
topPhoto: EventEngagementTopPhoto | null;
trendingEmotion: EventEngagementTrendingEmotion | null;
timeline: EventEngagementTimelinePoint[];
};
feed: EventEngagementFeedEntry[];
};
export type PhotoboothStatusMetrics = {
uploads_last_hour?: number | null;
uploads_today?: number | null;
@@ -255,6 +312,7 @@ export type TenantFont = {
export type WatermarkSettings = {
mode?: 'base' | 'custom' | 'off';
asset?: string | null;
asset_url?: string | null;
asset_data_url?: string | null;
position?:
| 'top-left'
@@ -1321,6 +1379,113 @@ function normalizeQrInvite(raw: JsonValue): EventQrInvite {
};
}
function toEngagementNumber(value: unknown, fallback = 0): number {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string' && value !== '') {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
return fallback;
}
function toEngagementString(value: unknown): string {
return typeof value === 'string' ? value : '';
}
function normalizeEventEngagement(payload: JsonValue): EventEngagement {
const record = payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {};
const summary = record.summary && typeof record.summary === 'object' ? (record.summary as Record<string, unknown>) : {};
const leaderboards = record.leaderboards && typeof record.leaderboards === 'object'
? (record.leaderboards as Record<string, unknown>)
: {};
const highlights = record.highlights && typeof record.highlights === 'object'
? (record.highlights as Record<string, unknown>)
: {};
const feedRaw = Array.isArray(record.feed) ? record.feed : [];
const uploadsBoard = Array.isArray(leaderboards.uploads)
? (leaderboards.uploads as Record<string, unknown>[]).map((row) => ({
guest: toEngagementString(row.guest),
photos: toEngagementNumber(row.photos),
likes: toEngagementNumber(row.likes),
}))
: [];
const likesBoard = Array.isArray(leaderboards.likes)
? (leaderboards.likes as Record<string, unknown>[]).map((row) => ({
guest: toEngagementString(row.guest),
photos: toEngagementNumber(row.photos),
likes: toEngagementNumber(row.likes),
}))
: [];
const topPhotoRaw = highlights.top_photo && typeof highlights.top_photo === 'object'
? (highlights.top_photo as Record<string, unknown>)
: null;
const topPhoto = topPhotoRaw
? {
photoId: toEngagementNumber(topPhotoRaw.photo_id),
guest: toEngagementString(topPhotoRaw.guest),
likes: toEngagementNumber(topPhotoRaw.likes),
task: (topPhotoRaw as { task?: string | null }).task ?? null,
createdAt: toEngagementString(topPhotoRaw.created_at),
thumbnail: topPhotoRaw.thumbnail ? toEngagementString(topPhotoRaw.thumbnail) : null,
}
: null;
const trendingRaw = highlights.trending_emotion && typeof highlights.trending_emotion === 'object'
? (highlights.trending_emotion as Record<string, unknown>)
: null;
const trendingEmotion = trendingRaw
? {
emotionId: toEngagementNumber(trendingRaw.emotion_id),
name: toEngagementString(trendingRaw.name),
count: toEngagementNumber(trendingRaw.count),
}
: null;
const timeline = Array.isArray(highlights.timeline)
? (highlights.timeline as Record<string, unknown>[]).map((row) => ({
date: toEngagementString(row.date),
photos: toEngagementNumber(row.photos),
guests: toEngagementNumber(row.guests),
}))
: [];
const feed = feedRaw.map((row) => {
const entry = row as Record<string, unknown>;
return {
photoId: toEngagementNumber(entry.photo_id),
guest: toEngagementString(entry.guest),
task: (entry as { task?: string | null }).task ?? null,
likes: toEngagementNumber(entry.likes),
createdAt: toEngagementString(entry.created_at),
thumbnail: entry.thumbnail ? toEngagementString(entry.thumbnail) : null,
};
});
return {
summary: {
totalPhotos: toEngagementNumber(summary.total_photos),
uniqueGuests: toEngagementNumber(summary.unique_guests),
tasksSolved: toEngagementNumber(summary.tasks_solved),
likesTotal: toEngagementNumber(summary.likes_total),
},
leaderboards: {
uploads: uploadsBoard,
likes: likesBoard,
},
highlights: {
topPhoto,
trendingEmotion,
timeline,
},
feed,
};
}
function normalizeGuestNotification(raw: JsonValue): GuestNotificationSummary | null {
if (!raw || typeof raw !== 'object') {
return null;
@@ -1818,6 +1983,37 @@ export async function getEventStats(slug: string): Promise<EventStats> {
};
}
export async function getEventEngagement(
token: string,
options: { locale?: string; guestName?: string; signal?: AbortSignal } = {}
): Promise<EventEngagement> {
const params = new URLSearchParams();
if (options.guestName) {
params.set('guest_name', options.guestName);
}
if (options.locale) {
params.set('locale', options.locale);
}
const query = params.toString();
const response = await fetch(
`/api/v1/events/${encodeURIComponent(token)}/achievements${query ? `?${query}` : ''}`,
{
method: 'GET',
headers: {
Accept: 'application/json',
...(options.locale ? { 'X-Locale': options.locale } : {}),
},
credentials: 'same-origin',
cache: 'no-store',
signal: options.signal,
}
);
const data = await jsonOrThrow<JsonValue>(response, 'Failed to load engagement data', { suppressToast: true });
return normalizeEventEngagement(data);
}
export async function getEventQrInvites(slug: string): Promise<EventQrInvite[]> {
const response = await authorizedFetch(`${eventEndpoint(slug)}/join-tokens`);
const payload = await jsonOrThrow<{ data: JsonValue[] }>(response, 'Failed to load invitations');

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -74,6 +74,7 @@ type WatermarkForm = {
mode: 'base' | 'custom' | 'off';
assetPath: string;
assetDataUrl: string;
assetPreviewUrl: string;
position: WatermarkPosition;
opacity: number;
scale: number;
@@ -97,6 +98,7 @@ export default function MobileBrandingPage() {
mode: 'base',
assetPath: '',
assetDataUrl: '',
assetPreviewUrl: '',
position: 'bottom-right',
opacity: 0.25,
scale: 0.2,
@@ -192,13 +194,6 @@ export default function MobileBrandingPage() {
async function handleSave() {
if (!event?.slug) return;
const eventTypeId = event.event_type_id ?? event.event_type?.id ?? null;
if (!eventTypeId) {
const msg = t('events.errors.missingType', 'Event type fehlt. Speichere das Event erneut im Admin.');
setError(msg);
toast.error(msg);
return;
}
setSaving(true);
setError(null);
try {
@@ -210,14 +205,6 @@ export default function MobileBrandingPage() {
return;
}
const payload = {
name: typeof event.name === 'string' ? event.name : renderName(event.name),
slug: event.slug,
event_type_id: eventTypeId,
event_date: event.event_date ?? undefined,
status: event.status ?? 'draft',
is_active: event.is_active ?? undefined,
};
const settings = { ...(event.settings ?? {}) };
const logoUploadValue = form.logoMode === 'upload' ? form.logoDataUrl.trim() : '';
const logoIsDataUrl = logoUploadValue.startsWith('data:image/');
@@ -288,11 +275,9 @@ export default function MobileBrandingPage() {
if (watermarkPayload) {
settings.watermark = watermarkPayload;
}
const updated = await updateEvent(event.slug, {
...payload,
settings,
});
const updated = await updateEvent(event.slug, { settings });
setEvent(updated);
setWatermarkForm(extractWatermark(updated));
void trackOnboarding('branding_configured', { event_id: updated.id });
toast.success(t('events.branding.saveSuccess', 'Branding gespeichert'));
} catch (err) {
@@ -340,6 +325,8 @@ export default function MobileBrandingPage() {
padding={watermarkForm.padding}
offsetX={watermarkForm.offsetX}
offsetY={watermarkForm.offsetY}
previewUrl={mode === 'off' ? '' : watermarkForm.assetDataUrl || watermarkForm.assetPreviewUrl}
previewAlt={t('events.watermark.previewAlt', 'Watermark preview')}
/>
</MobileCard>
@@ -404,12 +391,29 @@ export default function MobileBrandingPage() {
>
<UploadCloud size={18} color={primary} />
<Text fontSize="$sm" color={primary} fontWeight="700">
{watermarkForm.assetPath
{watermarkForm.assetPath || watermarkForm.assetDataUrl
? t('events.watermark.replace', 'Wasserzeichen ersetzen')
: t('events.watermark.uploadCta', 'PNG/SVG/JPG (max. 3 MB)')}
</Text>
</XStack>
</Pressable>
{watermarkForm.assetDataUrl ? (
<YStack
borderRadius={12}
borderWidth={1}
borderColor={border}
backgroundColor={surfaceMuted}
padding="$2"
alignItems="center"
justifyContent="center"
>
<img
src={watermarkForm.assetDataUrl}
alt={t('events.watermark.previewAlt', 'Wasserzeichen Vorschau')}
style={{ maxHeight: 120, maxWidth: '100%', objectFit: 'contain' }}
/>
</YStack>
) : null}
<MobileFileInput
id="watermark-upload-input"
accept="image/png,image/jpeg,image/webp,image/svg+xml"
@@ -423,7 +427,12 @@ export default function MobileBrandingPage() {
const reader = new FileReader();
reader.onload = () => {
const result = typeof reader.result === 'string' ? reader.result : '';
setWatermarkForm((prev) => ({ ...prev, assetDataUrl: result, assetPath: '' }));
setWatermarkForm((prev) => ({
...prev,
assetDataUrl: result,
assetPreviewUrl: result,
assetPath: '',
}));
setError(null);
};
reader.readAsDataURL(file);
@@ -1073,6 +1082,7 @@ function extractWatermark(event: TenantEvent): WatermarkForm {
mode,
assetPath: readString('asset', ''),
assetDataUrl: '',
assetPreviewUrl: readString('asset_url', ''),
position,
opacity: readNumber('opacity', 0.25),
scale: readNumber('scale', 0.2),
@@ -1339,6 +1349,8 @@ function WatermarkPreview({
padding,
offsetX,
offsetY,
previewUrl,
previewAlt,
}: {
position: WatermarkPosition;
scale: number;
@@ -1346,6 +1358,8 @@ function WatermarkPreview({
padding: number;
offsetX: number;
offsetY: number;
previewUrl?: string;
previewAlt?: string;
}) {
const { border, muted, textStrong, overlay } = useAdminTheme();
const width = 280;
@@ -1406,7 +1420,7 @@ function WatermarkPreview({
top: y,
width: wmWidth,
height: wmHeight,
background: 'rgba(255,255,255,0.8)',
background: previewUrl ? 'transparent' : 'rgba(255,255,255,0.8)',
borderRadius: 10,
display: 'flex',
alignItems: 'center',
@@ -1415,7 +1429,15 @@ function WatermarkPreview({
border: `1px dashed ${muted}`,
}}
>
<Droplets size={18} color={textStrong} />
{previewUrl ? (
<img
src={previewUrl}
alt={previewAlt ?? 'Watermark'}
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
/>
) : (
<Droplets size={18} color={textStrong} />
)}
</div>
</div>
</div>
@@ -1486,7 +1508,7 @@ function UpgradeCard({
}
function TabButton({ label, active, onPress }: { label: string; active: boolean; onPress: () => void }) {
const { backdrop, surfaceMuted, border, surface } = useAdminTheme();
const { primary, surfaceMuted, border, surface, textStrong } = useAdminTheme();
return (
<Pressable onPress={onPress} style={{ flex: 1 }}>
<XStack
@@ -1494,11 +1516,11 @@ function TabButton({ label, active, onPress }: { label: string; active: boolean;
justifyContent="center"
paddingVertical="$2.5"
borderRadius={12}
backgroundColor={active ? backdrop : surfaceMuted}
backgroundColor={active ? primary : surfaceMuted}
borderWidth={1}
borderColor={active ? backdrop : border}
borderColor={active ? primary : border}
>
<Text fontSize="$sm" color={active ? surface : backdrop} fontWeight="700">
<Text fontSize="$sm" color={active ? surface : textStrong} fontWeight="700">
{label}
</Text>
</XStack>
@@ -1517,7 +1539,7 @@ function ModeButton({
onPress: () => void;
disabled?: boolean;
}) {
const { backdrop, surfaceMuted, border, surface } = useAdminTheme();
const { primary, surfaceMuted, border, surface, textStrong } = useAdminTheme();
return (
<Pressable onPress={onPress} disabled={disabled} style={{ flex: 1, opacity: disabled ? 0.6 : 1 }}>
<XStack
@@ -1525,11 +1547,11 @@ function ModeButton({
justifyContent="center"
paddingVertical="$2"
borderRadius={10}
backgroundColor={active ? backdrop : surfaceMuted}
backgroundColor={active ? primary : surfaceMuted}
borderWidth={1}
borderColor={active ? backdrop : border}
borderColor={active ? primary : border}
>
<Text fontSize="$xs" color={active ? surface : backdrop} fontWeight="700">
<Text fontSize="$xs" color={active ? surface : textStrong} fontWeight="700">
{label}
</Text>
</XStack>

View File

@@ -10,7 +10,7 @@ import { Image } from '@tamagui/image';
import { isSameDay, isPast, isFuture, parseISO, differenceInDays, startOfDay } from 'date-fns';
import { MobileShell } from './components/MobileShell';
import { adminPath } from '../constants';
import { ADMIN_EVENTS_PATH, adminPath } from '../constants';
import { useEventContext } from '../context/EventContext';
import { getEventStats, EventStats, TenantEvent, getEventPhotos, TenantPhoto } from '../api';
import { formatEventDate, resolveEventDisplayName } from '../lib/events';
@@ -165,10 +165,21 @@ export default function MobileDashboardPage() {
selectEvent(slugParam);
}, [activeEvent?.slug, selectEvent, slugParam]);
const shouldRedirectToSelector = !isLoading && !activeEvent && !slugParam;
React.useEffect(() => {
if (!shouldRedirectToSelector) return;
navigate(ADMIN_EVENTS_PATH, { replace: true });
}, [navigate, shouldRedirectToSelector]);
const [eventSwitcherOpen, setEventSwitcherOpen] = React.useState(false);
// --- RENDER ---
if (shouldRedirectToSelector) {
return null;
}
if (isLoading) {
return (
<MobileShell activeTab="home" title={t('mobileDashboard.title', 'Dashboard')}>
@@ -188,6 +199,7 @@ export default function MobileDashboardPage() {
// Calculate Readiness
const readiness = useEventReadiness(activeEvent, t as any);
const phase = activeEvent ? getEventPhase(activeEvent) : 'setup';
const isCompleted = phase === 'post';
return (
<MobileShell activeTab="home" title={t('mobileDashboard.title', 'Dashboard')}>
@@ -223,6 +235,7 @@ export default function MobileDashboardPage() {
navigate={navigate}
permissions={memberPermissions}
isMember={isMember}
isCompleted={isCompleted}
/>
{/* 5. RECENT PHOTOS */}
@@ -241,6 +254,7 @@ export default function MobileDashboardPage() {
type EventPhase = 'setup' | 'live' | 'post';
function getEventPhase(event: TenantEvent): EventPhase {
if (event.status === 'archived') return 'post';
if (!event.event_date) return 'setup';
const today = startOfDay(new Date());
const eventDate = parseISO(event.event_date);
@@ -318,17 +332,23 @@ function LifecycleHero({ event, stats, locale, navigate, onSwitch, canSwitch, re
</YStack>
<YStack>
<Text fontSize="$md" fontWeight="800" color={theme.textStrong}>
{t('management:status.archived', 'Event Completed')}
{t('events.recap.completedTitle', 'Event completed')}
</Text>
<Text fontSize="$xs" color={theme.muted}>{t('management:recap.galleryOpen', 'Gallery online')}</Text>
<Text fontSize="$xs" color={theme.muted}>{t('events.recap.galleryOpen', 'Gallery online')}</Text>
</YStack>
</XStack>
<ModernButton
label={t('management:recap.downloadAll', 'Download Photos')}
label={t('events.recap.downloadAll', 'Download photos')}
tone="primary"
icon={<Download size={16} color="white" />}
onPress={() => navigate(adminPath(`/mobile/exports`))}
/>
<ModernButton
label={t('events.recap.openRecap', 'Open recap')}
tone="ghost"
icon={<ArrowRight size={16} color={theme.text} />}
onPress={() => navigate(adminPath(`/mobile/events/${event.slug}/recap`))}
/>
</ModernCard>
</YStack>
);
@@ -413,40 +433,46 @@ function PulseStrip({ event, stats }: any) {
);
}
function UnifiedToolGrid({ event, navigate, permissions, isMember }: any) {
function UnifiedToolGrid({ event, navigate, permissions, isMember, isCompleted }: any) {
const theme = useAdminTheme();
const { t } = useTranslation(['management', 'dashboard']);
const slug = event?.slug;
if (!slug) return null;
const experienceItems = [
{ label: t('management:photos.gallery.title', 'Photos'), icon: ImageIcon, path: `/mobile/events/${slug}/control-room`, color: theme.primary },
!isCompleted ? { label: t('management:events.quick.liveShowSettings', 'Slide Show'), icon: Tv, path: `/mobile/events/${slug}/live-show/settings`, color: '#F59E0B' } : null,
!isCompleted ? { label: t('events.tasks.badge', 'Tasks'), icon: ListTodo, path: `/mobile/events/${slug}/tasks`, color: theme.accent } : null,
!isCompleted ? { label: t('management:events.quick.photobooth', 'Photobooth'), icon: Camera, path: `/mobile/events/${slug}/photobooth`, color: '#8B5CF6' } : null,
].filter((item): item is { label: string; icon: any; path: string; color?: string } => Boolean(item));
const operationsItems = [
!isCompleted ? { label: t('management:invites.badge', 'QR Codes'), icon: QrCode, path: `/mobile/events/${slug}/qr`, color: '#10B981' } : null,
{ label: t('management:events.quick.guests', 'Guests'), icon: Users, path: `/mobile/events/${slug}/members`, color: theme.text },
!isCompleted ? { label: t('management:events.quick.guestMessages', 'Messages'), icon: Megaphone, path: `/mobile/events/${slug}/guest-notifications`, color: theme.text } : null,
!isCompleted ? { label: t('events.branding.titleShort', 'Branding'), icon: Layout, path: `/mobile/events/${slug}/branding`, color: theme.text } : null,
].filter((item): item is { label: string; icon: any; path: string; color?: string } => Boolean(item));
const adminItems = [
{ label: t('management:mobileDashboard.shortcutAnalytics', 'Analytics'), icon: TrendingUp, path: `/mobile/events/${slug}/analytics` },
!isCompleted ? { label: t('events.recap.exportTitleShort', 'Exports'), icon: Download, path: `/mobile/exports` } : null,
{ label: t('management:mobileProfile.settings', 'Settings'), icon: Settings, path: `/mobile/events/${slug}/edit` },
].filter((item): item is { label: string; icon: any; path: string; color?: string } => Boolean(item));
const sections = [
{
title: t('management:branding.badge', 'Experience'),
items: [
{ label: t('management:photos.gallery.title', 'Photos'), icon: ImageIcon, path: `/mobile/events/${slug}/control-room`, color: theme.primary },
{ label: t('management:events.quick.liveShowSettings', 'Slide Show'), icon: Tv, path: `/mobile/events/${slug}/live-show/settings`, color: '#F59E0B' },
{ label: t('management:tasks.badge', 'Tasks'), icon: ListTodo, path: `/mobile/events/${slug}/tasks`, color: theme.accent },
{ label: t('management:events.quick.photobooth', 'Photobooth'), icon: Camera, path: `/mobile/events/${slug}/photobooth`, color: '#8B5CF6' },
]
items: experienceItems,
},
{
title: t('management:workspace.hero.badge', 'Operations'),
items: [
{ label: t('management:invites.badge', 'QR Codes'), icon: QrCode, path: `/mobile/events/${slug}/qr`, color: '#10B981' },
{ label: t('management:events.quick.guests', 'Guests'), icon: Users, path: `/mobile/events/${slug}/members`, color: theme.text },
{ label: t('management:events.quick.guestMessages', 'Messages'), icon: Megaphone, path: `/mobile/events/${slug}/guest-notifications`, color: theme.text },
{ label: t('management:branding.titleShort', 'Branding'), icon: Layout, path: `/mobile/events/${slug}/branding`, color: theme.text },
]
items: operationsItems,
},
{
title: t('management:settings.hero.badge', 'Admin'),
items: [
{ label: t('management:mobileDashboard.shortcutAnalytics', 'Analytics'), icon: TrendingUp, path: `/mobile/events/${slug}/analytics` },
{ label: t('management:recap.exportTitle', 'Exports'), icon: Download, path: `/mobile/exports` },
{ label: t('management:mobileProfile.settings', 'Settings'), icon: Settings, path: `/mobile/events/${slug}/edit` },
]
items: adminItems,
}
];
].filter((section) => section.items.length > 0);
return (
<YStack space="$4">

View File

@@ -56,35 +56,69 @@ function formatEventName(event: TenantEvent | DataExportSummary['event'] | null)
return event.slug ?? '';
}
export default function MobileDataExportsPage() {
type DataExportsPanelProps = {
variant?: 'page' | 'recap';
event?: TenantEvent | null;
onRefreshReady?: (refresh: () => void) => void;
};
export function DataExportsPanel({
variant = 'page',
event,
onRefreshReady,
}: DataExportsPanelProps) {
const { t } = useTranslation('management');
const { textStrong, text, muted, danger } = useAdminTheme();
const [exports, setExports] = React.useState<DataExportSummary[]>([]);
const [events, setEvents] = React.useState<TenantEvent[]>([]);
const [scope, setScope] = React.useState<'tenant' | 'event'>('tenant');
const [eventId, setEventId] = React.useState<number | null>(null);
const isRecap = variant === 'recap';
const [scope, setScope] = React.useState<'tenant' | 'event'>(isRecap ? 'event' : 'tenant');
const [eventId, setEventId] = React.useState<number | null>(isRecap ? event?.id ?? null : null);
const [includeMedia, setIncludeMedia] = React.useState(false);
const [loading, setLoading] = React.useState(true);
const [requesting, setRequesting] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const back = useBackNavigation(adminPath('/mobile/profile'));
React.useEffect(() => {
if (!isRecap) {
return;
}
if (event?.id && eventId !== event.id) {
setEventId(event.id);
}
if (event) {
setEvents([event]);
}
}, [event, eventId, isRecap]);
const load = React.useCallback(async () => {
setLoading(true);
try {
const [exportRows, eventRows] = await Promise.all([
listTenantDataExports(),
getEvents({ force: true }),
isRecap ? Promise.resolve(event ? [event] : []) : getEvents({ force: true }),
]);
setExports(exportRows);
setEvents(eventRows);
if (!isRecap) {
setEvents(eventRows);
} else if (eventRows.length > 0) {
setEvents(eventRows);
}
setError(null);
} catch (err) {
setError(getApiErrorMessage(err, t('dataExports.errors.load', 'Exports konnten nicht geladen werden.')));
} finally {
setLoading(false);
}
}, [t]);
}, [event, isRecap, t]);
const refresh = React.useCallback(() => {
void load();
}, [load]);
React.useEffect(() => {
onRefreshReady?.(refresh);
}, [onRefreshReady, refresh]);
React.useEffect(() => {
void load();
@@ -111,7 +145,10 @@ export default function MobileDataExportsPage() {
return;
}
if (scope === 'event' && !eventId) {
const requestScope = isRecap ? 'event' : scope;
const requestEventId = isRecap ? eventId : eventId;
if (requestScope === 'event' && !requestEventId) {
setError(t('dataExports.errors.eventRequired', 'Bitte wähle ein Event aus.'));
return;
}
@@ -119,8 +156,8 @@ export default function MobileDataExportsPage() {
setRequesting(true);
try {
await requestTenantDataExport({
scope,
eventId: scope === 'event' ? eventId ?? undefined : undefined,
scope: requestScope,
eventId: requestScope === 'event' ? requestEventId ?? undefined : undefined,
includeMedia,
});
toast.success(t('dataExports.actions.requested', 'Export wird vorbereitet.'));
@@ -133,23 +170,24 @@ export default function MobileDataExportsPage() {
}
};
const visibleExports = React.useMemo(() => {
if (!isRecap) {
return exports;
}
if (!event?.id) {
return exports.filter((entry) => entry.scope === 'event');
}
return exports.filter((entry) => entry.scope === 'event' && entry.event?.id === event.id);
}, [event?.id, exports, isRecap]);
return (
<MobileShell
activeTab="profile"
title={t('dataExports.title', 'Data exports')}
onBack={back}
headerActions={
<HeaderActionButton onPress={() => load()} ariaLabel={t('common.refresh', 'Refresh')}>
<RefreshCcw size={18} color={textStrong} />
</HeaderActionButton>
}
>
<>
{error ? (
<MobileCard>
<Text fontWeight="700" color={danger}>
{error}
</Text>
<CTAButton label={t('dataExports.actions.refresh', 'Refresh')} tone="ghost" onPress={load} />
<CTAButton label={t('dataExports.actions.refresh', 'Refresh')} tone="ghost" onPress={refresh} />
</MobileCard>
) : null}
@@ -161,21 +199,32 @@ export default function MobileDataExportsPage() {
{t('dataExports.request.hint', 'Export tenant data or a specific event archive.')}
</Text>
<YStack space="$2">
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$sm" color={text}>
{t('dataExports.fields.scope', 'Scope')}
</Text>
<MobileSelect
value={scope}
onChange={(event) => setScope(event.target.value as 'tenant' | 'event')}
compact
style={{ minWidth: 140, maxWidth: 180 }}
>
<option value="tenant">{t('dataExports.scopes.tenant', 'Tenant')}</option>
<option value="event">{t('dataExports.scopes.event', 'Event')}</option>
</MobileSelect>
</XStack>
{scope === 'event' ? (
{!isRecap ? (
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$sm" color={text}>
{t('dataExports.fields.scope', 'Scope')}
</Text>
<MobileSelect
value={scope}
onChange={(event) => setScope(event.target.value as 'tenant' | 'event')}
compact
style={{ minWidth: 140, maxWidth: 180 }}
>
<option value="tenant">{t('dataExports.scopes.tenant', 'Tenant')}</option>
<option value="event">{t('dataExports.scopes.event', 'Event')}</option>
</MobileSelect>
</XStack>
) : (
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$sm" color={text}>
{t('dataExports.fields.event', 'Event')}
</Text>
<Text fontSize="$sm" color={textStrong}>
{formatEventName(event) || event?.slug || t('dataExports.fields.eventPlaceholder', 'Choose event')}
</Text>
</XStack>
)}
{!isRecap && scope === 'event' ? (
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$sm" color={text}>
{t('dataExports.fields.event', 'Event')}
@@ -238,13 +287,13 @@ export default function MobileDataExportsPage() {
<SkeletonCard height={72} />
<SkeletonCard height={72} />
</YStack>
) : exports.length === 0 ? (
) : visibleExports.length === 0 ? (
<Text fontSize="$sm" color={muted}>
{t('dataExports.history.empty', 'No exports yet.')}
</Text>
) : (
<YStack space="$2">
{exports.map((entry) => (
{visibleExports.map((entry) => (
<MobileCard key={entry.id} space="$2">
<XStack alignItems="center" justifyContent="space-between">
<YStack>
@@ -301,6 +350,28 @@ export default function MobileDataExportsPage() {
</YStack>
)}
</MobileCard>
</>
);
}
export default function MobileDataExportsPage() {
const { t } = useTranslation('management');
const { textStrong } = useAdminTheme();
const back = useBackNavigation(adminPath('/mobile/profile'));
const [refresh, setRefresh] = React.useState<null | (() => void)>(null);
return (
<MobileShell
activeTab="profile"
title={t('dataExports.title', 'Data exports')}
onBack={back}
headerActions={
<HeaderActionButton onPress={() => refresh?.()} ariaLabel={t('common.refresh', 'Refresh')}>
<RefreshCcw size={18} color={textStrong} />
</HeaderActionButton>
}
>
<DataExportsPanel onRefreshReady={setRefresh} />
</MobileShell>
);
}

View File

@@ -3,6 +3,7 @@ import { useNavigate, useParams } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { CalendarDays, ChevronDown, MapPin, Save, Check } from 'lucide-react';
import { isPast, isSameDay, parseISO, startOfDay } from 'date-fns';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Switch } from '@tamagui/switch';
@@ -71,6 +72,9 @@ export default function MobileEventFormPage() {
packageId: null,
servicePackageSlug: null,
});
const [eventMeta, setEventMeta] = React.useState<{ status: TenantEvent['status']; eventDate: string | null } | null>(
null,
);
const [eventTypes, setEventTypes] = React.useState<TenantEventType[]>([]);
const [typesLoading, setTypesLoading] = React.useState(false);
const [packages, setPackages] = React.useState<Package[]>([]);
@@ -86,11 +90,15 @@ export default function MobileEventFormPage() {
const back = useBackNavigation(slug ? adminPath(`/mobile/events/${slug}`) : adminPath('/mobile/events'));
React.useEffect(() => {
if (!slug) return;
if (!slug) {
setEventMeta(null);
return;
}
(async () => {
setLoading(true);
try {
const data = await getEvent(slug);
setEventMeta({ status: data.status, eventDate: data.event_date });
setForm({
name: renderName(data.name),
date: toDateTimeLocal(data.event_date),
@@ -117,6 +125,24 @@ export default function MobileEventFormPage() {
})();
}, [slug, t, isEdit]);
const isEventCompleted = React.useMemo(() => {
if (!eventMeta) return false;
if (eventMeta.status === 'archived') return true;
if (!eventMeta.eventDate) return false;
const eventDate = parseISO(eventMeta.eventDate);
const today = startOfDay(new Date());
if (isSameDay(today, eventDate)) return false;
return isPast(eventDate);
}, [eventMeta]);
const handleDateChange = React.useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
if (isEventCompleted) return;
setForm((prev) => ({ ...prev, date: event.target.value }));
},
[isEventCompleted],
);
React.useEffect(() => {
(async () => {
setTypesLoading(true);
@@ -429,8 +455,9 @@ export default function MobileEventFormPage() {
<XStack alignItems="center" space="$2">
<MobileDateTimeInput
value={form.date}
onChange={(event) => setForm((prev) => ({ ...prev, date: event.target.value }))}
onChange={handleDateChange}
style={{ flex: 1 }}
disabled={isEventCompleted}
/>
<CalendarDays size={16} color={subtle} />
</XStack>

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Check, Copy, Download, Share2, Sparkles, Trophy, Users } from 'lucide-react';
import { Share2, Sparkles, Trophy, Users, TrendingUp, Heart, Image as ImageIcon } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite';
@@ -9,20 +9,23 @@ import { Switch } from '@tamagui/switch';
import { MobileShell } from './components/MobileShell';
import { MobileCard, CTAButton, PillBadge, SkeletonCard } from './components/Primitives';
import { LegalConsentSheet } from './components/LegalConsentSheet';
import { DataExportsPanel } from './DataExportsPage';
import {
getEvent,
getEventStats,
getEventQrInvites,
getEventEngagement,
updateEvent,
TenantEvent,
EventStats,
EventQrInvite,
EventEngagement,
EventAddonCatalogItem,
getAddonCatalog,
createEventAddonCheckout,
} from '../api';
import { isAuthError } from '../auth/tokens';
import { getApiErrorMessage } from '../lib/apiError';
import { getApiErrorMessage, isApiError } from '../lib/apiError';
import { adminPath } from '../constants';
import toast from 'react-hot-toast';
import { useBackNavigation } from './hooks/useBackNavigation';
@@ -37,13 +40,18 @@ type GalleryCounts = {
export default function MobileEventRecapPage() {
const { slug } = useParams<{ slug: string }>();
const navigate = useNavigate();
const { t } = useTranslation('management');
const { textStrong, text, muted, border, primary, successText, danger } = useAdminTheme();
const { t, i18n } = useTranslation('management');
const { textStrong, text, muted, border, primary, danger } = useAdminTheme();
const locale = i18n.language;
const [activeTab, setActiveTab] = React.useState<'overview' | 'engagement' | 'compliance'>('overview');
const [event, setEvent] = React.useState<TenantEvent | null>(null);
const [stats, setEventStats] = React.useState<EventStats | null>(null);
const [invites, setInvites] = React.useState<EventQrInvite[]>([]);
const [addons, setAddons] = React.useState<EventAddonCatalogItem[]>([]);
const [engagement, setEngagement] = React.useState<EventEngagement | null>(null);
const [engagementLoading, setEngagementLoading] = React.useState(false);
const [engagementError, setEngagementError] = React.useState<string | null>(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [consentOpen, setConsentOpen] = React.useState(false);
@@ -78,6 +86,50 @@ export default function MobileEventRecapPage() {
void load();
}, [load]);
React.useEffect(() => {
if (event?.status && event.status !== 'published') {
setEngagement(null);
setEngagementError(null);
setEngagementLoading(false);
return;
}
const token = invites.find((invite) => invite.is_active)?.token ?? invites[0]?.token ?? '';
if (!token) {
setEngagement(null);
setEngagementError(null);
setEngagementLoading(false);
return;
}
const controller = new AbortController();
setEngagementLoading(true);
setEngagementError(null);
getEventEngagement(token, { locale: i18n.language, signal: controller.signal })
.then((payload) => {
setEngagement(payload);
})
.catch((err) => {
if (err?.name === 'AbortError') {
return;
}
if (isApiError(err) && (err.status === 403 || err.status === 404)) {
setEngagement(null);
setEngagementError(null);
return;
}
setEngagement(null);
setEngagementError(getApiErrorMessage(err, t('events.recap.engagementError', 'Engagement konnte nicht geladen werden.')));
})
.finally(() => {
setEngagementLoading(false);
});
return () => {
controller.abort();
};
}, [event?.status, i18n.language, invites, t]);
const handleCheckout = async (addonKey: string) => {
if (!slug || busyScope) return;
setBusyScope(addonKey);
@@ -156,121 +208,355 @@ export default function MobileEventRecapPage() {
onBack={back}
>
<YStack space="$4">
{/* Status & Summary */}
<MobileCard space="$3">
<XStack alignItems="center" justifyContent="space-between">
<YStack space="$1">
<Text fontSize="$xl" fontWeight="800" color={textStrong}>
{t('events.recap.done', 'Event beendet')}
<XStack space="$2">
<TabButton
label={t('events.recap.tabs.overview', 'Overview')}
active={activeTab === 'overview'}
onPress={() => setActiveTab('overview')}
/>
<TabButton
label={t('events.recap.tabs.engagement', 'Engagement')}
active={activeTab === 'engagement'}
onPress={() => setActiveTab('engagement')}
/>
<TabButton
label={t('events.recap.tabs.compliance', 'Compliance')}
active={activeTab === 'compliance'}
onPress={() => setActiveTab('compliance')}
/>
</XStack>
{activeTab === 'overview' ? (
<YStack space="$4">
<MobileCard space="$3">
<XStack alignItems="center" justifyContent="space-between">
<YStack space="$1">
<Text fontSize="$xl" fontWeight="800" color={textStrong}>
{t('events.recap.completedTitle', 'Event abgeschlossen')}
</Text>
<Text fontSize="$sm" color={muted}>
{formatDate(event.event_date, locale)}
</Text>
</YStack>
<PillBadge tone="success">{t('events.recap.statusClosed', 'Archiviert')}</PillBadge>
</XStack>
<XStack flexWrap="wrap" gap="$2" marginTop="$1">
<Stat label={t('events.stats.uploads', 'Uploads')} value={formatCount(galleryCounts.photos, locale)} />
<Stat label={t('events.stats.pending', 'Offen')} value={formatCount(galleryCounts.pending, locale)} />
<Stat label={t('events.stats.likes', 'Likes')} value={formatCount(galleryCounts.likes, locale)} />
</XStack>
</MobileCard>
<MobileCard space="$3">
<XStack alignItems="center" space="$2">
<Share2 size={18} color={primary} />
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('events.recap.shareGuests', 'Gäste-Galerie teilen')}
</Text>
</XStack>
<Text fontSize="$sm" color={text}>
{t('events.recap.shareBody', 'Deine Gäste können die Galerie auch nach dem Event weiterhin ansehen.')}
</Text>
<Text fontSize="$sm" color={muted}>
{formatDate(event.event_date)}
</Text>
</YStack>
<PillBadge tone="success">{t('events.recap.statusClosed', 'Archiviert')}</PillBadge>
</XStack>
<XStack flexWrap="wrap" gap="$2" marginTop="$1">
<Stat label={t('events.stats.uploads', 'Uploads')} value={String(galleryCounts.photos)} />
<Stat label={t('events.stats.pending', 'Offen')} value={String(galleryCounts.pending)} />
<Stat label={t('events.stats.likes', 'Likes')} value={String(galleryCounts.likes)} />
</XStack>
</MobileCard>
{guestLink ? (
<YStack space="$2" marginTop="$1">
<XStack
backgroundColor={border}
padding="$3"
borderRadius={12}
alignItems="center"
justifyContent="space-between"
>
<Text fontSize="$xs" color={muted} numberOfLines={1} flex={1}>
{guestLink}
</Text>
<CTAButton
label={t('events.recap.copyLink', 'Link kopieren')}
tone="ghost"
onPress={() => copyToClipboard(guestLink, t)}
/>
</XStack>
{typeof navigator !== 'undefined' && !!navigator.share && (
<CTAButton
label={t('events.recap.qrShare', 'Link/QR teilen')}
tone="ghost"
onPress={() => shareLink(guestLink, event, t)}
/>
)}
</YStack>
) : (
<Text fontSize="$sm" color={muted} marginTop="$1">
{t('events.recap.noPublicUrl', 'Kein Gäste-Link gesetzt. Lege den öffentlichen Link im Event-Setup fest.')}
</Text>
)}
{/* Share Section */}
<MobileCard space="$3">
<XStack alignItems="center" space="$2">
<Share2 size={18} color={primary} />
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('events.recap.shareGallery', 'Galerie teilen')}
</Text>
</XStack>
<Text fontSize="$sm" color={text}>
{t('events.recap.shareBody', 'Deine Gäste können die Galerie auch nach dem Event weiterhin ansehen.')}
</Text>
<YStack space="$2" marginTop="$1">
<XStack
backgroundColor={border}
padding="$3"
borderRadius={12}
alignItems="center"
justifyContent="space-between"
>
<Text fontSize="$xs" color={muted} numberOfLines={1} flex={1}>
{guestLink}
</Text>
<CTAButton label={t('events.recap.copy', 'Kopieren')} tone="ghost" onPress={() => guestLink && copyToClipboard(guestLink, t)} />
</XStack>
{typeof navigator !== 'undefined' && !!navigator.share && (
<CTAButton label={t('events.recap.share', 'Teilen')} tone="ghost" onPress={() => shareLink(guestLink, event, t)} />
)}
</YStack>
{activeInvite?.qr_code_data_url ? (
<YStack alignItems="center" space="$2" marginTop="$2">
<YStack
{guestLink && activeInvite?.qr_code_data_url ? (
<YStack alignItems="center" space="$2" marginTop="$2">
<YStack
padding="$2"
backgroundColor="white"
borderRadius={12}
borderWidth={1}
borderColor={border}
>
<img src={activeInvite.qr_code_data_url} alt="QR" style={{ width: 120, height: 120 }} />
>
<img
src={activeInvite.qr_code_data_url}
alt={t('events.recap.qrAlt', 'QR-Code zur Gäste-Galerie')}
style={{ width: 120, height: 120 }}
/>
</YStack>
<CTAButton
label={t('events.recap.qrDownload', 'QR-Code herunterladen')}
tone="ghost"
onPress={() => downloadQr(activeInvite.qr_code_data_url!)}
/>
</YStack>
<CTAButton label={t('events.recap.downloadQr', 'QR herunterladen')} tone="ghost" onPress={() => downloadQr(activeInvite.qr_code_data_url!)} />
</YStack>
) : null}
</MobileCard>
) : null}
</MobileCard>
{/* Settings */}
<MobileCard space="$3">
<XStack alignItems="center" space="$2">
<Users size={18} color={primary} />
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('events.recap.settings', 'Nachlauf-Optionen')}
</Text>
</XStack>
<MobileCard space="$3">
<XStack alignItems="center" space="$2">
<Users size={18} color={primary} />
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('events.recap.settings', 'Nachlauf-Optionen')}
</Text>
</XStack>
<YStack space="$1.5">
<ToggleOption
label={t('events.recap.allowDownloads', 'Gäste dürfen Fotos laden')}
value={Boolean(event.settings?.guest_downloads_enabled)}
onToggle={(value) => updateSetting(event, setEvent, slug, 'guest_downloads_enabled', value, setError, t as any)}
/>
<ToggleOption
label={t('events.recap.allowSharing', 'Gäste dürfen Fotos teilen')}
value={Boolean(event.settings?.guest_sharing_enabled)}
onToggle={(value) => updateSetting(event, setEvent, slug, 'guest_sharing_enabled', value, setError, t as any)}
/>
</YStack>
</MobileCard>
{/* Extensions */}
<MobileCard space="$3">
<XStack alignItems="center" space="$2">
<Sparkles size={18} color={primary} />
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('events.recap.addons', 'Galerie verlängern')}
</Text>
</XStack>
<Text fontSize="$sm" color={text}>
{t('events.recap.addonBody', 'Die Online-Zeit deiner Galerie neigt sich dem Ende? Hier kannst du sie verlängern.')}
</Text>
<YStack space="$2">
{addons
.filter((a) => a.key === 'gallery_extension')
.map((addon) => (
<CTAButton
key={addon.key}
label={t('events.recap.buyExtension', 'Galerie um 30 Tage verlängern')}
onPress={() => handleCheckout(addon.key)}
loading={busyScope === addon.key}
<YStack space="$1.5">
<ToggleOption
label={t('events.recap.allowDownloads', 'Gäste dürfen Fotos laden')}
value={Boolean(event.settings?.guest_downloads_enabled)}
onToggle={(value) => updateSetting(event, setEvent, slug, 'guest_downloads_enabled', value, setError, t as any)}
/>
))}
<ToggleOption
label={t('events.recap.allowSharing', 'Gäste dürfen Fotos teilen')}
value={Boolean(event.settings?.guest_sharing_enabled)}
onToggle={(value) => updateSetting(event, setEvent, slug, 'guest_sharing_enabled', value, setError, t as any)}
/>
</YStack>
</MobileCard>
<MobileCard space="$3">
<XStack alignItems="center" space="$2">
<Sparkles size={18} color={primary} />
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('events.recap.addons', 'Galerie verlängern')}
</Text>
</XStack>
<Text fontSize="$sm" color={text}>
{t('events.recap.addonBody', 'Die Online-Zeit deiner Galerie neigt sich dem Ende? Hier kannst du sie verlängern.')}
</Text>
<YStack space="$2">
{addons
.filter((a) => a.key === 'gallery_extension')
.map((addon) => (
<CTAButton
key={addon.key}
label={t('events.recap.buyExtension', 'Galerie um 30 Tage verlängern')}
onPress={() => handleCheckout(addon.key)}
loading={busyScope === addon.key}
/>
))}
</YStack>
</MobileCard>
</YStack>
</MobileCard>
) : null}
{activeTab === 'engagement' ? (
<YStack space="$4">
{engagementLoading ? (
<YStack space="$2">
<SkeletonCard height={140} />
<SkeletonCard height={180} />
<SkeletonCard height={180} />
</YStack>
) : engagementError ? (
<MobileCard>
<Text fontWeight="700" color={danger}>
{engagementError}
</Text>
</MobileCard>
) : !engagement ? (
<MobileCard>
<Text fontWeight="700" color={muted}>
{activeInvite
? t('events.recap.engagement.empty', 'No engagement data yet.')
: t('events.recap.engagement.noInvite', 'No active guest link available yet.')}
</Text>
</MobileCard>
) : (
<YStack space="$4">
<MobileCard space="$3">
<XStack alignItems="center" space="$2">
<TrendingUp size={18} color={primary} />
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('events.recap.engagement.title', 'Guest engagement')}
</Text>
</XStack>
<Text fontSize="$sm" color={text}>
{t('events.recap.engagement.subtitle', 'Highlights, leaderboards, and milestones from your guests.')}
</Text>
<XStack flexWrap="wrap" gap="$2" marginTop="$1">
<Stat
label={t('events.recap.engagement.summary.photos', 'Photos')}
value={formatCount(engagement.summary.totalPhotos, locale)}
/>
<Stat
label={t('events.recap.engagement.summary.guests', 'Guests')}
value={formatCount(engagement.summary.uniqueGuests, locale)}
/>
<Stat
label={t('events.recap.engagement.summary.tasks', 'Tasks solved')}
value={formatCount(engagement.summary.tasksSolved, locale)}
/>
<Stat
label={t('events.recap.engagement.summary.likes', 'Likes')}
value={formatCount(engagement.summary.likesTotal, locale)}
/>
</XStack>
</MobileCard>
<MobileCard space="$3">
<XStack alignItems="center" space="$2">
<Trophy size={18} color={primary} />
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('events.recap.engagement.leaderboards.uploadsTitle', 'Top contributors')}
</Text>
</XStack>
{engagement.leaderboards.uploads.length === 0 ? (
<Text fontSize="$sm" color={muted}>
{t('events.recap.engagement.leaderboards.uploadsEmpty', 'No uploads yet.')}
</Text>
) : (
<YStack space="$1.5" marginTop="$1">
{engagement.leaderboards.uploads.slice(0, 5).map((entry, index) => (
<LeaderboardRow
key={`${entry.guest}-${entry.photos}-${index}`}
rank={index + 1}
name={entry.guest || t('events.recap.engagement.guestFallback', 'Guest')}
value={`${formatCount(entry.photos, locale)} · ${formatCount(entry.likes, locale)} ${t('events.recap.engagement.likesLabel', 'Likes')}`}
/>
))}
</YStack>
)}
</MobileCard>
<MobileCard space="$3">
<XStack alignItems="center" space="$2">
<Heart size={18} color={primary} />
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('events.recap.engagement.leaderboards.likesTitle', 'Most liked')}
</Text>
</XStack>
{engagement.leaderboards.likes.length === 0 ? (
<Text fontSize="$sm" color={muted}>
{t('events.recap.engagement.leaderboards.likesEmpty', 'No likes yet.')}
</Text>
) : (
<YStack space="$1.5" marginTop="$1">
{engagement.leaderboards.likes.slice(0, 5).map((entry, index) => (
<LeaderboardRow
key={`${entry.guest}-${entry.likes}-${index}`}
rank={index + 1}
name={entry.guest || t('events.recap.engagement.guestFallback', 'Guest')}
value={`${formatCount(entry.likes, locale)} ${t('events.recap.engagement.likesLabel', 'Likes')}`}
/>
))}
</YStack>
)}
</MobileCard>
<MobileCard space="$3">
<XStack alignItems="center" space="$2">
<Sparkles size={18} color={primary} />
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('events.recap.engagement.highlightsTitle', 'Highlights')}
</Text>
</XStack>
<YStack space="$2" marginTop="$1">
<XStack space="$2" alignItems="center">
<YStack
width={72}
height={72}
borderRadius={12}
backgroundColor={border}
alignItems="center"
justifyContent="center"
overflow="hidden"
>
{engagement.highlights.topPhoto?.thumbnail ? (
<img
src={engagement.highlights.topPhoto.thumbnail}
alt={t('events.recap.engagement.topPhoto', 'Top photo')}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
) : (
<ImageIcon size={24} color={muted} />
)}
</YStack>
<YStack flex={1}>
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{t('events.recap.engagement.topPhoto', 'Top photo')}
</Text>
<Text fontSize="$xs" color={muted}>
{engagement.highlights.topPhoto
? `${engagement.highlights.topPhoto.guest || t('events.recap.engagement.guestFallback', 'Guest')} · ${formatCount(engagement.highlights.topPhoto.likes, locale)} ${t('events.recap.engagement.likesLabel', 'Likes')}`
: t('events.recap.engagement.topPhotoEmpty', 'No photo highlights yet.')}
</Text>
</YStack>
</XStack>
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$sm" fontWeight="600" color={textStrong}>
{t('events.recap.engagement.trendingEmotion', 'Trending emotion')}
</Text>
<Text fontSize="$sm" color={muted}>
{engagement.highlights.trendingEmotion
? `${engagement.highlights.trendingEmotion.name} · ${formatCount(engagement.highlights.trendingEmotion.count, locale)}`
: t('events.recap.engagement.trendingEmpty', 'No trends yet.')}
</Text>
</XStack>
<YStack space="$1">
<Text fontSize="$sm" fontWeight="600" color={textStrong}>
{t('events.recap.engagement.timeline', 'Uploads over time')}
</Text>
{engagement.highlights.timeline.length === 0 ? (
<Text fontSize="$xs" color={muted}>
{t('events.recap.engagement.timelineEmpty', 'No timeline data yet.')}
</Text>
) : (
<YStack space="$1">
{engagement.highlights.timeline.slice(-5).map((point) => (
<XStack key={point.date} alignItems="center" justifyContent="space-between">
<Text fontSize="$xs" color={muted}>
{formatShortDate(point.date, locale)}
</Text>
<Text fontSize="$xs" color={muted}>
{t('events.recap.engagement.timelineRow', '{{photos}} photos · {{guests}} guests', {
photos: formatCount(point.photos, locale),
guests: formatCount(point.guests, locale),
})}
</Text>
</XStack>
))}
</YStack>
)}
</YStack>
</YStack>
</MobileCard>
</YStack>
)}
</YStack>
) : null}
{activeTab === 'compliance' ? (
<YStack space="$4">
<DataExportsPanel variant="recap" event={event} />
</YStack>
) : null}
</YStack>
<LegalConsentSheet
@@ -323,6 +609,67 @@ function ToggleOption({ label, value, onToggle }: { label: string; value: boolea
);
}
function TabButton({ label, active, onPress }: { label: string; active: boolean; onPress: () => void }) {
const { primary, surfaceMuted, border, surface, textStrong } = useAdminTheme();
return (
<Pressable onPress={onPress} style={{ flex: 1 }}>
<XStack
alignItems="center"
justifyContent="center"
paddingVertical="$2.5"
borderRadius={12}
backgroundColor={active ? primary : surfaceMuted}
borderWidth={1}
borderColor={active ? primary : border}
>
<Text fontSize="$sm" color={active ? surface : textStrong} fontWeight="700">
{label}
</Text>
</XStack>
</Pressable>
);
}
function LeaderboardRow({ rank, name, value }: { rank: number; name: string; value: string }) {
const { textStrong, muted, border, surfaceMuted } = useAdminTheme();
return (
<XStack
alignItems="center"
justifyContent="space-between"
paddingHorizontal="$2.5"
paddingVertical="$2"
borderRadius={12}
borderWidth={1}
borderColor={border}
backgroundColor={surfaceMuted}
>
<XStack alignItems="center" space="$2">
<Text fontSize="$xs" color={muted} fontWeight="700">
#{rank}
</Text>
<Text fontSize="$sm" color={textStrong} fontWeight="600">
{name}
</Text>
</XStack>
<Text fontSize="$xs" color={muted}>
{value}
</Text>
</XStack>
);
}
function formatCount(value: number, locale?: string): string {
const normalized = Number.isFinite(value) ? value : 0;
return new Intl.NumberFormat(locale, { maximumFractionDigits: 0 }).format(normalized);
}
function formatShortDate(iso: string, locale?: string): string {
if (!iso) return '';
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return '';
return new Intl.DateTimeFormat(locale, { month: 'short', day: 'numeric' }).format(date);
}
async function updateSetting(
event: TenantEvent,
setEvent: (event: TenantEvent) => void,
@@ -384,9 +731,9 @@ function resolveName(name: TenantEvent['name']): string {
return 'Event';
}
function formatDate(iso?: string | null): string {
function formatDate(iso?: string | null, locale?: string): 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' });
return date.toLocaleDateString(locale, { day: '2-digit', month: 'short', year: 'numeric' });
}

View File

@@ -41,7 +41,7 @@ export default function MobileEventsPage() {
setEvents(await getEvents());
} catch (err) {
if (!isAuthError(err)) {
setError(getApiErrorMessage(err, t('events.errors.loadFailed', 'Event konnte nicht geladen werden.')));
setError(getApiErrorMessage(err, t('events.errors.loadFailed')));
}
} finally {
setLoading(false);
@@ -52,10 +52,13 @@ export default function MobileEventsPage() {
return (
<MobileShell
activeTab="home"
title={t('events.list.dashboardTitle', 'All Events Dashboard')}
title={t('events.list.title')}
onBack={back}
headerActions={
<HeaderActionButton onPress={() => searchRef.current?.focus()} ariaLabel={t('events.list.search', 'Search events')}>
<HeaderActionButton
onPress={() => searchRef.current?.focus()}
ariaLabel={t('events.detail.pickEvent')}
>
<Search size={18} color={text} />
</HeaderActionButton>
}
@@ -100,7 +103,7 @@ export default function MobileEventsPage() {
backgroundColor={surfaceMuted}
>
<Text fontSize="$xs" fontWeight="800" color={text}>
{t('events.list.filters.title', 'Filters & Search')}
{t('events.list.overview.title')}
</Text>
</XStack>
<MobileInput
@@ -108,11 +111,11 @@ export default function MobileEventsPage() {
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t('events.list.search', 'Search events')}
placeholder={t('events.detail.pickEvent')}
compact
/>
<Text fontSize="$xs" color={muted}>
{t('events.list.filters.hint', 'Filter your events by status or search by name.')}
{t('events.list.subtitle')}
</Text>
</YStack>
</Card>
@@ -137,13 +140,16 @@ export default function MobileEventsPage() {
>
<YStack space="$2" alignItems="center">
<Text fontSize="$md" fontWeight="700">
{t('events.list.empty.title', 'Noch kein Event angelegt')}
{t('events.list.title')}
</Text>
<Text fontSize="$sm" color={muted} textAlign="center">
{t('events.list.empty.description', 'Starte jetzt mit deinem ersten Event.')}
{t('events.list.overview.empty')}
</Text>
{!isMember ? (
<CTAButton label={t('events.actions.create', 'Create New Event')} onPress={() => navigate(adminPath('/events/new'))} />
<CTAButton
label={t('events.list.actions.create')}
onPress={() => navigate(adminPath('/events/new'))}
/>
) : null}
</YStack>
</Card>
@@ -160,7 +166,7 @@ export default function MobileEventsPage() {
{!isMember ? (
<FloatingActionButton
label={t('events.actions.create', 'Create New Event')}
label={t('events.list.actions.create')}
icon={Plus}
onPress={() => navigate(adminPath('/mobile/events/new'))}
/>
@@ -204,10 +210,10 @@ function EventsList({
}, [filteredByStatus, query]);
const filters: Array<{ key: EventStatusKey; label: string; count: number }> = [
{ key: 'all', label: t('events.list.filters.all', 'All'), count: statusCounts.all },
{ key: 'upcoming', label: t('events.list.filters.upcoming', 'Upcoming'), count: statusCounts.upcoming },
{ key: 'draft', label: t('events.list.filters.draft', 'Draft'), count: statusCounts.draft },
{ key: 'past', label: t('events.list.filters.past', 'Past'), count: statusCounts.past },
{ key: 'all', label: t('events.list.filters.all'), count: statusCounts.all },
{ key: 'upcoming', label: t('events.list.filters.upcoming'), count: statusCounts.upcoming },
{ key: 'draft', label: t('events.list.filters.draft'), count: statusCounts.draft },
{ key: 'past', label: t('events.list.filters.past'), count: statusCounts.past },
];
return (
@@ -234,7 +240,7 @@ function EventsList({
backgroundColor={surfaceMuted}
>
<Text fontSize="$xs" fontWeight="800" color={text}>
{t('events.list.filters.status', 'Status')}
{t('events.workspace.fields.status')}
</Text>
</XStack>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
@@ -286,13 +292,13 @@ function EventsList({
>
<YStack space="$2" alignItems="center">
<Text fontSize="$sm" fontWeight="700" color={text}>
{t('events.list.empty.filtered', 'No events match this filter.')}
{t('events.list.empty.filtered')}
</Text>
<Text fontSize="$xs" color={muted} textAlign="center">
{t('events.list.empty.filteredHint', 'Try a different status or clear your search.')}
{t('events.list.empty.filteredHint')}
</Text>
<CTAButton
label={t('events.list.filters.all', 'All')}
label={t('events.list.filters.all')}
tone="ghost"
fullWidth={false}
onPress={() => onStatusChange('all')}
@@ -304,10 +310,10 @@ function EventsList({
const statusKey = resolveEventStatusKey(event);
const statusLabel =
statusKey === 'draft'
? t('events.list.filters.draft', 'Draft')
? t('events.list.filters.draft')
: statusKey === 'past'
? t('events.list.filters.past', 'Past')
: t('events.list.filters.upcoming', 'Upcoming');
? t('events.list.filters.past')
: t('events.list.filters.upcoming');
const statusTone = statusKey === 'draft' ? 'warning' : statusKey === 'past' ? 'muted' : 'success';
return (
<EventRow
@@ -359,7 +365,8 @@ function EventRow({
onOpen: (slug: string) => void;
onEdit?: (slug: string) => void;
}) {
const { t } = useTranslation('management');
const { t, i18n } = useTranslation('management');
const locale = i18n.language;
const stats = buildEventListStats(event);
return (
<Card
@@ -377,18 +384,18 @@ function EventRow({
<XStack justifyContent="space-between" alignItems="flex-start" space="$2">
<YStack space="$1">
<Text fontSize="$md" fontWeight="800" color={text}>
{renderName(event.name)}
{renderName(event.name, t)}
</Text>
<XStack alignItems="center" space="$2">
<CalendarDays size={14} color={subtle} />
<Text fontSize="$sm" color={muted}>
{formatDate(event.event_date)}
{formatDate(event.event_date, t, locale)}
</Text>
</XStack>
<XStack alignItems="center" space="$2">
<MapPin size={14} color={subtle} />
<Text fontSize="$sm" color={muted}>
{resolveLocation(event)}
{resolveLocation(event, t)}
</Text>
</XStack>
<PillBadge tone={statusTone}>{statusLabel}</PillBadge>
@@ -405,19 +412,19 @@ function EventRow({
<XStack alignItems="center" space="$2" flexWrap="wrap">
<EventStatChip
icon={Camera}
label={t('events.list.stats.photos', 'Photos')}
label={t('events.list.stats.photos')}
value={stats.photos}
muted={subtle}
/>
<EventStatChip
icon={Users}
label={t('events.list.stats.guests', 'Guests')}
label={t('events.list.stats.guests')}
value={stats.guests}
muted={subtle}
/>
<EventStatChip
icon={Sparkles}
label={t('events.list.stats.tasks', 'Tasks')}
label={t('events.list.stats.tasks')}
value={stats.tasks}
muted={subtle}
/>
@@ -429,7 +436,7 @@ function EventRow({
<XStack alignItems="center" justifyContent="flex-start" space="$2">
<Plus size={16} color={primary} />
<Text fontSize="$sm" color={primary} fontWeight="700">
{t('events.list.actions.open', 'Open event')}
{t('events.list.actions.open')}
</Text>
</XStack>
</Pressable>
@@ -459,15 +466,15 @@ function EventStatChip({
);
}
function renderName(name: TenantEvent['name']): string {
function renderName(name: TenantEvent['name'], t: (key: string) => string): string {
if (typeof name === 'string') return name;
if (name && typeof name === 'object') {
return name.de ?? name.en ?? Object.values(name)[0] ?? 'Unbenanntes Event';
return name.de ?? name.en ?? Object.values(name)[0] ?? t('events.placeholders.untitled');
}
return 'Unbenanntes Event';
return t('events.placeholders.untitled');
}
function resolveLocation(event: TenantEvent): string {
function resolveLocation(event: TenantEvent, t: (key: string) => string): string {
const settings = (event.settings ?? {}) as Record<string, unknown>;
const candidate =
(settings.location as string | undefined) ??
@@ -476,14 +483,15 @@ function resolveLocation(event: TenantEvent): string {
if (candidate && candidate.trim()) {
return candidate;
}
return 'Location';
return t('events.detail.locationPlaceholder');
}
function formatDate(iso: string | null): string {
if (!iso) return 'Date tbd';
function formatDate(iso: string | null, t: (key: string) => string, locale?: string): string {
const fallback = t('events.detail.dateTbd');
if (!iso) return fallback;
const date = new Date(iso);
if (Number.isNaN(date.getTime())) {
return 'Date tbd';
return fallback;
}
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
return date.toLocaleDateString(locale || undefined, { month: 'short', day: 'numeric', year: 'numeric' });
}

View File

@@ -11,6 +11,7 @@ import { MobileCard, CTAButton, PillBadge, SkeletonCard } from './components/Pri
import { useAdminTheme } from './theme';
import { getPackages, Package, getTenantPackagesOverview, TenantPackageSummary } from '../api';
import { useQuery } from '@tanstack/react-query';
import { adminPath } from '../constants';
import {
buildPackageComparisonRows,
classifyPackageChange,
@@ -170,6 +171,7 @@ export default function MobilePackageShopPage() {
<PackageShopCompareView
entries={packageEntries}
onSelect={(pkg) => setSelectedPackage(pkg)}
onManage={() => navigate(adminPath('/mobile/billing#packages'))}
catalogType={catalogType}
/>
) : (
@@ -184,6 +186,7 @@ export default function MobilePackageShopPage() {
isDowngrade={entry.isDowngrade}
catalogType={catalogType}
onSelect={() => setSelectedPackage(entry.pkg)}
onManage={() => navigate(adminPath('/mobile/billing#packages'))}
/>
))
)}
@@ -201,7 +204,8 @@ function PackageShopCard({
isUpgrade,
isDowngrade,
catalogType,
onSelect
onSelect,
onManage,
}: {
pkg: Package;
owned?: TenantPackageSummary;
@@ -210,7 +214,8 @@ function PackageShopCard({
isUpgrade?: boolean;
isDowngrade?: boolean;
catalogType: 'endcustomer' | 'reseller';
onSelect: () => void
onSelect: () => void;
onManage?: () => void;
}) {
const { textStrong, muted, border, primary, accentSoft } = useAdminTheme();
const { t } = useTranslation('management');
@@ -219,15 +224,17 @@ function PackageShopCard({
const statusLabel = getPackageStatusLabel({ t, isActive, owned });
const isSubdued = Boolean(!isResellerCatalog && (isDowngrade || !isUpgrade) && !isActive);
const canSelect = isResellerCatalog ? Boolean(pkg.paddle_price_id) : canSelectPackage(isUpgrade, isActive);
const hasManageAction = Boolean(isActive && onManage);
const includedTierLabel = resolveIncludedTierLabel(t, pkg.included_package_slug ?? null);
const handlePress = isActive ? onManage : canSelect ? onSelect : undefined;
return (
<MobileCard
onPress={canSelect ? onSelect : undefined}
onPress={handlePress}
borderColor={isRecommended ? primary : (isActive ? '$green8' : border)}
borderWidth={isRecommended || isActive ? 2 : 1}
space="$3"
pressStyle={canSelect ? { backgroundColor: accentSoft } : undefined}
pressStyle={handlePress ? { backgroundColor: accentSoft } : undefined}
backgroundColor={isActive ? '$green1' : undefined}
style={{ opacity: isSubdued ? 0.6 : 1 }}
>
@@ -314,9 +321,9 @@ function PackageShopCard({
? t('shop.select', 'Select')
: t('shop.selectDisabled', 'Not available')
}
onPress={canSelect ? onSelect : undefined}
onPress={handlePress}
tone={isResellerCatalog ? (canSelect ? 'primary' : 'ghost') : isActive || !isUpgrade ? 'ghost' : 'primary'}
disabled={!canSelect}
disabled={!canSelect && !hasManageAction}
/>
</MobileCard>
);
@@ -344,10 +351,12 @@ type PackageEntry = {
function PackageShopCompareView({
entries,
onSelect,
onManage,
catalogType,
}: {
entries: PackageEntry[];
onSelect: (pkg: Package) => void;
onManage: () => void;
catalogType: 'endcustomer' | 'reseller';
}) {
const { t } = useTranslation('management');
@@ -520,13 +529,14 @@ function PackageShopCompareView({
: entry.isUpgrade
? t('shop.select', 'Select')
: t('shop.selectDisabled', 'Not available');
const handlePress = entry.isActive ? onManage : canSelect ? () => onSelect(entry.pkg) : undefined;
return (
<YStack key={`cta-${entry.pkg.id}`} width={columnWidth} paddingHorizontal="$2">
<CTAButton
label={label}
onPress={canSelect ? () => onSelect(entry.pkg) : undefined}
disabled={!canSelect}
onPress={handlePress}
disabled={!canSelect && !entry.isActive}
tone={
catalogType === 'reseller'
? canSelect

View File

@@ -1,6 +1,7 @@
import React from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { render, screen, waitFor } from '@testing-library/react';
import { ADMIN_EVENTS_PATH } from '../../constants';
const fixtures = vi.hoisted(() => ({
event: {
@@ -41,6 +42,9 @@ const authState = {
status: 'authenticated',
user: { role: 'tenant_admin' },
};
const paramsState = {
slug: fixtures.event.slug as string | undefined,
};
const eventContext = {
events: [fixtures.event],
activeEvent: fixtures.event,
@@ -52,7 +56,7 @@ const eventContext = {
vi.mock('react-router-dom', () => ({
useNavigate: () => navigateMock,
useLocation: () => ({ search: '', pathname: '/event-admin/mobile/dashboard' }),
useParams: () => ({ slug: fixtures.event.slug }),
useParams: () => paramsState,
}));
vi.mock('react-i18next', () => ({
@@ -143,6 +147,13 @@ vi.mock('../components/Sheet', () => ({
vi.mock('../components/Primitives', () => ({
MobileCard: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
CTAButton: ({ label }: { label: string }) => <button type="button">{label}</button>,
KpiStrip: ({ items }: { items: Array<{ label: string; value: string | number }> }) => (
<div>
{items.map((item) => (
<span key={item.label}>{item.label}</span>
))}
</div>
),
KpiTile: ({ label, value }: { label: string; value: string | number }) => (
<div>
<span>{label}</span>
@@ -244,6 +255,7 @@ describe('MobileDashboardPage', () => {
eventContext.activeEvent = fixtures.event;
eventContext.hasEvents = true;
eventContext.hasMultipleEvents = false;
paramsState.slug = fixtures.event.slug;
fixtures.activePackage.package_type = 'reseller';
fixtures.activePackage.remaining_events = 3;
@@ -253,19 +265,27 @@ describe('MobileDashboardPage', () => {
window.sessionStorage.clear();
});
it('shows a tasks setup nudge and prompt when no tasks are assigned', () => {
it('redirects to the event selector when no event is active', async () => {
eventContext.activeEvent = null as unknown as typeof fixtures.event;
eventContext.events = [fixtures.event];
eventContext.hasEvents = true;
paramsState.slug = undefined;
render(<MobileDashboardPage />);
await waitFor(() => {
expect(navigateMock).toHaveBeenCalledWith(ADMIN_EVENTS_PATH, { replace: true });
});
});
it('shows the next-step CTA when tasks are missing', () => {
fixtures.event.tasks_count = 0;
fixtures.event.engagement_mode = 'tasks';
window.sessionStorage.setItem(`tasksDecisionPrompt:${fixtures.event.id}`, 'pending');
render(<MobileDashboardPage />);
expect(screen.getByText('Setup needed')).toBeInTheDocument();
expect(
screen.getByText(
'Your event is live with tasks enabled, but no tasks are assigned yet. Choose to add tasks now or disable tasks for this event.'
)
).toBeInTheDocument();
expect(screen.getByText('Aufgaben hinzufügen')).toBeInTheDocument();
});
it('does not redirect endcustomer packages without remaining event quota', () => {
@@ -282,21 +302,21 @@ describe('MobileDashboardPage', () => {
expect(navigateMock).not.toHaveBeenCalledWith('/event-admin/mobile/billing#packages', { replace: true });
});
it('shows package usage progress when a limit is available', () => {
it('shows the activity pulse strip', () => {
render(<MobileDashboardPage />);
expect(screen.getByText('2 of 5 events used')).toBeInTheDocument();
expect(screen.getByText('3 remaining')).toBeInTheDocument();
expect(screen.getAllByText('Photos').length).toBeGreaterThan(0);
expect(screen.getAllByText('Guests').length).toBeGreaterThan(0);
expect(screen.getAllByText('Pending').length).toBeGreaterThan(0);
});
it('hides admin-only shortcuts for members', () => {
it('shows shortcut sections for members', () => {
authState.user = { role: 'member' };
render(<MobileDashboardPage />);
expect(screen.getByText('Moderation & Live Show')).toBeInTheDocument();
expect(screen.queryByText('Event settings')).not.toBeInTheDocument();
expect(screen.queryByText('Live Show settings')).not.toBeInTheDocument();
expect(screen.getByText('Experience')).toBeInTheDocument();
expect(screen.getByText('Settings')).toBeInTheDocument();
authState.user = { role: 'tenant_admin' };
});

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, screen } from '@testing-library/react';
const backMock = vi.fn();
@@ -7,10 +7,11 @@ const navigateMock = vi.fn();
const selectEventMock = vi.fn();
const refetchMock = vi.fn();
const invalidateQueriesMock = vi.fn();
const paramsState: { slug?: string } = {};
vi.mock('react-router-dom', () => ({
useNavigate: () => navigateMock,
useParams: () => ({}),
useParams: () => paramsState,
}));
vi.mock('@tanstack/react-query', () => ({
@@ -50,11 +51,18 @@ vi.mock('../components/Primitives', () => ({
{label}
</button>
),
FloatingActionButton: ({ label, onPress }: { label: string; onPress?: () => void }) => (
<button type="button" onClick={onPress}>
{label}
</button>
),
}));
vi.mock('../components/FormControls', () => ({
MobileField: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
MobileDateTimeInput: (props: React.InputHTMLAttributes<HTMLInputElement>) => <input {...props} />,
MobileDateTimeInput: (props: React.InputHTMLAttributes<HTMLInputElement>) => (
<input type="datetime-local" {...props} />
),
MobileInput: (props: React.InputHTMLAttributes<HTMLInputElement>) => <input {...props} />,
MobileSelect: ({ children, ...props }: { children: React.ReactNode }) => <select {...props}>{children}</select>,
MobileTextArea: (props: React.TextareaHTMLAttributes<HTMLTextAreaElement>) => <textarea {...props} />,
@@ -108,11 +116,16 @@ vi.mock('../../context/EventContext', () => ({
}),
}));
import { getEventTypes } from '../../api';
import { getEvent, getEventTypes } from '../../api';
import MobileEventFormPage from '../EventFormPage';
describe('MobileEventFormPage', () => {
afterEach(() => {
paramsState.slug = undefined;
});
it('renders a save draft button when creating a new event', async () => {
paramsState.slug = undefined;
await act(async () => {
render(<MobileEventFormPage />);
});
@@ -124,6 +137,7 @@ describe('MobileEventFormPage', () => {
});
it('defaults event type to wedding when available', async () => {
paramsState.slug = undefined;
vi.mocked(getEventTypes).mockResolvedValueOnce([
{ id: 11, slug: 'conference', name: 'Conference', name_translations: {}, icon: null, settings: {} },
{ id: 22, slug: 'wedding', name: 'Wedding', name_translations: {}, icon: null, settings: {} },
@@ -136,4 +150,24 @@ describe('MobileEventFormPage', () => {
const select = screen.getByRole('combobox');
expect(select).toHaveValue('22');
});
it('disables the date field when the event is completed', async () => {
paramsState.slug = 'past-event';
vi.mocked(getEvent).mockResolvedValueOnce({
id: 1,
name: 'Past event',
slug: 'past-event',
event_date: '2020-01-01T10:00:00Z',
event_type_id: null,
event_type: null,
status: 'archived',
description: null,
settings: {},
} as any);
render(<MobileEventFormPage />);
const dateInput = await screen.findByDisplayValue('2020-01-01T10:00');
expect(dateInput).toBeDisabled();
});
});

View File

@@ -15,10 +15,12 @@ const fixtures = vi.hoisted(() => ({
},
},
stats: {
photo_count: 12,
like_count: 3,
pending_count: 1,
guest_count: 22,
total: 12,
likes: 3,
pending_photos: 1,
recent_uploads: 4,
status: 'published',
is_active: true,
},
invites: [],
addons: [],
@@ -40,7 +42,12 @@ vi.mock('react-i18next', () => ({
}
return key;
},
i18n: { language: 'de' },
}),
initReactI18next: {
type: '3rdParty',
init: () => undefined,
},
}));
vi.mock('../hooks/useBackNavigation', () => ({
@@ -54,6 +61,15 @@ vi.mock('../../api', () => ({
getAddonCatalog: vi.fn().mockResolvedValue(fixtures.addons),
updateEvent: vi.fn().mockResolvedValue(fixtures.event),
createEventAddonCheckout: vi.fn(),
getEventEngagement: vi.fn().mockResolvedValue({
summary: { totalPhotos: 0, uniqueGuests: 0, tasksSolved: 0, likesTotal: 0 },
leaderboards: { uploads: [], likes: [] },
highlights: { topPhoto: null, trendingEmotion: null, timeline: [] },
feed: [],
}),
listTenantDataExports: vi.fn().mockResolvedValue([]),
requestTenantDataExport: vi.fn(),
downloadTenantDataExport: vi.fn(),
}));
vi.mock('../../auth/tokens', () => ({
@@ -76,6 +92,14 @@ vi.mock('../components/Primitives', () => ({
SkeletonCard: () => <div>Loading...</div>,
}));
vi.mock('../components/FormControls', () => ({
MobileSelect: ({ children, value, onChange }: any) => (
<select value={value ?? ''} onChange={onChange}>
{children}
</select>
),
}));
vi.mock('../components/LegalConsentSheet', () => ({
LegalConsentSheet: () => <div />,
}));
@@ -125,6 +149,7 @@ vi.mock('../theme', () => ({
danger: '#dc2626',
surface: '#ffffff',
surfaceMuted: '#f9fafb',
backdrop: '#111827',
accentSoft: '#eef2ff',
textStrong: '#0f172a',
}),

View File

@@ -13,14 +13,34 @@ vi.mock('react-router-dom', () => ({
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, fallback?: string | Record<string, unknown>) => {
if (typeof fallback === 'string') {
return fallback;
}
if (fallback && typeof fallback === 'object' && typeof fallback.defaultValue === 'string') {
return fallback.defaultValue;
}
return key;
t: (key: string) => {
const translations: Record<string, string> = {
'events.list.title': 'Your events',
'events.list.subtitle': 'Plan memorable moments. Manage everything around your events here.',
'events.list.overview.title': 'Overview',
'events.list.overview.empty': 'No events yet create your first one to get started.',
'events.list.filters.all': 'All',
'events.list.filters.upcoming': 'Upcoming',
'events.list.filters.draft': 'Draft',
'events.list.filters.past': 'Past',
'events.list.actions.create': 'New event',
'events.list.actions.open': 'Open event',
'events.list.empty.filtered': 'No events match this filter.',
'events.list.empty.filteredHint': 'Try a different status or clear your search.',
'events.list.stats.photos': 'Photos',
'events.list.stats.guests': 'Guests',
'events.list.stats.tasks': 'Tasks',
'events.workspace.fields.status': 'Status',
'events.detail.pickEvent': 'Select event',
'events.detail.dateTbd': 'Date tbd',
'events.detail.locationPlaceholder': 'Location',
'events.placeholders.untitled': 'Untitled event',
'events.errors.loadFailed': 'Event konnte nicht geladen werden.',
};
return translations[key] ?? key;
},
i18n: {
language: 'en',
},
}),
}));
@@ -136,7 +156,7 @@ describe('MobileEventsPage', () => {
it('renders filters and event list', async () => {
render(<MobileEventsPage />);
expect(await screen.findByText('Filters & Search')).toBeInTheDocument();
expect(await screen.findByText('Overview')).toBeInTheDocument();
expect(screen.getByText('Status')).toBeInTheDocument();
expect(screen.getByText('Demo Event')).toBeInTheDocument();
});
@@ -147,7 +167,7 @@ describe('MobileEventsPage', () => {
render(<MobileEventsPage />);
expect(await screen.findByText('Demo Event')).toBeInTheDocument();
expect(screen.queryByText('Create New Event')).not.toBeInTheDocument();
expect(screen.queryByText('New event')).not.toBeInTheDocument();
authState.user = { role: 'tenant_admin' };
});

View File

@@ -33,7 +33,7 @@ type MobileShellProps = {
};
export function MobileShell({ title, subtitle, children, activeTab, onBack, headerActions }: MobileShellProps) {
const { events, activeEvent, hasMultipleEvents, selectEvent } = useEventContext();
const { events, activeEvent, selectEvent } = useEventContext();
const { user } = useAuth();
const { go } = useMobileNav(activeEvent?.slug, activeTab);
const navigate = useNavigate();
@@ -45,6 +45,7 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
const theme = useAdminTheme();
const backgroundColor = theme.background;
const [isCompactHeader, setIsCompactHeader] = React.useState(false);
// --- DARK HEADER ---
const headerSurface = '#0F172A'; // Slate 900
@@ -78,6 +79,27 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
.finally(() => setLoadingEvents(false));
}, [events.length, loadingEvents, attemptedFetch, activeEvent, selectEvent]);
React.useEffect(() => {
if (typeof window === 'undefined' || !window.matchMedia) {
return;
}
const mediaQuery = window.matchMedia('(max-width: 320px)');
const handleChange = (event: MediaQueryListEvent | MediaQueryList) => {
setIsCompactHeader(event.matches);
};
handleChange(mediaQuery);
if (typeof mediaQuery.addEventListener === 'function') {
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}
mediaQuery.addListener?.(handleChange);
return () => mediaQuery.removeListener?.(handleChange);
}, []);
React.useEffect(() => {
const path = `${location.pathname}${location.search}${location.hash}`;
if (!location.pathname.includes('/billing/shop') && !location.pathname.includes('/welcome')) {
@@ -102,7 +124,7 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
const pageTitle = title ?? t('header.appName', 'Event Admin');
const isEventsIndex = location.pathname === ADMIN_EVENTS_PATH;
const canSwitchEvents = hasMultipleEvents && !isEventsIndex;
const canSwitchEvents = effectiveEvents.length > 1 && !isEventsIndex;
const isMember = user?.role === 'member';
const memberPermissions = Array.isArray(effectiveActive?.member_permissions) ? effectiveActive?.member_permissions ?? [] : [];
@@ -119,7 +141,7 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
// --- CONTEXT PILL ---
const EventContextPill = () => {
if (!effectiveActive || isEventsIndex) {
if (!effectiveActive || isEventsIndex || isCompactHeader) {
return (
<Text fontSize="$md" fontWeight="800" fontFamily="$display" color="white">
{pageTitle}
@@ -138,7 +160,7 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
}
return (
<Pressable onPress={() => setSwitcherOpen(true)}>
<Pressable onPress={() => setSwitcherOpen(true)} aria-label={t('header.eventSwitcher', 'Switch event')}>
<XStack
backgroundColor="rgba(255, 255, 255, 0.12)"
paddingHorizontal="$3"

View File

@@ -157,7 +157,6 @@ describe('MobileShell', () => {
});
it('shows the event switcher when multiple events are available', async () => {
eventContext.hasMultipleEvents = true;
eventContext.events = [
{ slug: 'event-1', name: 'Test Event', event_date: '2024-01-01', status: 'active', settings: {} },
{ slug: 'event-2', name: 'Second Event', event_date: '2024-02-01', status: 'active', settings: {} },
@@ -177,7 +176,6 @@ describe('MobileShell', () => {
});
it('hides the event switcher on the events list page', async () => {
eventContext.hasMultipleEvents = true;
eventContext.events = [
{ slug: 'event-1', name: 'Test Event', event_date: '2024-01-01', status: 'active', settings: {} },
{ slug: 'event-2', name: 'Second Event', event_date: '2024-02-01', status: 'active', settings: {} },