Misc unrelated updates
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

This commit is contained in:
Codex Agent
2026-01-12 10:31:31 +01:00
parent e9afbeb028
commit 3de1d3deab
40 changed files with 433 additions and 267 deletions

View File

@@ -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)}
>

View File

@@ -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 events premium limits.')}
</Text>
</MobileField>
) : null}
<MobileField label={t('eventForm.fields.date.label', 'Date & time')}>
<XStack alignItems="center" space="$2">
<NativeDateTimeInput

View File

@@ -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')}>

View File

@@ -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')}>

View File

@@ -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' });
}
}

View File

@@ -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>

View File

@@ -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';

View File

@@ -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>
);
}

View File

@@ -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>,
);

View File

@@ -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}