Misc unrelated updates
This commit is contained in:
@@ -14,13 +14,11 @@ import { getEventAnalytics, EventAnalytics } from '../api';
|
||||
import { ApiError } from '../lib/apiError';
|
||||
import { useAdminTheme } from './theme';
|
||||
import { adminPath } from '../constants';
|
||||
import { useEventContext } from '../context/EventContext';
|
||||
|
||||
export default function MobileEventAnalyticsPage() {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const { t, i18n } = useTranslation('management');
|
||||
const navigate = useNavigate();
|
||||
const { activeEvent } = useEventContext();
|
||||
const { textStrong, muted, border, surface, primary, accentSoft } = useAdminTheme();
|
||||
|
||||
const dateLocale = i18n.language.startsWith('de') ? de : enGB;
|
||||
@@ -106,7 +104,6 @@ export default function MobileEventAnalyticsPage() {
|
||||
return (
|
||||
<MobileShell
|
||||
title={t('analytics.title', 'Analytics')}
|
||||
subtitle={activeEvent?.name as string}
|
||||
activeTab="home"
|
||||
onBack={() => navigate(-1)}
|
||||
>
|
||||
|
||||
@@ -9,7 +9,7 @@ import { MobileShell } from './components/MobileShell';
|
||||
import { MobileCard, CTAButton } from './components/Primitives';
|
||||
import { MobileField, MobileInput, MobileSelect, MobileTextArea } from './components/FormControls';
|
||||
import { LegalConsentSheet } from './components/LegalConsentSheet';
|
||||
import { createEvent, getEvent, updateEvent, getEventTypes, TenantEvent, TenantEventType, trackOnboarding } from '../api';
|
||||
import { createEvent, getEvent, updateEvent, getEventTypes, getPackages, Package, TenantEvent, TenantEventType, trackOnboarding } from '../api';
|
||||
import { resolveEventSlugAfterUpdate } from './eventFormNavigation';
|
||||
import { adminPath } from '../constants';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
@@ -18,6 +18,7 @@ import toast from 'react-hot-toast';
|
||||
import { useBackNavigation } from './hooks/useBackNavigation';
|
||||
import { useAdminTheme } from './theme';
|
||||
import { withAlpha } from './components/colors';
|
||||
import { useAuth } from '../auth/context';
|
||||
|
||||
type FormState = {
|
||||
name: string;
|
||||
@@ -28,6 +29,7 @@ type FormState = {
|
||||
published: boolean;
|
||||
autoApproveUploads: boolean;
|
||||
tasksEnabled: boolean;
|
||||
packageId: number | null;
|
||||
};
|
||||
|
||||
export default function MobileEventFormPage() {
|
||||
@@ -36,7 +38,9 @@ export default function MobileEventFormPage() {
|
||||
const isEdit = Boolean(slug);
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation(['management', 'common']);
|
||||
const { user } = useAuth();
|
||||
const { text, muted, subtle, danger, border, surface, primary } = useAdminTheme();
|
||||
const isSuperAdmin = user?.role === 'super_admin' || user?.role === 'superadmin';
|
||||
|
||||
const [form, setForm] = React.useState<FormState>({
|
||||
name: '',
|
||||
@@ -47,9 +51,12 @@ export default function MobileEventFormPage() {
|
||||
published: false,
|
||||
autoApproveUploads: true,
|
||||
tasksEnabled: true,
|
||||
packageId: null,
|
||||
});
|
||||
const [eventTypes, setEventTypes] = React.useState<TenantEventType[]>([]);
|
||||
const [typesLoading, setTypesLoading] = React.useState(false);
|
||||
const [packages, setPackages] = React.useState<Package[]>([]);
|
||||
const [packagesLoading, setPackagesLoading] = React.useState(false);
|
||||
const [loading, setLoading] = React.useState(isEdit);
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
const [consentOpen, setConsentOpen] = React.useState(false);
|
||||
@@ -76,6 +83,7 @@ export default function MobileEventFormPage() {
|
||||
tasksEnabled:
|
||||
(data.settings?.engagement_mode as string | undefined) !== 'photo_only' &&
|
||||
(data.engagement_mode as string | undefined) !== 'photo_only',
|
||||
packageId: null,
|
||||
});
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
@@ -106,6 +114,31 @@ export default function MobileEventFormPage() {
|
||||
})();
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isSuperAdmin || isEdit) {
|
||||
return;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
setPackagesLoading(true);
|
||||
try {
|
||||
const data = await getPackages('endcustomer');
|
||||
setPackages(data);
|
||||
setForm((prev) => {
|
||||
if (prev.packageId) {
|
||||
return prev;
|
||||
}
|
||||
const preferred = data.find((pkg) => pkg.id === 3) ?? data[0] ?? null;
|
||||
return { ...prev, packageId: preferred?.id ?? null };
|
||||
});
|
||||
} catch {
|
||||
setPackages([]);
|
||||
} finally {
|
||||
setPackagesLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [isSuperAdmin, isEdit]);
|
||||
|
||||
async function handleSubmit() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
@@ -131,6 +164,7 @@ export default function MobileEventFormPage() {
|
||||
event_type_id: form.eventTypeId ?? undefined,
|
||||
event_date: form.date || undefined,
|
||||
status: form.published ? 'published' : 'draft',
|
||||
package_id: isSuperAdmin ? form.packageId ?? undefined : undefined,
|
||||
settings: {
|
||||
location: form.location,
|
||||
guest_upload_visibility: form.autoApproveUploads ? 'immediate' : 'review',
|
||||
@@ -153,6 +187,7 @@ export default function MobileEventFormPage() {
|
||||
event_type_id: form.eventTypeId ?? undefined,
|
||||
event_date: form.date || undefined,
|
||||
status: form.published ? 'published' : 'draft',
|
||||
package_id: isSuperAdmin ? form.packageId ?? undefined : undefined,
|
||||
settings: {
|
||||
location: form.location,
|
||||
guest_upload_visibility: form.autoApproveUploads ? 'immediate' : 'review',
|
||||
@@ -223,6 +258,31 @@ export default function MobileEventFormPage() {
|
||||
/>
|
||||
</MobileField>
|
||||
|
||||
{isSuperAdmin && !isEdit ? (
|
||||
<MobileField label={t('eventForm.fields.package.label', 'Package')}>
|
||||
{packagesLoading ? (
|
||||
<Text fontSize="$sm" color={muted}>{t('eventForm.fields.package.loading', 'Loading packages…')}</Text>
|
||||
) : packages.length === 0 ? (
|
||||
<Text fontSize="$sm" color={muted}>{t('eventForm.fields.package.empty', 'No packages available yet.')}</Text>
|
||||
) : (
|
||||
<MobileSelect
|
||||
value={form.packageId ?? ''}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, packageId: Number(e.target.value) }))}
|
||||
>
|
||||
<option value="">{t('eventForm.fields.package.placeholder', 'Select package')}</option>
|
||||
{packages.map((pkg) => (
|
||||
<option key={pkg.id} value={pkg.id}>
|
||||
{pkg.name || `#${pkg.id}`}
|
||||
</option>
|
||||
))}
|
||||
</MobileSelect>
|
||||
)}
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t('eventForm.fields.package.help', 'This controls the event’s premium limits.')}
|
||||
</Text>
|
||||
</MobileField>
|
||||
) : null}
|
||||
|
||||
<MobileField label={t('eventForm.fields.date.label', 'Date & time')}>
|
||||
<XStack alignItems="center" space="$2">
|
||||
<NativeDateTimeInput
|
||||
|
||||
@@ -12,7 +12,7 @@ import { MobileField, MobileInput, MobileSelect } from './components/FormControl
|
||||
import { getEvent, getLiveShowLink, rotateLiveShowLink, updateEvent, LiveShowLink, LiveShowSettings, TenantEvent } from '../api';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
import { getApiErrorMessage } from '../lib/apiError';
|
||||
import { formatEventDate, resolveEventDisplayName } from '../lib/events';
|
||||
import { resolveEventDisplayName } from '../lib/events';
|
||||
import { adminPath } from '../constants';
|
||||
import { useBackNavigation } from './hooks/useBackNavigation';
|
||||
import { useAdminTheme } from './theme';
|
||||
@@ -262,8 +262,7 @@ export default function MobileEventLiveShowSettingsPage() {
|
||||
return (
|
||||
<MobileShell
|
||||
activeTab="home"
|
||||
title={event ? resolveEventDisplayName(event) : t('liveShowSettings.title', 'Live Show settings')}
|
||||
subtitle={event?.event_date ? formatEventDate(event.event_date, locale) ?? undefined : undefined}
|
||||
title={t('liveShowSettings.title', 'Live Show settings')}
|
||||
onBack={back}
|
||||
headerActions={
|
||||
<HeaderActionButton onPress={() => load()} ariaLabel={t('common.refresh', 'Refresh')}>
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
} from '../api';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
import { getApiErrorMessage } from '../lib/apiError';
|
||||
import { formatEventDate, resolveEventDisplayName } from '../lib/events';
|
||||
import { formatEventDate } from '../lib/events';
|
||||
import toast from 'react-hot-toast';
|
||||
import { adminPath } from '../constants';
|
||||
import { useBackNavigation } from './hooks/useBackNavigation';
|
||||
@@ -146,9 +146,7 @@ export default function MobileEventPhotoboothPage() {
|
||||
: t('photobooth.credentials.heading', 'FTP (Classic)');
|
||||
|
||||
const isActive = Boolean(status?.enabled);
|
||||
const title = event ? resolveEventDisplayName(event) : t('management.header.appName', 'Event Admin');
|
||||
const subtitle =
|
||||
event?.event_date ? formatEventDate(event.event_date, locale) : t('header.selectEvent', 'Select an event to continue');
|
||||
const title = t('photobooth.title', 'Photobooth');
|
||||
|
||||
const handleToggle = (checked: boolean) => {
|
||||
if (!slug || updating) return;
|
||||
@@ -163,7 +161,6 @@ export default function MobileEventPhotoboothPage() {
|
||||
<MobileShell
|
||||
activeTab="home"
|
||||
title={title}
|
||||
subtitle={subtitle ?? undefined}
|
||||
onBack={back}
|
||||
headerActions={
|
||||
<HeaderActionButton onPress={() => load()} ariaLabel={t('common.refresh', 'Refresh')}>
|
||||
|
||||
@@ -152,7 +152,6 @@ export default function MobileEventRecapPage() {
|
||||
<MobileShell
|
||||
activeTab="home"
|
||||
title={t('events.recap.title', 'Event Recap')}
|
||||
subtitle={resolveName(event.name)}
|
||||
onBack={back}
|
||||
>
|
||||
<YStack space="$4">
|
||||
@@ -392,4 +391,4 @@ function formatDate(iso?: string | null): string {
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
return date.toLocaleDateString(undefined, { day: '2-digit', month: 'short', year: 'numeric' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -691,6 +691,7 @@ export default function MobileNotificationsPage() {
|
||||
}
|
||||
}}
|
||||
title={selectedNotification?.title ?? t('mobileNotifications.title', 'Notifications')}
|
||||
snapPoints={[94]}
|
||||
footer={
|
||||
selectedNotification && !selectedNotification.is_read ? (
|
||||
<CTAButton label={t('notificationLogs.markRead', 'Mark as read')} onPress={() => markSelectedRead()} />
|
||||
@@ -705,7 +706,7 @@ export default function MobileNotificationsPage() {
|
||||
<Text fontSize="$sm" color={muted}>
|
||||
{selectedNotification.body}
|
||||
</Text>
|
||||
<XStack space="$2">
|
||||
<XStack space="$2" flexWrap="wrap" style={{ rowGap: 8 }}>
|
||||
<PillBadge tone={selectedNotification.tone === 'warning' ? 'warning' : 'muted'}>
|
||||
{selectedNotification.scope}
|
||||
</PillBadge>
|
||||
|
||||
@@ -19,6 +19,7 @@ vi.mock('../../api', () => ({
|
||||
getEvent: vi.fn(),
|
||||
updateEvent: vi.fn(),
|
||||
getEventTypes: vi.fn().mockResolvedValue([]),
|
||||
getPackages: vi.fn().mockResolvedValue([]),
|
||||
trackOnboarding: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -81,6 +82,10 @@ vi.mock('../theme', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../auth/context', () => ({
|
||||
useAuth: () => ({ user: { role: 'tenant_admin' } }),
|
||||
}));
|
||||
|
||||
import { getEventTypes } from '../../api';
|
||||
import MobileEventFormPage from '../EventFormPage';
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { Suspense } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { ChevronDown, ChevronLeft, Bell, QrCode } from 'lucide-react';
|
||||
import { ChevronLeft, Bell, QrCode } 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,11 +9,10 @@ import { useEventContext } from '../../context/EventContext';
|
||||
import { BottomNav, NavKey } from './BottomNav';
|
||||
import { useMobileNav } from '../hooks/useMobileNav';
|
||||
import { adminPath } from '../../constants';
|
||||
import { MobileSheet } from './Sheet';
|
||||
import { MobileCard, PillBadge, CTAButton } from './Primitives';
|
||||
import { MobileCard, CTAButton } from './Primitives';
|
||||
import { useNotificationsBadge } from '../hooks/useNotificationsBadge';
|
||||
import { useOnlineStatus } from '../hooks/useOnlineStatus';
|
||||
import { formatEventDate, resolveEventDisplayName } from '../../lib/events';
|
||||
import { resolveEventDisplayName } from '../../lib/events';
|
||||
import { TenantEvent, getEvents } from '../../api';
|
||||
import { withAlpha } from './colors';
|
||||
import { setTabHistory } from '../lib/tabHistory';
|
||||
@@ -31,11 +30,11 @@ type MobileShellProps = {
|
||||
};
|
||||
|
||||
export function MobileShell({ title, subtitle, children, activeTab, onBack, headerActions }: MobileShellProps) {
|
||||
const { events, activeEvent, hasMultipleEvents, hasEvents, selectEvent } = useEventContext();
|
||||
const { events, activeEvent, selectEvent } = useEventContext();
|
||||
const { go } = useMobileNav(activeEvent?.slug, activeTab);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { t, i18n } = useTranslation('mobile');
|
||||
const { t } = useTranslation('mobile');
|
||||
const { count: notificationCount } = useNotificationsBadge();
|
||||
const online = useOnlineStatus();
|
||||
const { background, surface, border, text, muted, warningBg, warningText, primary, danger, shadow } = useAdminTheme();
|
||||
@@ -45,16 +44,13 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
|
||||
const textColor = text;
|
||||
const mutedText = muted;
|
||||
const headerSurface = withAlpha(surfaceColor, 0.94);
|
||||
const [pickerOpen, setPickerOpen] = React.useState(false);
|
||||
const [fallbackEvents, setFallbackEvents] = React.useState<TenantEvent[]>([]);
|
||||
const [loadingEvents, setLoadingEvents] = React.useState(false);
|
||||
const [attemptedFetch, setAttemptedFetch] = React.useState(false);
|
||||
const [queuedPhotoCount, setQueuedPhotoCount] = React.useState(0);
|
||||
const [isCompactHeader, setIsCompactHeader] = React.useState(false);
|
||||
|
||||
const locale = i18n.language?.startsWith('en') ? 'en-GB' : 'de-DE';
|
||||
const effectiveEvents = events.length ? events : fallbackEvents;
|
||||
const effectiveHasMultiple = hasMultipleEvents || effectiveEvents.length > 1;
|
||||
const effectiveHasEvents = hasEvents || effectiveEvents.length > 0;
|
||||
const effectiveActive = activeEvent ?? (effectiveEvents.length === 1 ? effectiveEvents[0] : null);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -74,16 +70,6 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
|
||||
.finally(() => setLoadingEvents(false));
|
||||
}, [events.length, loadingEvents, attemptedFetch, activeEvent, selectEvent]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!pickerOpen) return;
|
||||
if (effectiveEvents.length) return;
|
||||
setLoadingEvents(true);
|
||||
getEvents({ force: true })
|
||||
.then((list) => setFallbackEvents(list ?? []))
|
||||
.catch(() => setFallbackEvents([]))
|
||||
.finally(() => setLoadingEvents(false));
|
||||
}, [pickerOpen, effectiveEvents.length]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const path = `${location.pathname}${location.search}${location.hash}`;
|
||||
|
||||
@@ -114,17 +100,104 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
|
||||
};
|
||||
}, [refreshQueuedActions]);
|
||||
|
||||
const eventTitle = title ?? (effectiveActive ? resolveEventDisplayName(effectiveActive) : t('header.appName', 'Event Admin'));
|
||||
const subtitleText =
|
||||
subtitle ??
|
||||
(effectiveActive?.event_date
|
||||
? formatEventDate(effectiveActive.event_date, locale) ?? ''
|
||||
: effectiveHasEvents
|
||||
? t('header.selectEvent', 'Select an event to continue')
|
||||
: t('header.empty', 'Create your first event to get started'));
|
||||
React.useEffect(() => {
|
||||
if (typeof window === 'undefined' || !window.matchMedia) {
|
||||
return;
|
||||
}
|
||||
const query = window.matchMedia('(max-width: 520px)');
|
||||
const handleChange = (event: MediaQueryListEvent) => {
|
||||
setIsCompactHeader(event.matches);
|
||||
};
|
||||
setIsCompactHeader(query.matches);
|
||||
query.addEventListener?.('change', handleChange);
|
||||
return () => {
|
||||
query.removeEventListener?.('change', handleChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const showEventSwitcher = effectiveHasMultiple;
|
||||
const pageTitle = title ?? t('header.appName', 'Event Admin');
|
||||
const eventContext = !isCompactHeader && effectiveActive ? resolveEventDisplayName(effectiveActive) : null;
|
||||
const subtitleText = subtitle ?? eventContext ?? '';
|
||||
const showQr = Boolean(effectiveActive?.slug);
|
||||
const headerBackButton = onBack ? (
|
||||
<HeaderActionButton onPress={onBack} ariaLabel={t('actions.back', 'Back')}>
|
||||
<XStack alignItems="center" space="$1.5">
|
||||
<ChevronLeft size={28} color={primary} strokeWidth={2.5} />
|
||||
</XStack>
|
||||
</HeaderActionButton>
|
||||
) : (
|
||||
<XStack width={28} />
|
||||
);
|
||||
const headerTitle = (
|
||||
<XStack alignItems="center" space="$1" flex={1} minWidth={0} justifyContent="flex-end">
|
||||
<YStack alignItems="flex-end" maxWidth="100%">
|
||||
<Text fontSize="$lg" fontWeight="800" fontFamily="$display" color={textColor} textAlign="right" numberOfLines={1}>
|
||||
{pageTitle}
|
||||
</Text>
|
||||
{subtitleText ? (
|
||||
<Text fontSize="$xs" color={mutedText} textAlign="right" numberOfLines={1} fontFamily="$body">
|
||||
{subtitleText}
|
||||
</Text>
|
||||
) : null}
|
||||
</YStack>
|
||||
</XStack>
|
||||
);
|
||||
const headerActionsRow = (
|
||||
<XStack alignItems="center" space="$2">
|
||||
<HeaderActionButton
|
||||
onPress={() => navigate(adminPath('/mobile/notifications'))}
|
||||
ariaLabel={t('mobile.notifications', 'Notifications')}
|
||||
>
|
||||
<XStack
|
||||
width={34}
|
||||
height={34}
|
||||
borderRadius={12}
|
||||
backgroundColor={surfaceColor}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
position="relative"
|
||||
>
|
||||
<Bell size={16} color={textColor} />
|
||||
{notificationCount > 0 ? (
|
||||
<YStack
|
||||
position="absolute"
|
||||
top={-4}
|
||||
right={-4}
|
||||
minWidth={18}
|
||||
height={18}
|
||||
paddingHorizontal={6}
|
||||
borderRadius={999}
|
||||
backgroundColor={danger}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<Text fontSize={10} color="white" fontWeight="700">
|
||||
{notificationCount > 9 ? '9+' : notificationCount}
|
||||
</Text>
|
||||
</YStack>
|
||||
) : null}
|
||||
</XStack>
|
||||
</HeaderActionButton>
|
||||
{showQr ? (
|
||||
<HeaderActionButton
|
||||
onPress={() => navigate(adminPath(`/mobile/events/${effectiveActive?.slug}/qr`))}
|
||||
ariaLabel={t('header.quickQr', 'Quick QR')}
|
||||
>
|
||||
<XStack
|
||||
width={34}
|
||||
height={34}
|
||||
borderRadius={12}
|
||||
backgroundColor={primary}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<QrCode size={16} color="white" />
|
||||
</XStack>
|
||||
</HeaderActionButton>
|
||||
) : null}
|
||||
{headerActions ?? null}
|
||||
</XStack>
|
||||
);
|
||||
|
||||
return (
|
||||
<YStack backgroundColor={backgroundColor} minHeight="100vh" alignItems="center">
|
||||
@@ -150,96 +223,27 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
|
||||
WebkitBackdropFilter: 'blur(12px)',
|
||||
}}
|
||||
>
|
||||
<XStack alignItems="center" justifyContent="space-between" minHeight={48} space="$3">
|
||||
{onBack ? (
|
||||
<HeaderActionButton onPress={onBack} ariaLabel={t('actions.back', 'Back')}>
|
||||
<XStack alignItems="center" space="$1.5">
|
||||
<ChevronLeft size={28} color={primary} strokeWidth={2.5} />
|
||||
{isCompactHeader ? (
|
||||
<YStack space="$2">
|
||||
<XStack alignItems="center" justifyContent="space-between" minHeight={48} space="$3">
|
||||
{headerBackButton}
|
||||
<XStack flex={1} minWidth={0} justifyContent="flex-end">
|
||||
{headerTitle}
|
||||
</XStack>
|
||||
</HeaderActionButton>
|
||||
) : (
|
||||
<XStack width={28} />
|
||||
)}
|
||||
|
||||
<XStack alignItems="center" space="$2.5" flex={1} justifyContent="flex-end">
|
||||
<XStack alignItems="center" space="$1" maxWidth="55%">
|
||||
<Pressable
|
||||
disabled={!showEventSwitcher}
|
||||
onPress={() => setPickerOpen(true)}
|
||||
style={{ alignItems: 'flex-end' }}
|
||||
>
|
||||
<Text fontSize="$lg" fontWeight="800" fontFamily="$display" color={textColor} textAlign="right" numberOfLines={1}>
|
||||
{eventTitle}
|
||||
</Text>
|
||||
{subtitleText ? (
|
||||
<Text fontSize="$xs" color={mutedText} textAlign="right" numberOfLines={1} fontFamily="$body">
|
||||
{subtitleText}
|
||||
</Text>
|
||||
) : null}
|
||||
</Pressable>
|
||||
{showEventSwitcher ? <ChevronDown size={14} color={textColor} /> : null}
|
||||
</XStack>
|
||||
|
||||
<XStack alignItems="center" space="$2">
|
||||
<HeaderActionButton
|
||||
onPress={() => navigate(adminPath('/mobile/notifications'))}
|
||||
ariaLabel={t('mobile.notifications', 'Notifications')}
|
||||
>
|
||||
<XStack
|
||||
width={34}
|
||||
height={34}
|
||||
borderRadius={12}
|
||||
backgroundColor={surfaceColor}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
position="relative"
|
||||
>
|
||||
<Bell size={16} color={textColor} />
|
||||
{notificationCount > 0 ? (
|
||||
<YStack
|
||||
position="absolute"
|
||||
top={-4}
|
||||
right={-4}
|
||||
minWidth={18}
|
||||
height={18}
|
||||
paddingHorizontal={6}
|
||||
borderRadius={999}
|
||||
backgroundColor={danger}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<Text fontSize={10} color="white" fontWeight="700">
|
||||
{notificationCount > 9 ? '9+' : notificationCount}
|
||||
</Text>
|
||||
</YStack>
|
||||
) : null}
|
||||
</XStack>
|
||||
</HeaderActionButton>
|
||||
{showQr ? (
|
||||
<HeaderActionButton
|
||||
onPress={() => navigate(adminPath(`/mobile/events/${effectiveActive?.slug}/qr`))}
|
||||
ariaLabel={t('header.quickQr', 'Quick QR')}
|
||||
>
|
||||
<XStack
|
||||
height={34}
|
||||
paddingHorizontal="$3"
|
||||
borderRadius={12}
|
||||
backgroundColor={primary}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
space="$1.5"
|
||||
>
|
||||
<QrCode size={16} color="white" />
|
||||
<Text fontSize="$xs" fontWeight="800" color="white">
|
||||
{t('header.quickQr', 'Quick QR')}
|
||||
</Text>
|
||||
</XStack>
|
||||
</HeaderActionButton>
|
||||
) : null}
|
||||
{headerActions ?? null}
|
||||
<XStack alignItems="center" justifyContent="flex-end">
|
||||
{headerActionsRow}
|
||||
</XStack>
|
||||
</YStack>
|
||||
) : (
|
||||
<XStack alignItems="center" justifyContent="space-between" minHeight={48} space="$3">
|
||||
{headerBackButton}
|
||||
<XStack alignItems="center" space="$2.5" flex={1} justifyContent="flex-end" minWidth={0}>
|
||||
{headerTitle}
|
||||
{headerActionsRow}
|
||||
</XStack>
|
||||
</XStack>
|
||||
</XStack>
|
||||
)}
|
||||
</YStack>
|
||||
|
||||
<YStack
|
||||
@@ -290,75 +294,6 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
|
||||
|
||||
<BottomNav active={activeTab} onNavigate={go} />
|
||||
|
||||
<MobileSheet
|
||||
open={pickerOpen}
|
||||
onClose={() => setPickerOpen(false)}
|
||||
title={t('header.eventSwitcher', 'Choose an event')}
|
||||
footer={null}
|
||||
bottomOffsetPx={110}
|
||||
>
|
||||
<YStack space="$2">
|
||||
{effectiveEvents.length === 0 ? (
|
||||
<MobileCard alignItems="flex-start" space="$2">
|
||||
<Text fontSize="$sm" color={textColor} fontWeight="700">
|
||||
{t('header.noEventsTitle', 'Create your first event')}
|
||||
</Text>
|
||||
<Text fontSize="$xs" color={mutedText}>
|
||||
{t('header.noEventsBody', 'Start an event to access tasks, uploads, QR posters and more.')}
|
||||
</Text>
|
||||
<Pressable onPress={() => navigate(adminPath('/mobile/events/new'))}>
|
||||
<XStack alignItems="center" space="$2">
|
||||
<Text fontSize="$sm" color={primary} fontWeight="700">
|
||||
{t('header.createEvent', 'Create event')}
|
||||
</Text>
|
||||
</XStack>
|
||||
</Pressable>
|
||||
</MobileCard>
|
||||
) : (
|
||||
effectiveEvents.map((event) => (
|
||||
<Pressable
|
||||
key={event.slug}
|
||||
onPress={() => {
|
||||
const targetSlug = event.slug ?? null;
|
||||
selectEvent(targetSlug);
|
||||
setPickerOpen(false);
|
||||
if (targetSlug) {
|
||||
navigate(adminPath(`/mobile/events/${targetSlug}`));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<XStack alignItems="center" justifyContent="space-between" paddingVertical="$2">
|
||||
<YStack space="$0.5">
|
||||
<Text fontSize="$sm" fontWeight="700" color={textColor}>
|
||||
{resolveEventDisplayName(event)}
|
||||
</Text>
|
||||
<Text fontSize="$xs" color={mutedText}>
|
||||
{formatEventDate(event.event_date, locale) ?? t('header.noDate', 'Date tbd')}
|
||||
</Text>
|
||||
</YStack>
|
||||
<PillBadge tone={event.slug === activeEvent?.slug ? 'success' : 'muted'}>
|
||||
{event.slug === activeEvent?.slug
|
||||
? t('header.active', 'Active')
|
||||
: (event.status ?? '—')}
|
||||
</PillBadge>
|
||||
</XStack>
|
||||
</Pressable>
|
||||
))
|
||||
)}
|
||||
{activeEvent ? (
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
selectEvent(null);
|
||||
setPickerOpen(false);
|
||||
}}
|
||||
>
|
||||
<Text fontSize="$xs" color={mutedText} textAlign="center">
|
||||
{t('header.clearSelection', 'Clear selection')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</YStack>
|
||||
</MobileSheet>
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ describe('MobileSheet', () => {
|
||||
const onClose = vi.fn();
|
||||
|
||||
render(
|
||||
<MobileSheet open title="Test Sheet" onClose={onClose}>
|
||||
<MobileSheet open title="Test Sheet" onClose={onClose} snapPoints={[94]} contentSpacing="$2" padding="$3" paddingBottom="$6">
|
||||
<div>Body</div>
|
||||
</MobileSheet>,
|
||||
);
|
||||
|
||||
@@ -12,11 +12,26 @@ type SheetProps = {
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
snapPoints?: number[];
|
||||
contentSpacing?: string;
|
||||
padding?: string;
|
||||
paddingBottom?: string;
|
||||
/** Optional bottom offset so content sits above the bottom nav/safe-area. */
|
||||
bottomOffsetPx?: number;
|
||||
};
|
||||
|
||||
export function MobileSheet({ open, title, onClose, children, footer, bottomOffsetPx = 88 }: SheetProps) {
|
||||
export function MobileSheet({
|
||||
open,
|
||||
title,
|
||||
onClose,
|
||||
children,
|
||||
footer,
|
||||
snapPoints = [82],
|
||||
contentSpacing = '$3',
|
||||
padding = '$4',
|
||||
paddingBottom = '$7',
|
||||
bottomOffsetPx = 88,
|
||||
}: SheetProps) {
|
||||
const { t } = useTranslation('mobile');
|
||||
const { surface, textStrong, muted, overlay, shadow, border } = useAdminTheme();
|
||||
const bottomOffset = `max(env(safe-area-inset-bottom, 0px), ${bottomOffsetPx}px)`;
|
||||
@@ -33,7 +48,7 @@ export function MobileSheet({ open, title, onClose, children, footer, bottomOffs
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
snapPoints={[82]}
|
||||
snapPoints={snapPoints}
|
||||
snapPointsMode="percent"
|
||||
dismissOnOverlayPress
|
||||
dismissOnSnapToBottom
|
||||
@@ -48,8 +63,8 @@ export function MobileSheet({ open, title, onClose, children, footer, bottomOffs
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
backgroundColor: surface,
|
||||
padding: '$4',
|
||||
paddingBottom: '$7',
|
||||
padding,
|
||||
paddingBottom,
|
||||
shadowColor: shadow,
|
||||
shadowOpacity: 0.12,
|
||||
shadowRadius: 18,
|
||||
@@ -62,7 +77,7 @@ export function MobileSheet({ open, title, onClose, children, footer, bottomOffs
|
||||
showsVerticalScrollIndicator={false}
|
||||
{...({ contentContainerStyle: { paddingBottom: 6 } } as any)}
|
||||
>
|
||||
<YStack space="$3">
|
||||
<YStack space={contentSpacing}>
|
||||
<XStack alignItems="center" justifyContent="space-between">
|
||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||
{title}
|
||||
|
||||
Reference in New Issue
Block a user