more usage of tamagui primitives
This commit is contained in:
@@ -35,7 +35,7 @@ export default function MobileEventFormPage() {
|
||||
const isEdit = Boolean(slug);
|
||||
const navigate = useNavigate();
|
||||
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>({
|
||||
name: '',
|
||||
@@ -359,20 +359,11 @@ export default function MobileEventFormPage() {
|
||||
|
||||
<YStack space="$2">
|
||||
{!isEdit ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={back}
|
||||
style={{
|
||||
...inputStyle,
|
||||
height: 48,
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${border}`,
|
||||
background: surfaceMuted,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
{t('eventForm.actions.saveDraft', 'Save as draft')}
|
||||
</button>
|
||||
<CTAButton
|
||||
label={t('eventForm.actions.saveDraft', 'Save as draft')}
|
||||
tone="ghost"
|
||||
onPress={back}
|
||||
/>
|
||||
) : null}
|
||||
<CTAButton
|
||||
label={
|
||||
|
||||
@@ -1479,7 +1479,12 @@ function MobileAddonsPicker({
|
||||
|
||||
return (
|
||||
<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) => (
|
||||
<option key={addon.key} value={addon.key}>
|
||||
{addon.label ?? addon.key}
|
||||
@@ -1497,6 +1502,7 @@ function MobileAddonsPicker({
|
||||
disabled={!selected || busy}
|
||||
onPress={() => selected && onCheckout(selected)}
|
||||
loading={busy}
|
||||
fullWidth={false}
|
||||
/>
|
||||
</XStack>
|
||||
);
|
||||
|
||||
@@ -3,11 +3,14 @@ import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RefreshCcw, Plus, Pencil, Trash2, ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { YGroup } from '@tamagui/group';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { ListItem } from '@tamagui/list-item';
|
||||
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 { 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 {
|
||||
getEvent,
|
||||
@@ -42,32 +45,53 @@ import { buildTaskSummary } from './lib/taskSummary';
|
||||
import { buildTaskSectionCounts, type TaskSectionKey } from './lib/taskSectionCounts';
|
||||
import { useAdminTheme } from './theme';
|
||||
|
||||
function InlineSeparator() {
|
||||
const { border } = useAdminTheme();
|
||||
return <XStack height={1} opacity={0.7} marginLeft="$3" backgroundColor={border} />;
|
||||
}
|
||||
|
||||
function TaskSummaryCard({
|
||||
summary,
|
||||
text,
|
||||
muted,
|
||||
border,
|
||||
surfaceMuted,
|
||||
}: {
|
||||
summary: ReturnType<typeof buildTaskSummary>;
|
||||
text: string;
|
||||
muted: string;
|
||||
border: string;
|
||||
surfaceMuted: string;
|
||||
}) {
|
||||
const { t } = useTranslation('management');
|
||||
return (
|
||||
<MobileCard space="$2" borderColor={border}>
|
||||
<XStack alignItems="center" justifyContent="space-between" space="$2">
|
||||
<SummaryItem label={t('events.tasks.summary.assigned', 'Assigned')} value={summary.assigned} text={text} muted={muted} />
|
||||
<SummaryItem label={t('events.tasks.summary.library', 'Library')} value={summary.library} text={text} muted={muted} />
|
||||
<SummaryItem
|
||||
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 alignItems="center" justifyContent="space-between" space="$2">
|
||||
<SummaryItem label={t('events.tasks.summary.collections', 'Collections')} value={summary.collections} text={text} muted={muted} />
|
||||
<SummaryItem label={t('events.tasks.summary.emotions', 'Emotions')} value={summary.emotions} text={text} muted={muted} />
|
||||
<SummaryItem
|
||||
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>
|
||||
</MobileCard>
|
||||
);
|
||||
@@ -78,14 +102,16 @@ function SummaryItem({
|
||||
value,
|
||||
text,
|
||||
muted,
|
||||
surfaceMuted,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
text: string;
|
||||
muted: string;
|
||||
surfaceMuted: string;
|
||||
}) {
|
||||
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}>
|
||||
{label}
|
||||
</Text>
|
||||
@@ -102,7 +128,7 @@ export default function MobileEventTasksPage() {
|
||||
const slug = slugParam ?? activeEvent?.slug ?? null;
|
||||
const navigate = useNavigate();
|
||||
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 [library, setLibrary] = React.useState<TenantTask[]>([]);
|
||||
const [collections, setCollections] = React.useState<TenantTaskCollection[]>([]);
|
||||
@@ -120,6 +146,7 @@ export default function MobileEventTasksPage() {
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [busyId, setBusyId] = 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 [searchTerm, setSearchTerm] = React.useState('');
|
||||
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) => {
|
||||
setNewTask({
|
||||
id: task.id,
|
||||
@@ -462,6 +496,7 @@ export default function MobileEventTasksPage() {
|
||||
text={text}
|
||||
muted={muted}
|
||||
border={border}
|
||||
surfaceMuted={surfaceMuted}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -472,23 +507,26 @@ export default function MobileEventTasksPage() {
|
||||
</Text>
|
||||
<XStack space="$2" flexWrap="wrap">
|
||||
{sectionCounts.map((section) => (
|
||||
<Pressable key={section.key} onPress={() => handleQuickNav(section.key)} style={{ flexGrow: 1 }}>
|
||||
<XStack
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
space="$1.5"
|
||||
paddingVertical="$2"
|
||||
paddingHorizontal="$3"
|
||||
borderRadius={14}
|
||||
borderWidth={1}
|
||||
borderColor={border}
|
||||
>
|
||||
<Button
|
||||
key={section.key}
|
||||
unstyled
|
||||
onPress={() => handleQuickNav(section.key)}
|
||||
borderRadius={14}
|
||||
borderWidth={1}
|
||||
borderColor={border}
|
||||
backgroundColor={surface}
|
||||
paddingVertical="$2"
|
||||
paddingHorizontal="$3"
|
||||
pressStyle={{ backgroundColor: surfaceMuted }}
|
||||
style={{ flexGrow: 1 }}
|
||||
>
|
||||
<XStack alignItems="center" justifyContent="center" space="$1.5">
|
||||
<Text fontSize="$xs" fontWeight="700" color={text}>
|
||||
{t(`events.tasks.sections.${section.key}`, section.key)}
|
||||
</Text>
|
||||
<PillBadge tone="muted">{section.count}</PillBadge>
|
||||
</XStack>
|
||||
</Pressable>
|
||||
</Button>
|
||||
))}
|
||||
</XStack>
|
||||
</YStack>
|
||||
@@ -523,11 +561,12 @@ export default function MobileEventTasksPage() {
|
||||
/>
|
||||
</XStack>
|
||||
</MobileCard>
|
||||
<YStack borderWidth={1} borderColor={border} borderRadius="$4" overflow="hidden">
|
||||
<Pressable onPress={() => setShowTaskSheet(true)}>
|
||||
<YGroup borderWidth={1} borderColor={border} borderRadius="$4" overflow="hidden">
|
||||
<YGroup.Item bordered>
|
||||
<ListItem
|
||||
hoverTheme
|
||||
pressTheme
|
||||
onPress={() => setShowTaskSheet(true)}
|
||||
title={
|
||||
<XStack alignItems="center" space="$2">
|
||||
<YStack
|
||||
@@ -554,12 +593,12 @@ export default function MobileEventTasksPage() {
|
||||
paddingHorizontal="$3"
|
||||
iconAfter={<ChevronRight size={16} color={muted} />}
|
||||
/>
|
||||
</Pressable>
|
||||
<InlineSeparator />
|
||||
<Pressable onPress={() => setShowCollectionSheet(true)}>
|
||||
</YGroup.Item>
|
||||
<YGroup.Item>
|
||||
<ListItem
|
||||
hoverTheme
|
||||
pressTheme
|
||||
onPress={() => setShowCollectionSheet(true)}
|
||||
title={
|
||||
<XStack alignItems="center" space="$2">
|
||||
<YStack
|
||||
@@ -586,8 +625,8 @@ export default function MobileEventTasksPage() {
|
||||
paddingHorizontal="$3"
|
||||
iconAfter={<ChevronRight size={16} color={muted} />}
|
||||
/>
|
||||
</Pressable>
|
||||
</YStack>
|
||||
</YGroup.Item>
|
||||
</YGroup>
|
||||
</YStack>
|
||||
) : (
|
||||
<YStack space="$2">
|
||||
@@ -622,44 +661,53 @@ export default function MobileEventTasksPage() {
|
||||
<Text fontSize="$sm" color={muted}>
|
||||
{t('events.tasks.count', '{{count}} Tasks', { count: filteredTasks.length })}
|
||||
</Text>
|
||||
<YStack borderWidth={1} borderColor={border} borderRadius="$4" overflow="hidden">
|
||||
<YGroup borderWidth={1} borderColor={border} borderRadius="$4" overflow="hidden">
|
||||
{filteredTasks.map((task, idx) => (
|
||||
<React.Fragment key={task.id}>
|
||||
<Pressable onPress={() => startEdit(task)}>
|
||||
<ListItem
|
||||
hoverTheme
|
||||
pressTheme
|
||||
title={
|
||||
<Text fontSize={12.5} fontWeight="600" color={text}>
|
||||
{task.title}
|
||||
<YGroup.Item key={task.id} bordered={idx < filteredTasks.length - 1}>
|
||||
<ListItem
|
||||
hoverTheme
|
||||
pressTheme
|
||||
onPress={() => startEdit(task)}
|
||||
title={
|
||||
<Text fontSize={12.5} fontWeight="600" color={text}>
|
||||
{task.title}
|
||||
</Text>
|
||||
}
|
||||
subTitle={
|
||||
task.description ? (
|
||||
<Text fontSize={11.5} fontWeight="400" color={subtle}>
|
||||
{task.description}
|
||||
</Text>
|
||||
}
|
||||
subTitle={
|
||||
task.description ? (
|
||||
<Text fontSize={11.5} fontWeight="400" color={subtle}>
|
||||
{task.description}
|
||||
</Text>
|
||||
) : null
|
||||
}
|
||||
iconAfter={
|
||||
<XStack space="$2" alignItems="flex-start">
|
||||
{task.emotion ? (
|
||||
<Tag label={task.emotion.name ?? ''} color={task.emotion.color ?? text} />
|
||||
) : null}
|
||||
<Pressable disabled={busyId === task.id} onPress={() => detachTask(task.id)}>
|
||||
<Trash2 size={14} color={danger} />
|
||||
</Pressable>
|
||||
<ChevronRight size={14} color={subtle} />
|
||||
</XStack>
|
||||
}
|
||||
paddingVertical="$2"
|
||||
paddingHorizontal="$3"
|
||||
/>
|
||||
</Pressable>
|
||||
{idx < assignedTasks.length - 1 ? <InlineSeparator /> : null}
|
||||
</React.Fragment>
|
||||
) : null
|
||||
}
|
||||
iconAfter={
|
||||
<XStack space="$2" alignItems="center">
|
||||
{task.emotion ? (
|
||||
<Tag label={task.emotion.name ?? ''} color={task.emotion.color ?? text} />
|
||||
) : null}
|
||||
<Button
|
||||
size="$2"
|
||||
circular
|
||||
backgroundColor={dangerBg}
|
||||
borderWidth={1}
|
||||
borderColor={`${danger}33`}
|
||||
icon={<Trash2 size={14} color={dangerText} />}
|
||||
aria-label={t('events.tasks.remove', 'Remove task')}
|
||||
disabled={busyId === task.id}
|
||||
onPress={(event) => {
|
||||
event?.stopPropagation?.();
|
||||
setDeleteCandidate(task);
|
||||
}}
|
||||
/>
|
||||
<ChevronRight size={14} color={subtle} />
|
||||
</XStack>
|
||||
}
|
||||
paddingVertical="$2"
|
||||
paddingHorizontal="$3"
|
||||
/>
|
||||
</YGroup.Item>
|
||||
))}
|
||||
</YStack>
|
||||
</YGroup>
|
||||
<XStack justifyContent="space-between" alignItems="center" marginTop="$2">
|
||||
<div ref={libraryRef} />
|
||||
<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.')}
|
||||
</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) => (
|
||||
<React.Fragment key={`lib-${task.id}`}>
|
||||
<YGroup.Item key={`lib-${task.id}`} bordered={idx < arr.length - 1}>
|
||||
<ListItem
|
||||
hoverTheme
|
||||
pressTheme
|
||||
@@ -712,10 +760,9 @@ export default function MobileEventTasksPage() {
|
||||
paddingVertical="$2"
|
||||
paddingHorizontal="$3"
|
||||
/>
|
||||
{idx < arr.length - 1 ? <InlineSeparator /> : null}
|
||||
</React.Fragment>
|
||||
</YGroup.Item>
|
||||
))}
|
||||
</YStack>
|
||||
</YGroup>
|
||||
)}
|
||||
</YStack>
|
||||
)}
|
||||
@@ -739,9 +786,9 @@ export default function MobileEventTasksPage() {
|
||||
{t('events.tasks.collectionsEmpty', 'Keine Pakete vorhanden.')}
|
||||
</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) => (
|
||||
<React.Fragment key={collection.id}>
|
||||
<YGroup.Item key={collection.id} bordered={idx < arr.length - 1}>
|
||||
<ListItem
|
||||
hoverTheme
|
||||
pressTheme
|
||||
@@ -767,13 +814,12 @@ export default function MobileEventTasksPage() {
|
||||
<ChevronRight size={14} color={subtle} />
|
||||
</XStack>
|
||||
}
|
||||
paddingVertical="$2"
|
||||
paddingHorizontal="$3"
|
||||
/>
|
||||
{idx < arr.length - 1 ? <InlineSeparator /> : null}
|
||||
</React.Fragment>
|
||||
paddingVertical="$2"
|
||||
paddingHorizontal="$3"
|
||||
/>
|
||||
</YGroup.Item>
|
||||
))}
|
||||
</YStack>
|
||||
</YGroup>
|
||||
)}
|
||||
</YStack>
|
||||
</MobileSheet>
|
||||
@@ -871,36 +917,39 @@ export default function MobileEventTasksPage() {
|
||||
style={{ padding: 0 }}
|
||||
/>
|
||||
</MobileField>
|
||||
<YStack space="$2">
|
||||
{emotions.map((em) => (
|
||||
<ListItem
|
||||
key={`emo-${em.id}`}
|
||||
hoverTheme
|
||||
pressTheme
|
||||
title={
|
||||
<XStack alignItems="center" space="$2">
|
||||
<Tag label={em.name ?? ''} color={em.color ?? border} />
|
||||
</XStack>
|
||||
}
|
||||
iconAfter={
|
||||
<XStack space="$2">
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
setEditingEmotion(em);
|
||||
setEmotionForm({ name: em.name ?? '', color: em.color ?? border });
|
||||
}}
|
||||
>
|
||||
<Pencil size={14} color={primary} />
|
||||
</Pressable>
|
||||
<Pressable onPress={() => removeEmotion(em.id)}>
|
||||
<Trash2 size={14} color={danger} />
|
||||
</Pressable>
|
||||
<ChevronRight size={14} color={subtle} />
|
||||
</XStack>
|
||||
}
|
||||
/>
|
||||
<YGroup borderWidth={1} borderColor={border} borderRadius="$4" overflow="hidden">
|
||||
{emotions.map((em, idx) => (
|
||||
<YGroup.Item key={`emo-${em.id}`} bordered={idx < emotions.length - 1}>
|
||||
<ListItem
|
||||
hoverTheme
|
||||
pressTheme
|
||||
title={
|
||||
<XStack alignItems="center" space="$2">
|
||||
<Tag label={em.name ?? ''} color={em.color ?? border} />
|
||||
</XStack>
|
||||
}
|
||||
iconAfter={
|
||||
<XStack space="$2">
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
setEditingEmotion(em);
|
||||
setEmotionForm({ name: em.name ?? '', color: em.color ?? border });
|
||||
}}
|
||||
>
|
||||
<Pencil size={14} color={primary} />
|
||||
</Pressable>
|
||||
<Pressable onPress={() => removeEmotion(em.id)}>
|
||||
<Trash2 size={14} color={danger} />
|
||||
</Pressable>
|
||||
<ChevronRight size={14} color={subtle} />
|
||||
</XStack>
|
||||
}
|
||||
paddingVertical="$2"
|
||||
paddingHorizontal="$3"
|
||||
/>
|
||||
</YGroup.Item>
|
||||
))}
|
||||
</YStack>
|
||||
</YGroup>
|
||||
</YStack>
|
||||
</MobileSheet>
|
||||
|
||||
@@ -942,24 +991,66 @@ export default function MobileEventTasksPage() {
|
||||
</RadioGroup>
|
||||
</MobileSheet>
|
||||
|
||||
<Pressable
|
||||
onPress={() => setShowFabMenu(true)}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
right: 20,
|
||||
bottom: 'calc(env(safe-area-inset-bottom, 0px) + 96px)',
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 28,
|
||||
backgroundColor: primary,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
boxShadow: '0 10px 25px rgba(0,122,255,0.35)',
|
||||
zIndex: 60,
|
||||
}}
|
||||
>
|
||||
<Plus size={20} color={surface} />
|
||||
</Pressable>
|
||||
<AlertDialog
|
||||
open={Boolean(deleteCandidate)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setDeleteCandidate(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AlertDialog.Portal>
|
||||
<AlertDialog.Overlay backgroundColor={`${overlay}66`} />
|
||||
<AlertDialog.Content
|
||||
borderRadius={20}
|
||||
borderWidth={1}
|
||||
borderColor={border}
|
||||
backgroundColor={surface}
|
||||
padding="$4"
|
||||
maxWidth={420}
|
||||
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
|
||||
open={showFabMenu}
|
||||
@@ -967,61 +1058,67 @@ export default function MobileEventTasksPage() {
|
||||
title={t('events.tasks.actions', 'Aktionen')}
|
||||
footer={null}
|
||||
>
|
||||
<YStack space="$1">
|
||||
<ListItem
|
||||
hoverTheme
|
||||
pressTheme
|
||||
title={
|
||||
<Text fontSize={12.5} fontWeight="600" color={text}>
|
||||
{t('events.tasks.addTask', 'Aufgabe hinzufügen')}
|
||||
</Text>
|
||||
}
|
||||
onPress={() => {
|
||||
setShowFabMenu(false);
|
||||
setShowTaskSheet(true);
|
||||
}}
|
||||
paddingVertical="$2"
|
||||
paddingHorizontal="$3"
|
||||
iconAfter={<ChevronRight size={14} color={subtle} />}
|
||||
/>
|
||||
<ListItem
|
||||
hoverTheme
|
||||
pressTheme
|
||||
title={
|
||||
<Text fontSize={12.5} fontWeight="600" color={text}>
|
||||
{t('events.tasks.bulkAdd', 'Bulk add')}
|
||||
</Text>
|
||||
}
|
||||
onPress={() => {
|
||||
setShowFabMenu(false);
|
||||
setShowBulkSheet(true);
|
||||
}}
|
||||
paddingVertical="$2"
|
||||
paddingHorizontal="$3"
|
||||
iconAfter={<ChevronRight size={14} color={subtle} />}
|
||||
/>
|
||||
<ListItem
|
||||
hoverTheme
|
||||
pressTheme
|
||||
title={
|
||||
<Text fontSize={12.5} fontWeight="600" color={text}>
|
||||
{t('events.tasks.manageEmotions', 'Manage emotions')}
|
||||
</Text>
|
||||
}
|
||||
subTitle={
|
||||
<Text fontSize={11.5} fontWeight="400" color={subtle}>
|
||||
{t('events.tasks.manageEmotionsHint', 'Filter and keep your taxonomy tidy.')}
|
||||
</Text>
|
||||
}
|
||||
onPress={() => {
|
||||
setShowFabMenu(false);
|
||||
setShowEmotionSheet(true);
|
||||
}}
|
||||
paddingVertical="$2"
|
||||
paddingHorizontal="$3"
|
||||
iconAfter={<ChevronRight size={14} color={subtle} />}
|
||||
/>
|
||||
</YStack>
|
||||
<YGroup borderWidth={1} borderColor={border} borderRadius="$4" overflow="hidden">
|
||||
<YGroup.Item bordered>
|
||||
<ListItem
|
||||
hoverTheme
|
||||
pressTheme
|
||||
title={
|
||||
<Text fontSize={12.5} fontWeight="600" color={text}>
|
||||
{t('events.tasks.addTask', 'Aufgabe hinzufügen')}
|
||||
</Text>
|
||||
}
|
||||
onPress={() => {
|
||||
setShowFabMenu(false);
|
||||
setShowTaskSheet(true);
|
||||
}}
|
||||
paddingVertical="$2"
|
||||
paddingHorizontal="$3"
|
||||
iconAfter={<ChevronRight size={14} color={subtle} />}
|
||||
/>
|
||||
</YGroup.Item>
|
||||
<YGroup.Item bordered>
|
||||
<ListItem
|
||||
hoverTheme
|
||||
pressTheme
|
||||
title={
|
||||
<Text fontSize={12.5} fontWeight="600" color={text}>
|
||||
{t('events.tasks.bulkAdd', 'Bulk add')}
|
||||
</Text>
|
||||
}
|
||||
onPress={() => {
|
||||
setShowFabMenu(false);
|
||||
setShowBulkSheet(true);
|
||||
}}
|
||||
paddingVertical="$2"
|
||||
paddingHorizontal="$3"
|
||||
iconAfter={<ChevronRight size={14} color={subtle} />}
|
||||
/>
|
||||
</YGroup.Item>
|
||||
<YGroup.Item>
|
||||
<ListItem
|
||||
hoverTheme
|
||||
pressTheme
|
||||
title={
|
||||
<Text fontSize={12.5} fontWeight="600" color={text}>
|
||||
{t('events.tasks.manageEmotions', 'Manage emotions')}
|
||||
</Text>
|
||||
}
|
||||
subTitle={
|
||||
<Text fontSize={11.5} fontWeight="400" color={subtle}>
|
||||
{t('events.tasks.manageEmotionsHint', 'Filter and keep your taxonomy tidy.')}
|
||||
</Text>
|
||||
}
|
||||
onPress={() => {
|
||||
setShowFabMenu(false);
|
||||
setShowEmotionSheet(true);
|
||||
}}
|
||||
paddingVertical="$2"
|
||||
paddingHorizontal="$3"
|
||||
iconAfter={<ChevronRight size={14} color={subtle} />}
|
||||
/>
|
||||
</YGroup.Item>
|
||||
</YGroup>
|
||||
</MobileSheet>
|
||||
</MobileShell>
|
||||
);
|
||||
|
||||
94
resources/js/admin/mobile/__tests__/EventFormPage.test.tsx
Normal file
94
resources/js/admin/mobile/__tests__/EventFormPage.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
85
resources/js/admin/mobile/components/FormControls.test.tsx
Normal file
85
resources/js/admin/mobile/components/FormControls.test.tsx
Normal 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' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,8 @@ import React from 'react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { Input, TextArea } from 'tamagui';
|
||||
import { Select } from '@tamagui/select';
|
||||
import { withAlpha } from './colors';
|
||||
import { useAdminTheme } from '../theme';
|
||||
|
||||
@@ -40,41 +42,41 @@ type ControlProps = {
|
||||
compact?: boolean;
|
||||
};
|
||||
|
||||
export const MobileInput = React.forwardRef<HTMLInputElement, React.ComponentPropsWithoutRef<'input'> & ControlProps>(
|
||||
function MobileInput({ hasError = false, compact = false, style, ...props }, ref) {
|
||||
const { border, surface, text, primary, danger } = useAdminTheme();
|
||||
const [focused, setFocused] = React.useState(false);
|
||||
type MobileSelectProps = React.ComponentPropsWithoutRef<'select'> & ControlProps & {
|
||||
placeholder?: string;
|
||||
containerStyle?: React.CSSProperties;
|
||||
};
|
||||
|
||||
const height = compact ? 36 : 44;
|
||||
const borderColor = hasError ? danger : focused ? primary : border;
|
||||
export const MobileInput = React.forwardRef<HTMLInputElement, React.ComponentPropsWithoutRef<'input'> & ControlProps>(
|
||||
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);
|
||||
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
<Input
|
||||
ref={ref as React.Ref<any>}
|
||||
{...props}
|
||||
onFocus={(event) => {
|
||||
setFocused(true);
|
||||
props.onFocus?.(event);
|
||||
onChangeText={(value) => {
|
||||
onChange?.({ target: { value } } as React.ChangeEvent<HTMLInputElement>);
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
setFocused(false);
|
||||
props.onBlur?.(event);
|
||||
size={compact ? '$3' : '$4'}
|
||||
height={compact ? 36 : 44}
|
||||
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={{
|
||||
width: '100%',
|
||||
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,
|
||||
hoverStyle={{
|
||||
borderColor,
|
||||
}}
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
},
|
||||
@@ -83,40 +85,35 @@ export const MobileInput = React.forwardRef<HTMLInputElement, React.ComponentPro
|
||||
export const MobileTextArea = React.forwardRef<
|
||||
HTMLTextAreaElement,
|
||||
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 [focused, setFocused] = React.useState(false);
|
||||
|
||||
const borderColor = hasError ? danger : focused ? primary : border;
|
||||
const borderColor = hasError ? danger : border;
|
||||
const ringColor = hasError ? withAlpha(danger, 0.18) : withAlpha(primary, 0.18);
|
||||
|
||||
return (
|
||||
<textarea
|
||||
ref={ref}
|
||||
<TextArea
|
||||
ref={ref as React.Ref<any>}
|
||||
{...props}
|
||||
onFocus={(event) => {
|
||||
setFocused(true);
|
||||
props.onFocus?.(event);
|
||||
onChangeText={(value) => {
|
||||
onChange?.({ target: { value } } as React.ChangeEvent<HTMLTextAreaElement>);
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
setFocused(false);
|
||||
props.onBlur?.(event);
|
||||
size={compact ? '$3' : '$4'}
|
||||
minHeight={compact ? 72 : 96}
|
||||
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={{
|
||||
width: '100%',
|
||||
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,
|
||||
hoverStyle={{
|
||||
borderColor,
|
||||
}}
|
||||
style={{ resize: 'vertical', ...style }}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -125,50 +122,92 @@ export function MobileSelect({
|
||||
children,
|
||||
hasError = false,
|
||||
compact = false,
|
||||
containerStyle,
|
||||
style,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<'select'> & ControlProps) {
|
||||
}: MobileSelectProps) {
|
||||
const { border, surface, text, primary, danger, subtle } = useAdminTheme();
|
||||
const [focused, setFocused] = React.useState(false);
|
||||
|
||||
const height = compact ? 36 : 44;
|
||||
const borderColor = hasError ? danger : focused ? primary : border;
|
||||
const borderColor = hasError ? danger : border;
|
||||
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 (
|
||||
<XStack position="relative" alignItems="center">
|
||||
<select
|
||||
{...props}
|
||||
onFocus={(event) => {
|
||||
setFocused(true);
|
||||
props.onFocus?.(event);
|
||||
}}
|
||||
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,
|
||||
<XStack position="relative" alignItems="center" width={hasSizing ? undefined : '100%'} style={containerStyle}>
|
||||
<Select
|
||||
value={selectValue}
|
||||
defaultValue={selectValue === undefined ? selectDefault : undefined}
|
||||
onValueChange={(next) => {
|
||||
props.onChange?.({ target: { value: next } } as React.ChangeEvent<HTMLSelectElement>);
|
||||
}}
|
||||
size={compact ? '$3' : '$4'}
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
<XStack position="absolute" right={12} pointerEvents="none">
|
||||
<ChevronDown size={16} color={subtle} />
|
||||
</XStack>
|
||||
<Select.Trigger
|
||||
width="100%"
|
||||
borderRadius={12}
|
||||
borderWidth={1}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -83,14 +83,18 @@ export function CTAButton({
|
||||
}: {
|
||||
label: string;
|
||||
onPress: () => void;
|
||||
tone?: 'primary' | 'ghost';
|
||||
tone?: 'primary' | 'ghost' | 'danger';
|
||||
fullWidth?: boolean;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
}) {
|
||||
const { primary, surface, border, text } = useAdminTheme();
|
||||
const { primary, surface, border, text, danger } = useAdminTheme();
|
||||
const isPrimary = tone === 'primary';
|
||||
const isDanger = tone === 'danger';
|
||||
const isDisabled = disabled || loading;
|
||||
const backgroundColor = isDanger ? danger : isPrimary ? primary : surface;
|
||||
const borderColor = isPrimary || isDanger ? 'transparent' : border;
|
||||
const labelColor = isPrimary || isDanger ? 'white' : text;
|
||||
return (
|
||||
<Pressable
|
||||
onPress={isDisabled ? undefined : onPress}
|
||||
@@ -106,11 +110,11 @@ export function CTAButton({
|
||||
borderRadius={16}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
backgroundColor={isPrimary ? primary : surface}
|
||||
borderWidth={isPrimary ? 0 : 1}
|
||||
borderColor={isPrimary ? 'transparent' : border}
|
||||
backgroundColor={backgroundColor}
|
||||
borderWidth={isPrimary || isDanger ? 0 : 1}
|
||||
borderColor={borderColor}
|
||||
>
|
||||
<Text fontSize="$sm" fontWeight="800" color={isPrimary ? 'white' : text}>
|
||||
<Text fontSize="$sm" fontWeight="800" color={labelColor}>
|
||||
{label}
|
||||
</Text>
|
||||
</XStack>
|
||||
|
||||
62
resources/js/admin/mobile/components/Sheet.test.tsx
Normal file
62
resources/js/admin/mobile/components/Sheet.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||
import { Sheet } from '@tamagui/sheet';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAdminTheme } from '../theme';
|
||||
|
||||
@@ -17,42 +18,64 @@ type SheetProps = {
|
||||
|
||||
export function MobileSheet({ open, title, onClose, children, footer, bottomOffsetPx = 88 }: SheetProps) {
|
||||
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 (
|
||||
<div className="fixed inset-0 z-50 flex items-end justify-center backdrop-blur-sm" style={{ backgroundColor: `${overlay}66` }}>
|
||||
<YStack
|
||||
<Sheet
|
||||
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%"
|
||||
maxWidth={520}
|
||||
alignSelf="center"
|
||||
borderTopLeftRadius={24}
|
||||
borderTopRightRadius={24}
|
||||
backgroundColor={surface}
|
||||
padding="$4"
|
||||
paddingBottom="$7"
|
||||
space="$3"
|
||||
shadowColor={shadow}
|
||||
shadowOpacity={0.12}
|
||||
shadowRadius={18}
|
||||
shadowOffset={{ width: 0, height: -8 }}
|
||||
maxHeight="82vh"
|
||||
overflow="auto"
|
||||
// keep sheet above bottom nav / safe area
|
||||
style={{ marginBottom: `max(env(safe-area-inset-bottom, 0px), ${bottomOffsetPx}px)` }}
|
||||
style={{ marginBottom: bottomOffset }}
|
||||
>
|
||||
<XStack alignItems="center" justifyContent="space-between">
|
||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||
{title}
|
||||
</Text>
|
||||
<Pressable onPress={onClose}>
|
||||
<Text fontSize="$md" color={muted}>
|
||||
{t('actions.close', 'Close')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</XStack>
|
||||
{children}
|
||||
{footer ? footer : null}
|
||||
</YStack>
|
||||
</div>
|
||||
<Sheet.Handle height={5} width={48} backgroundColor={border} borderRadius={999} marginBottom="$3" />
|
||||
<Sheet.ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={{ paddingBottom: 6 }}
|
||||
>
|
||||
<YStack space="$3">
|
||||
<XStack alignItems="center" justifyContent="space-between">
|
||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||
{title}
|
||||
</Text>
|
||||
<Pressable onPress={onClose}>
|
||||
<Text fontSize="$md" color={muted}>
|
||||
{t('actions.close', 'Close')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</XStack>
|
||||
{children}
|
||||
{footer ? footer : null}
|
||||
</YStack>
|
||||
</Sheet.ScrollView>
|
||||
</Sheet.Frame>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,15 @@ vi.mock('@tamagui/react-native-web-lite', () => ({
|
||||
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';
|
||||
|
||||
describe('LegalConsentSheet', () => {
|
||||
|
||||
@@ -49,6 +49,20 @@ describe('resolveOnboardingRedirect', () => {
|
||||
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', () => {
|
||||
const result = resolveOnboardingRedirect({
|
||||
hasEvents: false,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
ADMIN_EVENT_CREATE_PATH,
|
||||
ADMIN_WELCOME_BASE_PATH,
|
||||
ADMIN_WELCOME_EVENT_PATH,
|
||||
ADMIN_WELCOME_SUMMARY_PATH,
|
||||
@@ -37,6 +38,10 @@ export function resolveOnboardingRedirect({
|
||||
return null;
|
||||
}
|
||||
|
||||
if (pathname.startsWith(ADMIN_EVENT_CREATE_PATH)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const shouldContinueSummary = Boolean(selectedPackageId && selectedPackageId > 0);
|
||||
const target = hasActivePackage
|
||||
? ADMIN_WELCOME_EVENT_PATH
|
||||
|
||||
Reference in New Issue
Block a user