Update admin PWA events, branding, and packages
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' };
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
|
||||
@@ -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' };
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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: {} },
|
||||
|
||||
Reference in New Issue
Block a user