more usage of tamagui primitives

This commit is contained in:
Codex Agent
2025-12-30 16:04:30 +01:00
parent efe2f25b3e
commit d7c2f85eeb
12 changed files with 744 additions and 315 deletions

View File

@@ -35,7 +35,7 @@ export default function MobileEventFormPage() {
const isEdit = Boolean(slug); const isEdit = Boolean(slug);
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useTranslation(['management', 'common']); const { t } = useTranslation(['management', 'common']);
const { text, muted, subtle, danger, border, surfaceMuted } = useAdminTheme(); const { text, muted, subtle, danger } = useAdminTheme();
const [form, setForm] = React.useState<FormState>({ const [form, setForm] = React.useState<FormState>({
name: '', name: '',
@@ -359,20 +359,11 @@ export default function MobileEventFormPage() {
<YStack space="$2"> <YStack space="$2">
{!isEdit ? ( {!isEdit ? (
<button <CTAButton
type="button" label={t('eventForm.actions.saveDraft', 'Save as draft')}
onClick={back} tone="ghost"
style={{ onPress={back}
...inputStyle, />
height: 48,
borderRadius: 12,
border: `1px solid ${border}`,
background: surfaceMuted,
fontWeight: 700,
}}
>
{t('eventForm.actions.saveDraft', 'Save as draft')}
</button>
) : null} ) : null}
<CTAButton <CTAButton
label={ label={

View File

@@ -1479,7 +1479,12 @@ function MobileAddonsPicker({
return ( return (
<XStack space="$2" alignItems="center"> <XStack space="$2" alignItems="center">
<MobileSelect value={selected} onChange={(event) => setSelected(event.target.value)} style={{ flex: 1 }} compact> <MobileSelect
value={selected}
onChange={(event) => setSelected(event.target.value)}
containerStyle={{ flex: 1, minWidth: 0 }}
compact
>
{options.map((addon) => ( {options.map((addon) => (
<option key={addon.key} value={addon.key}> <option key={addon.key} value={addon.key}>
{addon.label ?? addon.key} {addon.label ?? addon.key}
@@ -1497,6 +1502,7 @@ function MobileAddonsPicker({
disabled={!selected || busy} disabled={!selected || busy}
onPress={() => selected && onCheckout(selected)} onPress={() => selected && onCheckout(selected)}
loading={busy} loading={busy}
fullWidth={false}
/> />
</XStack> </XStack>
); );

View File

@@ -3,11 +3,14 @@ import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { RefreshCcw, Plus, Pencil, Trash2, ChevronDown, ChevronRight } from 'lucide-react'; import { RefreshCcw, Plus, Pencil, Trash2, ChevronDown, ChevronRight } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks'; import { YStack, XStack } from '@tamagui/stacks';
import { YGroup } from '@tamagui/group';
import { SizableText as Text } from '@tamagui/text'; import { SizableText as Text } from '@tamagui/text';
import { ListItem } from '@tamagui/list-item'; import { ListItem } from '@tamagui/list-item';
import { Pressable } from '@tamagui/react-native-web-lite'; import { Pressable } from '@tamagui/react-native-web-lite';
import { Button } from '@tamagui/button';
import { AlertDialog } from '@tamagui/alert-dialog';
import { MobileShell, HeaderActionButton } from './components/MobileShell'; import { MobileShell, HeaderActionButton } from './components/MobileShell';
import { MobileCard, CTAButton, SkeletonCard, PillBadge } from './components/Primitives'; import { MobileCard, CTAButton, SkeletonCard, PillBadge, FloatingActionButton } from './components/Primitives';
import { MobileField, MobileInput, MobileSelect, MobileTextArea } from './components/FormControls'; import { MobileField, MobileInput, MobileSelect, MobileTextArea } from './components/FormControls';
import { import {
getEvent, getEvent,
@@ -42,32 +45,53 @@ import { buildTaskSummary } from './lib/taskSummary';
import { buildTaskSectionCounts, type TaskSectionKey } from './lib/taskSectionCounts'; import { buildTaskSectionCounts, type TaskSectionKey } from './lib/taskSectionCounts';
import { useAdminTheme } from './theme'; import { useAdminTheme } from './theme';
function InlineSeparator() {
const { border } = useAdminTheme();
return <XStack height={1} opacity={0.7} marginLeft="$3" backgroundColor={border} />;
}
function TaskSummaryCard({ function TaskSummaryCard({
summary, summary,
text, text,
muted, muted,
border, border,
surfaceMuted,
}: { }: {
summary: ReturnType<typeof buildTaskSummary>; summary: ReturnType<typeof buildTaskSummary>;
text: string; text: string;
muted: string; muted: string;
border: string; border: string;
surfaceMuted: string;
}) { }) {
const { t } = useTranslation('management'); const { t } = useTranslation('management');
return ( return (
<MobileCard space="$2" borderColor={border}> <MobileCard space="$2" borderColor={border}>
<XStack alignItems="center" justifyContent="space-between" space="$2"> <XStack alignItems="center" justifyContent="space-between" space="$2">
<SummaryItem label={t('events.tasks.summary.assigned', 'Assigned')} value={summary.assigned} text={text} muted={muted} /> <SummaryItem
<SummaryItem label={t('events.tasks.summary.library', 'Library')} value={summary.library} text={text} muted={muted} /> label={t('events.tasks.summary.assigned', 'Assigned')}
value={summary.assigned}
text={text}
muted={muted}
surfaceMuted={surfaceMuted}
/>
<SummaryItem
label={t('events.tasks.summary.library', 'Library')}
value={summary.library}
text={text}
muted={muted}
surfaceMuted={surfaceMuted}
/>
</XStack> </XStack>
<XStack alignItems="center" justifyContent="space-between" space="$2"> <XStack alignItems="center" justifyContent="space-between" space="$2">
<SummaryItem label={t('events.tasks.summary.collections', 'Collections')} value={summary.collections} text={text} muted={muted} /> <SummaryItem
<SummaryItem label={t('events.tasks.summary.emotions', 'Emotions')} value={summary.emotions} text={text} muted={muted} /> label={t('events.tasks.summary.collections', 'Collections')}
value={summary.collections}
text={text}
muted={muted}
surfaceMuted={surfaceMuted}
/>
<SummaryItem
label={t('events.tasks.summary.emotions', 'Emotions')}
value={summary.emotions}
text={text}
muted={muted}
surfaceMuted={surfaceMuted}
/>
</XStack> </XStack>
</MobileCard> </MobileCard>
); );
@@ -78,14 +102,16 @@ function SummaryItem({
value, value,
text, text,
muted, muted,
surfaceMuted,
}: { }: {
label: string; label: string;
value: number; value: number;
text: string; text: string;
muted: string; muted: string;
surfaceMuted: string;
}) { }) {
return ( return (
<YStack flex={1} padding="$2" borderRadius={12} backgroundColor="rgba(15, 23, 42, 0.03)" space="$1"> <YStack flex={1} padding="$2" borderRadius={12} backgroundColor={surfaceMuted} space="$1">
<Text fontSize={11} color={muted}> <Text fontSize={11} color={muted}>
{label} {label}
</Text> </Text>
@@ -102,7 +128,7 @@ export default function MobileEventTasksPage() {
const slug = slugParam ?? activeEvent?.slug ?? null; const slug = slugParam ?? activeEvent?.slug ?? null;
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useTranslation('management'); const { t } = useTranslation('management');
const { textStrong, muted, subtle, border, primary, danger, surface } = useAdminTheme(); const { textStrong, muted, subtle, border, primary, danger, surface, surfaceMuted, dangerBg, dangerText, overlay } = useAdminTheme();
const [assignedTasks, setAssignedTasks] = React.useState<TenantTask[]>([]); const [assignedTasks, setAssignedTasks] = React.useState<TenantTask[]>([]);
const [library, setLibrary] = React.useState<TenantTask[]>([]); const [library, setLibrary] = React.useState<TenantTask[]>([]);
const [collections, setCollections] = React.useState<TenantTaskCollection[]>([]); const [collections, setCollections] = React.useState<TenantTaskCollection[]>([]);
@@ -120,6 +146,7 @@ export default function MobileEventTasksPage() {
const [error, setError] = React.useState<string | null>(null); const [error, setError] = React.useState<string | null>(null);
const [busyId, setBusyId] = React.useState<number | null>(null); const [busyId, setBusyId] = React.useState<number | null>(null);
const [assigningId, setAssigningId] = React.useState<number | null>(null); const [assigningId, setAssigningId] = React.useState<number | null>(null);
const [deleteCandidate, setDeleteCandidate] = React.useState<TenantTask | null>(null);
const [eventId, setEventId] = React.useState<number | null>(null); const [eventId, setEventId] = React.useState<number | null>(null);
const [searchTerm, setSearchTerm] = React.useState(''); const [searchTerm, setSearchTerm] = React.useState('');
const [emotionFilter, setEmotionFilter] = React.useState<string>(''); const [emotionFilter, setEmotionFilter] = React.useState<string>('');
@@ -349,6 +376,13 @@ export default function MobileEventTasksPage() {
} }
} }
async function confirmDeleteTask() {
if (!deleteCandidate) return;
const taskId = deleteCandidate.id;
setDeleteCandidate(null);
await detachTask(taskId);
}
const startEdit = (task: TenantTask) => { const startEdit = (task: TenantTask) => {
setNewTask({ setNewTask({
id: task.id, id: task.id,
@@ -462,6 +496,7 @@ export default function MobileEventTasksPage() {
text={text} text={text}
muted={muted} muted={muted}
border={border} border={border}
surfaceMuted={surfaceMuted}
/> />
) : null} ) : null}
@@ -472,23 +507,26 @@ export default function MobileEventTasksPage() {
</Text> </Text>
<XStack space="$2" flexWrap="wrap"> <XStack space="$2" flexWrap="wrap">
{sectionCounts.map((section) => ( {sectionCounts.map((section) => (
<Pressable key={section.key} onPress={() => handleQuickNav(section.key)} style={{ flexGrow: 1 }}> <Button
<XStack key={section.key}
alignItems="center" unstyled
justifyContent="center" onPress={() => handleQuickNav(section.key)}
space="$1.5" borderRadius={14}
paddingVertical="$2" borderWidth={1}
paddingHorizontal="$3" borderColor={border}
borderRadius={14} backgroundColor={surface}
borderWidth={1} paddingVertical="$2"
borderColor={border} paddingHorizontal="$3"
> pressStyle={{ backgroundColor: surfaceMuted }}
style={{ flexGrow: 1 }}
>
<XStack alignItems="center" justifyContent="center" space="$1.5">
<Text fontSize="$xs" fontWeight="700" color={text}> <Text fontSize="$xs" fontWeight="700" color={text}>
{t(`events.tasks.sections.${section.key}`, section.key)} {t(`events.tasks.sections.${section.key}`, section.key)}
</Text> </Text>
<PillBadge tone="muted">{section.count}</PillBadge> <PillBadge tone="muted">{section.count}</PillBadge>
</XStack> </XStack>
</Pressable> </Button>
))} ))}
</XStack> </XStack>
</YStack> </YStack>
@@ -523,11 +561,12 @@ export default function MobileEventTasksPage() {
/> />
</XStack> </XStack>
</MobileCard> </MobileCard>
<YStack borderWidth={1} borderColor={border} borderRadius="$4" overflow="hidden"> <YGroup borderWidth={1} borderColor={border} borderRadius="$4" overflow="hidden">
<Pressable onPress={() => setShowTaskSheet(true)}> <YGroup.Item bordered>
<ListItem <ListItem
hoverTheme hoverTheme
pressTheme pressTheme
onPress={() => setShowTaskSheet(true)}
title={ title={
<XStack alignItems="center" space="$2"> <XStack alignItems="center" space="$2">
<YStack <YStack
@@ -554,12 +593,12 @@ export default function MobileEventTasksPage() {
paddingHorizontal="$3" paddingHorizontal="$3"
iconAfter={<ChevronRight size={16} color={muted} />} iconAfter={<ChevronRight size={16} color={muted} />}
/> />
</Pressable> </YGroup.Item>
<InlineSeparator /> <YGroup.Item>
<Pressable onPress={() => setShowCollectionSheet(true)}>
<ListItem <ListItem
hoverTheme hoverTheme
pressTheme pressTheme
onPress={() => setShowCollectionSheet(true)}
title={ title={
<XStack alignItems="center" space="$2"> <XStack alignItems="center" space="$2">
<YStack <YStack
@@ -586,8 +625,8 @@ export default function MobileEventTasksPage() {
paddingHorizontal="$3" paddingHorizontal="$3"
iconAfter={<ChevronRight size={16} color={muted} />} iconAfter={<ChevronRight size={16} color={muted} />}
/> />
</Pressable> </YGroup.Item>
</YStack> </YGroup>
</YStack> </YStack>
) : ( ) : (
<YStack space="$2"> <YStack space="$2">
@@ -622,44 +661,53 @@ export default function MobileEventTasksPage() {
<Text fontSize="$sm" color={muted}> <Text fontSize="$sm" color={muted}>
{t('events.tasks.count', '{{count}} Tasks', { count: filteredTasks.length })} {t('events.tasks.count', '{{count}} Tasks', { count: filteredTasks.length })}
</Text> </Text>
<YStack borderWidth={1} borderColor={border} borderRadius="$4" overflow="hidden"> <YGroup borderWidth={1} borderColor={border} borderRadius="$4" overflow="hidden">
{filteredTasks.map((task, idx) => ( {filteredTasks.map((task, idx) => (
<React.Fragment key={task.id}> <YGroup.Item key={task.id} bordered={idx < filteredTasks.length - 1}>
<Pressable onPress={() => startEdit(task)}> <ListItem
<ListItem hoverTheme
hoverTheme pressTheme
pressTheme onPress={() => startEdit(task)}
title={ title={
<Text fontSize={12.5} fontWeight="600" color={text}> <Text fontSize={12.5} fontWeight="600" color={text}>
{task.title} {task.title}
</Text>
}
subTitle={
task.description ? (
<Text fontSize={11.5} fontWeight="400" color={subtle}>
{task.description}
</Text> </Text>
} ) : null
subTitle={ }
task.description ? ( iconAfter={
<Text fontSize={11.5} fontWeight="400" color={subtle}> <XStack space="$2" alignItems="center">
{task.description} {task.emotion ? (
</Text> <Tag label={task.emotion.name ?? ''} color={task.emotion.color ?? text} />
) : null ) : null}
} <Button
iconAfter={ size="$2"
<XStack space="$2" alignItems="flex-start"> circular
{task.emotion ? ( backgroundColor={dangerBg}
<Tag label={task.emotion.name ?? ''} color={task.emotion.color ?? text} /> borderWidth={1}
) : null} borderColor={`${danger}33`}
<Pressable disabled={busyId === task.id} onPress={() => detachTask(task.id)}> icon={<Trash2 size={14} color={dangerText} />}
<Trash2 size={14} color={danger} /> aria-label={t('events.tasks.remove', 'Remove task')}
</Pressable> disabled={busyId === task.id}
<ChevronRight size={14} color={subtle} /> onPress={(event) => {
</XStack> event?.stopPropagation?.();
} setDeleteCandidate(task);
paddingVertical="$2" }}
paddingHorizontal="$3" />
/> <ChevronRight size={14} color={subtle} />
</Pressable> </XStack>
{idx < assignedTasks.length - 1 ? <InlineSeparator /> : null} }
</React.Fragment> paddingVertical="$2"
paddingHorizontal="$3"
/>
</YGroup.Item>
))} ))}
</YStack> </YGroup>
<XStack justifyContent="space-between" alignItems="center" marginTop="$2"> <XStack justifyContent="space-between" alignItems="center" marginTop="$2">
<div ref={libraryRef} /> <div ref={libraryRef} />
<Text fontSize={12.5} fontWeight="600" color={text}> <Text fontSize={12.5} fontWeight="600" color={text}>
@@ -681,9 +729,9 @@ export default function MobileEventTasksPage() {
{t('events.tasks.libraryEmpty', 'Keine weiteren Aufgaben verfügbar.')} {t('events.tasks.libraryEmpty', 'Keine weiteren Aufgaben verfügbar.')}
</Text> </Text>
) : ( ) : (
<YStack borderWidth={1} borderColor={border} borderRadius="$4" overflow="hidden"> <YGroup borderWidth={1} borderColor={border} borderRadius="$4" overflow="hidden">
{(expandedLibrary ? library : library.slice(0, 6)).map((task, idx, arr) => ( {(expandedLibrary ? library : library.slice(0, 6)).map((task, idx, arr) => (
<React.Fragment key={`lib-${task.id}`}> <YGroup.Item key={`lib-${task.id}`} bordered={idx < arr.length - 1}>
<ListItem <ListItem
hoverTheme hoverTheme
pressTheme pressTheme
@@ -712,10 +760,9 @@ export default function MobileEventTasksPage() {
paddingVertical="$2" paddingVertical="$2"
paddingHorizontal="$3" paddingHorizontal="$3"
/> />
{idx < arr.length - 1 ? <InlineSeparator /> : null} </YGroup.Item>
</React.Fragment>
))} ))}
</YStack> </YGroup>
)} )}
</YStack> </YStack>
)} )}
@@ -739,9 +786,9 @@ export default function MobileEventTasksPage() {
{t('events.tasks.collectionsEmpty', 'Keine Pakete vorhanden.')} {t('events.tasks.collectionsEmpty', 'Keine Pakete vorhanden.')}
</Text> </Text>
) : ( ) : (
<YStack borderWidth={1} borderColor={border} borderRadius="$4" overflow="hidden"> <YGroup borderWidth={1} borderColor={border} borderRadius="$4" overflow="hidden">
{(expandedCollections ? collections : collections.slice(0, 6)).map((collection, idx, arr) => ( {(expandedCollections ? collections : collections.slice(0, 6)).map((collection, idx, arr) => (
<React.Fragment key={collection.id}> <YGroup.Item key={collection.id} bordered={idx < arr.length - 1}>
<ListItem <ListItem
hoverTheme hoverTheme
pressTheme pressTheme
@@ -767,13 +814,12 @@ export default function MobileEventTasksPage() {
<ChevronRight size={14} color={subtle} /> <ChevronRight size={14} color={subtle} />
</XStack> </XStack>
} }
paddingVertical="$2" paddingVertical="$2"
paddingHorizontal="$3" paddingHorizontal="$3"
/> />
{idx < arr.length - 1 ? <InlineSeparator /> : null} </YGroup.Item>
</React.Fragment>
))} ))}
</YStack> </YGroup>
)} )}
</YStack> </YStack>
</MobileSheet> </MobileSheet>
@@ -871,36 +917,39 @@ export default function MobileEventTasksPage() {
style={{ padding: 0 }} style={{ padding: 0 }}
/> />
</MobileField> </MobileField>
<YStack space="$2"> <YGroup borderWidth={1} borderColor={border} borderRadius="$4" overflow="hidden">
{emotions.map((em) => ( {emotions.map((em, idx) => (
<ListItem <YGroup.Item key={`emo-${em.id}`} bordered={idx < emotions.length - 1}>
key={`emo-${em.id}`} <ListItem
hoverTheme hoverTheme
pressTheme pressTheme
title={ title={
<XStack alignItems="center" space="$2"> <XStack alignItems="center" space="$2">
<Tag label={em.name ?? ''} color={em.color ?? border} /> <Tag label={em.name ?? ''} color={em.color ?? border} />
</XStack> </XStack>
} }
iconAfter={ iconAfter={
<XStack space="$2"> <XStack space="$2">
<Pressable <Pressable
onPress={() => { onPress={() => {
setEditingEmotion(em); setEditingEmotion(em);
setEmotionForm({ name: em.name ?? '', color: em.color ?? border }); setEmotionForm({ name: em.name ?? '', color: em.color ?? border });
}} }}
> >
<Pencil size={14} color={primary} /> <Pencil size={14} color={primary} />
</Pressable> </Pressable>
<Pressable onPress={() => removeEmotion(em.id)}> <Pressable onPress={() => removeEmotion(em.id)}>
<Trash2 size={14} color={danger} /> <Trash2 size={14} color={danger} />
</Pressable> </Pressable>
<ChevronRight size={14} color={subtle} /> <ChevronRight size={14} color={subtle} />
</XStack> </XStack>
} }
/> paddingVertical="$2"
paddingHorizontal="$3"
/>
</YGroup.Item>
))} ))}
</YStack> </YGroup>
</YStack> </YStack>
</MobileSheet> </MobileSheet>
@@ -942,24 +991,66 @@ export default function MobileEventTasksPage() {
</RadioGroup> </RadioGroup>
</MobileSheet> </MobileSheet>
<Pressable <AlertDialog
onPress={() => setShowFabMenu(true)} open={Boolean(deleteCandidate)}
style={{ onOpenChange={(open) => {
position: 'fixed', if (!open) {
right: 20, setDeleteCandidate(null);
bottom: 'calc(env(safe-area-inset-bottom, 0px) + 96px)', }
width: 56, }}
height: 56, >
borderRadius: 28, <AlertDialog.Portal>
backgroundColor: primary, <AlertDialog.Overlay backgroundColor={`${overlay}66`} />
justifyContent: 'center', <AlertDialog.Content
alignItems: 'center', borderRadius={20}
boxShadow: '0 10px 25px rgba(0,122,255,0.35)', borderWidth={1}
zIndex: 60, borderColor={border}
}} backgroundColor={surface}
> padding="$4"
<Plus size={20} color={surface} /> maxWidth={420}
</Pressable> width="90%"
>
<YStack space="$3">
<AlertDialog.Title>
<Text fontSize="$md" fontWeight="800" color={text}>
{t('events.tasks.removeTitle', 'Remove task?')}
</Text>
</AlertDialog.Title>
<AlertDialog.Description>
<Text fontSize="$sm" color={muted}>
{deleteCandidate
? t('events.tasks.removeBody', 'This will remove "{{title}}" from the event.', { title: deleteCandidate.title })
: t('events.tasks.removeBodyFallback', 'This will remove the task from the event.')}
</Text>
</AlertDialog.Description>
<XStack space="$2" justifyContent="flex-end">
<AlertDialog.Cancel asChild>
<CTAButton
label={t('common.cancel', 'Cancel')}
tone="ghost"
fullWidth={false}
onPress={() => setDeleteCandidate(null)}
/>
</AlertDialog.Cancel>
<AlertDialog.Action asChild>
<CTAButton
label={t('events.tasks.remove', 'Remove')}
tone="danger"
fullWidth={false}
onPress={() => confirmDeleteTask()}
/>
</AlertDialog.Action>
</XStack>
</YStack>
</AlertDialog.Content>
</AlertDialog.Portal>
</AlertDialog>
<FloatingActionButton
onPress={() => setShowFabMenu(true)}
label={t('events.tasks.add', 'Add')}
icon={Plus}
/>
<MobileSheet <MobileSheet
open={showFabMenu} open={showFabMenu}
@@ -967,61 +1058,67 @@ export default function MobileEventTasksPage() {
title={t('events.tasks.actions', 'Aktionen')} title={t('events.tasks.actions', 'Aktionen')}
footer={null} footer={null}
> >
<YStack space="$1"> <YGroup borderWidth={1} borderColor={border} borderRadius="$4" overflow="hidden">
<ListItem <YGroup.Item bordered>
hoverTheme <ListItem
pressTheme hoverTheme
title={ pressTheme
<Text fontSize={12.5} fontWeight="600" color={text}> title={
{t('events.tasks.addTask', 'Aufgabe hinzufügen')} <Text fontSize={12.5} fontWeight="600" color={text}>
</Text> {t('events.tasks.addTask', 'Aufgabe hinzufügen')}
} </Text>
onPress={() => { }
setShowFabMenu(false); onPress={() => {
setShowTaskSheet(true); setShowFabMenu(false);
}} setShowTaskSheet(true);
paddingVertical="$2" }}
paddingHorizontal="$3" paddingVertical="$2"
iconAfter={<ChevronRight size={14} color={subtle} />} paddingHorizontal="$3"
/> iconAfter={<ChevronRight size={14} color={subtle} />}
<ListItem />
hoverTheme </YGroup.Item>
pressTheme <YGroup.Item bordered>
title={ <ListItem
<Text fontSize={12.5} fontWeight="600" color={text}> hoverTheme
{t('events.tasks.bulkAdd', 'Bulk add')} pressTheme
</Text> title={
} <Text fontSize={12.5} fontWeight="600" color={text}>
onPress={() => { {t('events.tasks.bulkAdd', 'Bulk add')}
setShowFabMenu(false); </Text>
setShowBulkSheet(true); }
}} onPress={() => {
paddingVertical="$2" setShowFabMenu(false);
paddingHorizontal="$3" setShowBulkSheet(true);
iconAfter={<ChevronRight size={14} color={subtle} />} }}
/> paddingVertical="$2"
<ListItem paddingHorizontal="$3"
hoverTheme iconAfter={<ChevronRight size={14} color={subtle} />}
pressTheme />
title={ </YGroup.Item>
<Text fontSize={12.5} fontWeight="600" color={text}> <YGroup.Item>
{t('events.tasks.manageEmotions', 'Manage emotions')} <ListItem
</Text> hoverTheme
} pressTheme
subTitle={ title={
<Text fontSize={11.5} fontWeight="400" color={subtle}> <Text fontSize={12.5} fontWeight="600" color={text}>
{t('events.tasks.manageEmotionsHint', 'Filter and keep your taxonomy tidy.')} {t('events.tasks.manageEmotions', 'Manage emotions')}
</Text> </Text>
} }
onPress={() => { subTitle={
setShowFabMenu(false); <Text fontSize={11.5} fontWeight="400" color={subtle}>
setShowEmotionSheet(true); {t('events.tasks.manageEmotionsHint', 'Filter and keep your taxonomy tidy.')}
}} </Text>
paddingVertical="$2" }
paddingHorizontal="$3" onPress={() => {
iconAfter={<ChevronRight size={14} color={subtle} />} setShowFabMenu(false);
/> setShowEmotionSheet(true);
</YStack> }}
paddingVertical="$2"
paddingHorizontal="$3"
iconAfter={<ChevronRight size={14} color={subtle} />}
/>
</YGroup.Item>
</YGroup>
</MobileSheet> </MobileSheet>
</MobileShell> </MobileShell>
); );

View File

@@ -0,0 +1,94 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, screen } from '@testing-library/react';
const backMock = vi.fn();
const navigateMock = vi.fn();
vi.mock('react-router-dom', () => ({
useNavigate: () => navigateMock,
useParams: () => ({}),
}));
vi.mock('../hooks/useBackNavigation', () => ({
useBackNavigation: () => backMock,
}));
vi.mock('../../api', () => ({
createEvent: vi.fn(),
getEvent: vi.fn(),
updateEvent: vi.fn(),
getEventTypes: vi.fn().mockResolvedValue([]),
trackOnboarding: vi.fn(),
}));
vi.mock('react-hot-toast', () => ({
default: {
error: vi.fn(),
success: vi.fn(),
},
}));
vi.mock('../components/MobileShell', () => ({
MobileShell: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('../components/Primitives', () => ({
MobileCard: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
CTAButton: ({ 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>,
MobileInput: (props: React.InputHTMLAttributes<HTMLInputElement>) => <input {...props} />,
MobileSelect: ({ children, ...props }: { children: React.ReactNode }) => <select {...props}>{children}</select>,
MobileTextArea: (props: React.TextareaHTMLAttributes<HTMLTextAreaElement>) => <textarea {...props} />,
}));
vi.mock('../components/LegalConsentSheet', () => ({
LegalConsentSheet: () => null,
}));
vi.mock('@tamagui/stacks', () => ({
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
XStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@tamagui/text', () => ({
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
}));
vi.mock('@tamagui/switch', () => ({
Switch: Object.assign(
({ children }: { children: React.ReactNode }) => <div>{children}</div>,
{ Thumb: () => <div /> },
),
}));
vi.mock('../theme', () => ({
useAdminTheme: () => ({
text: '#111827',
muted: '#6b7280',
subtle: '#94a3b8',
danger: '#b91c1c',
}),
}));
import MobileEventFormPage from '../EventFormPage';
describe('MobileEventFormPage', () => {
it('renders a save draft button when creating a new event', async () => {
await act(async () => {
render(<MobileEventFormPage />);
});
const saveDraft = screen.getByText('eventForm.actions.saveDraft');
fireEvent.click(saveDraft);
expect(backMock).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,85 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';
vi.mock('@tamagui/core', () => ({
useTheme: () => ({
color: { val: '#111827' },
gray: { val: '#6b7280' },
borderColor: { val: '#e5e7eb' },
primary: { val: '#FF5A5F' },
surface: { val: '#ffffff' },
red10: { val: '#b91c1c' },
}),
}));
vi.mock('@tamagui/stacks', () => ({
YStack: ({ children, ...props }: { children: React.ReactNode }) => <div {...props}>{children}</div>,
XStack: ({ children, ...props }: { children: React.ReactNode }) => <div {...props}>{children}</div>,
}));
vi.mock('@tamagui/text', () => ({
SizableText: ({ children, ...props }: { children: React.ReactNode }) => <span {...props}>{children}</span>,
}));
vi.mock('tamagui', () => ({
Input: ({ ...props }: React.InputHTMLAttributes<HTMLInputElement>) => <input {...props} />,
TextArea: ({ ...props }: React.TextareaHTMLAttributes<HTMLTextAreaElement>) => <textarea {...props} />,
}));
vi.mock('@tamagui/select', () => {
const SelectContext = React.createContext<{ onValueChange?: (value: string) => void } | null>(null);
const Select = ({ children, onValueChange }: { children: React.ReactNode; onValueChange?: (value: string) => void }) => (
<SelectContext.Provider value={{ onValueChange }}>
<div>{children}</div>
</SelectContext.Provider>
);
const Trigger = ({ children, ...props }: { children: React.ReactNode }) => <div {...props}>{children}</div>;
const Value = ({ placeholder }: { placeholder?: React.ReactNode }) => <span>{placeholder}</span>;
const Content = ({ children }: { children: React.ReactNode }) => <div>{children}</div>;
const Viewport = ({ children }: { children: React.ReactNode }) => <div>{children}</div>;
const Group = ({ children }: { children: React.ReactNode }) => <div>{children}</div>;
const Item = ({ children, value }: { children: React.ReactNode; value: string }) => {
const ctx = React.useContext(SelectContext);
return (
<button type="button" data-value={value} onClick={() => ctx?.onValueChange?.(value)}>
{children}
</button>
);
};
const ItemText = ({ children }: { children: React.ReactNode }) => <span>{children}</span>;
Select.Trigger = Trigger;
Select.Value = Value;
Select.Content = Content;
Select.Viewport = Viewport;
Select.Group = Group;
Select.Item = Item;
Select.ItemText = ItemText;
return { Select };
});
import { MobileSelect } from './FormControls';
describe('MobileSelect', () => {
it('maps options and forwards selection changes', () => {
const handleChange = vi.fn();
render(
<MobileSelect value="" onChange={handleChange}>
<option value="">None</option>
<option value="one">One</option>
</MobileSelect>,
);
const items = screen.getAllByRole('button');
fireEvent.click(items[1]);
expect(handleChange).toHaveBeenCalledWith(
expect.objectContaining({
target: expect.objectContaining({ value: 'one' }),
}),
);
});
});

View File

@@ -2,6 +2,8 @@ import React from 'react';
import { ChevronDown } from 'lucide-react'; import { ChevronDown } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks'; import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text'; import { SizableText as Text } from '@tamagui/text';
import { Input, TextArea } from 'tamagui';
import { Select } from '@tamagui/select';
import { withAlpha } from './colors'; import { withAlpha } from './colors';
import { useAdminTheme } from '../theme'; import { useAdminTheme } from '../theme';
@@ -40,41 +42,41 @@ type ControlProps = {
compact?: boolean; compact?: boolean;
}; };
export const MobileInput = React.forwardRef<HTMLInputElement, React.ComponentPropsWithoutRef<'input'> & ControlProps>( type MobileSelectProps = React.ComponentPropsWithoutRef<'select'> & ControlProps & {
function MobileInput({ hasError = false, compact = false, style, ...props }, ref) { placeholder?: string;
const { border, surface, text, primary, danger } = useAdminTheme(); containerStyle?: React.CSSProperties;
const [focused, setFocused] = React.useState(false); };
const height = compact ? 36 : 44; export const MobileInput = React.forwardRef<HTMLInputElement, React.ComponentPropsWithoutRef<'input'> & ControlProps>(
const borderColor = hasError ? danger : focused ? primary : border; function MobileInput({ hasError = false, compact = false, style, onChange, ...props }, ref) {
const { border, surface, text, primary, danger } = useAdminTheme();
const borderColor = hasError ? danger : border;
const ringColor = hasError ? withAlpha(danger, 0.18) : withAlpha(primary, 0.18); const ringColor = hasError ? withAlpha(danger, 0.18) : withAlpha(primary, 0.18);
return ( return (
<input <Input
ref={ref} ref={ref as React.Ref<any>}
{...props} {...props}
onFocus={(event) => { onChangeText={(value) => {
setFocused(true); onChange?.({ target: { value } } as React.ChangeEvent<HTMLInputElement>);
props.onFocus?.(event);
}} }}
onBlur={(event) => { size={compact ? '$3' : '$4'}
setFocused(false); height={compact ? 36 : 44}
props.onBlur?.(event); paddingHorizontal="$3"
borderRadius={12}
width="100%"
fontSize={compact ? 13 : 14}
backgroundColor={surface}
color={text}
borderColor={borderColor}
focusStyle={{
borderColor: hasError ? danger : primary,
boxShadow: `0 0 0 3px ${ringColor}`,
}} }}
style={{ hoverStyle={{
width: '100%', borderColor,
height,
borderRadius: 12,
border: `1px solid ${borderColor}`,
padding: '0 12px',
fontSize: compact ? 13 : 14,
background: surface,
color: text,
outline: 'none',
boxShadow: focused || hasError ? `0 0 0 3px ${ringColor}` : 'none',
transition: 'border-color 150ms ease, box-shadow 150ms ease',
...style,
}} }}
style={style}
/> />
); );
}, },
@@ -83,40 +85,35 @@ export const MobileInput = React.forwardRef<HTMLInputElement, React.ComponentPro
export const MobileTextArea = React.forwardRef< export const MobileTextArea = React.forwardRef<
HTMLTextAreaElement, HTMLTextAreaElement,
React.ComponentPropsWithoutRef<'textarea'> & ControlProps React.ComponentPropsWithoutRef<'textarea'> & ControlProps
>(function MobileTextArea({ hasError = false, compact = false, style, ...props }, ref) { >(function MobileTextArea({ hasError = false, compact = false, style, onChange, ...props }, ref) {
const { border, surface, text, primary, danger } = useAdminTheme(); const { border, surface, text, primary, danger } = useAdminTheme();
const [focused, setFocused] = React.useState(false); const borderColor = hasError ? danger : border;
const borderColor = hasError ? danger : focused ? primary : border;
const ringColor = hasError ? withAlpha(danger, 0.18) : withAlpha(primary, 0.18); const ringColor = hasError ? withAlpha(danger, 0.18) : withAlpha(primary, 0.18);
return ( return (
<textarea <TextArea
ref={ref} ref={ref as React.Ref<any>}
{...props} {...props}
onFocus={(event) => { onChangeText={(value) => {
setFocused(true); onChange?.({ target: { value } } as React.ChangeEvent<HTMLTextAreaElement>);
props.onFocus?.(event);
}} }}
onBlur={(event) => { size={compact ? '$3' : '$4'}
setFocused(false); minHeight={compact ? 72 : 96}
props.onBlur?.(event); borderRadius={12}
padding="$3"
width="100%"
fontSize={compact ? 13 : 14}
backgroundColor={surface}
color={text}
borderColor={borderColor}
focusStyle={{
borderColor: hasError ? danger : primary,
boxShadow: `0 0 0 3px ${ringColor}`,
}} }}
style={{ hoverStyle={{
width: '100%', borderColor,
borderRadius: 12,
border: `1px solid ${borderColor}`,
padding: '10px 12px',
fontSize: compact ? 13 : 14,
background: surface,
color: text,
outline: 'none',
minHeight: compact ? 72 : 96,
boxShadow: focused || hasError ? `0 0 0 3px ${ringColor}` : 'none',
transition: 'border-color 150ms ease, box-shadow 150ms ease',
resize: 'vertical',
...style,
}} }}
style={{ resize: 'vertical', ...style }}
/> />
); );
}); });
@@ -125,50 +122,92 @@ export function MobileSelect({
children, children,
hasError = false, hasError = false,
compact = false, compact = false,
containerStyle,
style, style,
...props ...props
}: React.ComponentPropsWithoutRef<'select'> & ControlProps) { }: MobileSelectProps) {
const { border, surface, text, primary, danger, subtle } = useAdminTheme(); const { border, surface, text, primary, danger, subtle } = useAdminTheme();
const [focused, setFocused] = React.useState(false); const borderColor = hasError ? danger : border;
const height = compact ? 36 : 44;
const borderColor = hasError ? danger : focused ? primary : border;
const ringColor = hasError ? withAlpha(danger, 0.18) : withAlpha(primary, 0.18); const ringColor = hasError ? withAlpha(danger, 0.18) : withAlpha(primary, 0.18);
const hasSizing =
typeof containerStyle === 'object' &&
containerStyle !== null &&
('width' in containerStyle ||
'maxWidth' in containerStyle ||
'minWidth' in containerStyle ||
'flex' in containerStyle ||
'flexGrow' in containerStyle);
const options = React.Children.toArray(children).flatMap((child) => {
if (!React.isValidElement(child)) return [];
const { value, children: label, disabled } = child.props as {
value?: string | number;
children?: React.ReactNode;
disabled?: boolean;
};
return [
{
value: value === undefined ? '' : String(value),
label: label ?? '',
disabled: Boolean(disabled),
},
];
});
const emptyOption = options.find((option) => option.value === '');
const selectValue = props.value === undefined ? undefined : String(props.value ?? '');
const selectDefault = props.defaultValue === undefined ? undefined : String(props.defaultValue ?? '');
return ( return (
<XStack position="relative" alignItems="center"> <XStack position="relative" alignItems="center" width={hasSizing ? undefined : '100%'} style={containerStyle}>
<select <Select
{...props} value={selectValue}
onFocus={(event) => { defaultValue={selectValue === undefined ? selectDefault : undefined}
setFocused(true); onValueChange={(next) => {
props.onFocus?.(event); props.onChange?.({ target: { value: next } } as React.ChangeEvent<HTMLSelectElement>);
}}
onBlur={(event) => {
setFocused(false);
props.onBlur?.(event);
}}
style={{
width: '100%',
height,
borderRadius: 12,
border: `1px solid ${borderColor}`,
padding: '0 36px 0 12px',
fontSize: compact ? 13 : 14,
background: surface,
color: text,
outline: 'none',
appearance: 'none',
WebkitAppearance: 'none',
boxShadow: focused || hasError ? `0 0 0 3px ${ringColor}` : 'none',
transition: 'border-color 150ms ease, box-shadow 150ms ease',
...style,
}} }}
size={compact ? '$3' : '$4'}
> >
{children} <Select.Trigger
</select> width="100%"
<XStack position="absolute" right={12} pointerEvents="none"> borderRadius={12}
<ChevronDown size={16} color={subtle} /> borderWidth={1}
</XStack> borderColor={borderColor}
backgroundColor={surface}
paddingVertical={compact ? 6 : 10}
paddingHorizontal="$3"
disabled={props.disabled}
onFocus={props.onFocus}
onBlur={props.onBlur}
iconAfter={<ChevronDown size={16} color={subtle} />}
focusStyle={{
borderColor: hasError ? danger : primary,
boxShadow: `0 0 0 3px ${ringColor}`,
}}
hoverStyle={{
borderColor,
}}
style={style}
>
<Select.Value placeholder={props.placeholder ?? emptyOption?.label ?? ''} color={text} />
</Select.Trigger>
<Select.Content
zIndex={200000}
borderRadius={14}
borderWidth={1}
borderColor={border}
backgroundColor={surface}
>
<Select.Viewport padding="$2">
<Select.Group>
{options.map((option, index) => (
<Select.Item key={`${option.value}-${index}`} value={option.value} disabled={option.disabled}>
<Select.ItemText>{option.label}</Select.ItemText>
</Select.Item>
))}
</Select.Group>
</Select.Viewport>
</Select.Content>
</Select>
{props.name ? <input type="hidden" name={props.name} value={selectValue ?? selectDefault ?? ''} /> : null}
</XStack> </XStack>
); );
} }

View File

@@ -83,14 +83,18 @@ export function CTAButton({
}: { }: {
label: string; label: string;
onPress: () => void; onPress: () => void;
tone?: 'primary' | 'ghost'; tone?: 'primary' | 'ghost' | 'danger';
fullWidth?: boolean; fullWidth?: boolean;
disabled?: boolean; disabled?: boolean;
loading?: boolean; loading?: boolean;
}) { }) {
const { primary, surface, border, text } = useAdminTheme(); const { primary, surface, border, text, danger } = useAdminTheme();
const isPrimary = tone === 'primary'; const isPrimary = tone === 'primary';
const isDanger = tone === 'danger';
const isDisabled = disabled || loading; const isDisabled = disabled || loading;
const backgroundColor = isDanger ? danger : isPrimary ? primary : surface;
const borderColor = isPrimary || isDanger ? 'transparent' : border;
const labelColor = isPrimary || isDanger ? 'white' : text;
return ( return (
<Pressable <Pressable
onPress={isDisabled ? undefined : onPress} onPress={isDisabled ? undefined : onPress}
@@ -106,11 +110,11 @@ export function CTAButton({
borderRadius={16} borderRadius={16}
alignItems="center" alignItems="center"
justifyContent="center" justifyContent="center"
backgroundColor={isPrimary ? primary : surface} backgroundColor={backgroundColor}
borderWidth={isPrimary ? 0 : 1} borderWidth={isPrimary || isDanger ? 0 : 1}
borderColor={isPrimary ? 'transparent' : border} borderColor={borderColor}
> >
<Text fontSize="$sm" fontWeight="800" color={isPrimary ? 'white' : text}> <Text fontSize="$sm" fontWeight="800" color={labelColor}>
{label} {label}
</Text> </Text>
</XStack> </XStack>

View File

@@ -0,0 +1,62 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string, fallback?: string) => fallback ?? key }),
}));
vi.mock('@tamagui/core', () => ({
useTheme: () => ({
surface: { val: '#ffffff' },
color12: { val: '#111827' },
gray: { val: '#6b7280' },
gray12: { val: '#0f172a' },
borderColor: { val: '#e5e7eb' },
shadowColor: { val: 'rgba(0,0,0,0.1)' },
}),
}));
vi.mock('@tamagui/stacks', () => ({
YStack: ({ children, ...props }: { children: React.ReactNode }) => <div {...props}>{children}</div>,
XStack: ({ children, ...props }: { children: React.ReactNode }) => <div {...props}>{children}</div>,
}));
vi.mock('@tamagui/text', () => ({
SizableText: ({ children, ...props }: { children: React.ReactNode }) => <span {...props}>{children}</span>,
}));
vi.mock('@tamagui/react-native-web-lite', () => ({
Pressable: ({ children, onPress, ...props }: { children: React.ReactNode; onPress?: () => void }) => (
<button type="button" onClick={onPress} {...props}>
{children}
</button>
),
}));
vi.mock('@tamagui/sheet', () => {
const Sheet = ({ children }: { children: React.ReactNode }) => <div>{children}</div>;
Sheet.Frame = ({ children }: { children: React.ReactNode }) => <div>{children}</div>;
Sheet.Overlay = ({ children }: { children?: React.ReactNode }) => <div>{children}</div>;
Sheet.ScrollView = ({ children }: { children: React.ReactNode }) => <div>{children}</div>;
Sheet.Handle = () => <div />;
return { Sheet };
});
import { MobileSheet } from './Sheet';
describe('MobileSheet', () => {
it('renders title and closes via the close action', () => {
const onClose = vi.fn();
render(
<MobileSheet open title="Test Sheet" onClose={onClose}>
<div>Body</div>
</MobileSheet>,
);
expect(screen.getByText('Test Sheet')).toBeInTheDocument();
fireEvent.click(screen.getByText('Close'));
expect(onClose).toHaveBeenCalledTimes(1);
});
});

View File

@@ -2,6 +2,7 @@ import React from 'react';
import { YStack, XStack } from '@tamagui/stacks'; import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text'; import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite'; import { Pressable } from '@tamagui/react-native-web-lite';
import { Sheet } from '@tamagui/sheet';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useAdminTheme } from '../theme'; import { useAdminTheme } from '../theme';
@@ -17,42 +18,64 @@ type SheetProps = {
export function MobileSheet({ open, title, onClose, children, footer, bottomOffsetPx = 88 }: SheetProps) { export function MobileSheet({ open, title, onClose, children, footer, bottomOffsetPx = 88 }: SheetProps) {
const { t } = useTranslation('mobile'); const { t } = useTranslation('mobile');
const { surface, textStrong, muted, overlay, shadow } = useAdminTheme(); const { surface, textStrong, muted, overlay, shadow, border } = useAdminTheme();
const bottomOffset = `max(env(safe-area-inset-bottom, 0px), ${bottomOffsetPx}px)`;
if (!open) return null; if (!open) {
return null;
}
return ( return (
<div className="fixed inset-0 z-50 flex items-end justify-center backdrop-blur-sm" style={{ backgroundColor: `${overlay}66` }}> <Sheet
<YStack modal
open={open}
onOpenChange={(next) => {
if (!next) {
onClose();
}
}}
snapPoints={[82]}
snapPointsMode="percent"
dismissOnOverlayPress
dismissOnSnapToBottom
zIndex={100000}
>
<Sheet.Overlay backgroundColor={`${overlay}66`} />
<Sheet.Frame
width="100%" width="100%"
maxWidth={520} maxWidth={520}
alignSelf="center"
borderTopLeftRadius={24} borderTopLeftRadius={24}
borderTopRightRadius={24} borderTopRightRadius={24}
backgroundColor={surface} backgroundColor={surface}
padding="$4" padding="$4"
paddingBottom="$7" paddingBottom="$7"
space="$3"
shadowColor={shadow} shadowColor={shadow}
shadowOpacity={0.12} shadowOpacity={0.12}
shadowRadius={18} shadowRadius={18}
shadowOffset={{ width: 0, height: -8 }} shadowOffset={{ width: 0, height: -8 }}
maxHeight="82vh" style={{ marginBottom: bottomOffset }}
overflow="auto"
// keep sheet above bottom nav / safe area
style={{ marginBottom: `max(env(safe-area-inset-bottom, 0px), ${bottomOffsetPx}px)` }}
> >
<XStack alignItems="center" justifyContent="space-between"> <Sheet.Handle height={5} width={48} backgroundColor={border} borderRadius={999} marginBottom="$3" />
<Text fontSize="$md" fontWeight="800" color={textStrong}> <Sheet.ScrollView
{title} showsVerticalScrollIndicator={false}
</Text> contentContainerStyle={{ paddingBottom: 6 }}
<Pressable onPress={onClose}> >
<Text fontSize="$md" color={muted}> <YStack space="$3">
{t('actions.close', 'Close')} <XStack alignItems="center" justifyContent="space-between">
</Text> <Text fontSize="$md" fontWeight="800" color={textStrong}>
</Pressable> {title}
</XStack> </Text>
{children} <Pressable onPress={onClose}>
{footer ? footer : null} <Text fontSize="$md" color={muted}>
</YStack> {t('actions.close', 'Close')}
</div> </Text>
</Pressable>
</XStack>
{children}
{footer ? footer : null}
</YStack>
</Sheet.ScrollView>
</Sheet.Frame>
</Sheet>
); );
} }

View File

@@ -23,6 +23,15 @@ vi.mock('@tamagui/react-native-web-lite', () => ({
Pressable: ({ children }: { children: React.ReactNode }) => <button type="button">{children}</button>, Pressable: ({ children }: { children: React.ReactNode }) => <button type="button">{children}</button>,
})); }));
vi.mock('@tamagui/sheet', () => {
const Sheet = ({ children }: { children: React.ReactNode }) => <div>{children}</div>;
Sheet.Frame = ({ children }: { children: React.ReactNode }) => <div>{children}</div>;
Sheet.Overlay = ({ children }: { children?: React.ReactNode }) => <div>{children}</div>;
Sheet.ScrollView = ({ children }: { children: React.ReactNode }) => <div>{children}</div>;
Sheet.Handle = () => <div />;
return { Sheet };
});
import { LegalConsentSheet } from '../LegalConsentSheet'; import { LegalConsentSheet } from '../LegalConsentSheet';
describe('LegalConsentSheet', () => { describe('LegalConsentSheet', () => {

View File

@@ -49,6 +49,20 @@ describe('resolveOnboardingRedirect', () => {
expect(result).toBeNull(); expect(result).toBeNull();
}); });
it('returns null for event creation path', () => {
const result = resolveOnboardingRedirect({
hasEvents: false,
hasActivePackage: true,
selectedPackageId: null,
pathname: '/event-admin/mobile/events/new',
isWelcomePath: false,
isBillingPath: false,
isOnboardingDismissed: false,
isOnboardingCompleted: false,
});
expect(result).toBeNull();
});
it('redirects to event setup when package active', () => { it('redirects to event setup when package active', () => {
const result = resolveOnboardingRedirect({ const result = resolveOnboardingRedirect({
hasEvents: false, hasEvents: false,

View File

@@ -1,4 +1,5 @@
import { import {
ADMIN_EVENT_CREATE_PATH,
ADMIN_WELCOME_BASE_PATH, ADMIN_WELCOME_BASE_PATH,
ADMIN_WELCOME_EVENT_PATH, ADMIN_WELCOME_EVENT_PATH,
ADMIN_WELCOME_SUMMARY_PATH, ADMIN_WELCOME_SUMMARY_PATH,
@@ -37,6 +38,10 @@ export function resolveOnboardingRedirect({
return null; return null;
} }
if (pathname.startsWith(ADMIN_EVENT_CREATE_PATH)) {
return null;
}
const shouldContinueSummary = Boolean(selectedPackageId && selectedPackageId > 0); const shouldContinueSummary = Boolean(selectedPackageId && selectedPackageId > 0);
const target = hasActivePackage const target = hasActivePackage
? ADMIN_WELCOME_EVENT_PATH ? ADMIN_WELCOME_EVENT_PATH