I finished the remaining polish so the admin app now feels fully “app‑like” across the core screens.
This commit is contained in:
@@ -286,6 +286,7 @@
|
||||
"general": "Allgemein"
|
||||
},
|
||||
"markAllRead": "Alle als gelesen markieren",
|
||||
"markScopeRead": "Als gelesen markieren",
|
||||
"markFailed": "Benachrichtigungen konnten nicht aktualisiert werden.",
|
||||
"unread": "Ungelesen"
|
||||
},
|
||||
@@ -317,6 +318,38 @@
|
||||
"draft": "Entwurf",
|
||||
"archived": "Archiviert"
|
||||
},
|
||||
"list": {
|
||||
"title": "Deine Events",
|
||||
"subtitle": "Plane besondere Momente. Verwalte alles rund um deine Events.",
|
||||
"filters": {
|
||||
"all": "Alle",
|
||||
"upcoming": "Bevorstehend",
|
||||
"draft": "Entwurf",
|
||||
"past": "Vergangen"
|
||||
},
|
||||
"empty": {
|
||||
"filtered": "Keine Events passen zu diesem Filter.",
|
||||
"filteredHint": "Wähle einen anderen Status oder lösche die Suche."
|
||||
},
|
||||
"actions": {
|
||||
"create": "Neues Event",
|
||||
"settings": "Einstellungen",
|
||||
"open": "Event öffnen"
|
||||
},
|
||||
"stats": {
|
||||
"photos": "Fotos",
|
||||
"guests": "Gäste",
|
||||
"tasks": "Tasks"
|
||||
},
|
||||
"overview": {
|
||||
"title": "Übersicht",
|
||||
"empty": "Noch keine Events – lege dein erstes an.",
|
||||
"count": "{{count}} {{count, plural, one {Event} other {Events}}} verwaltet.",
|
||||
"badge": {
|
||||
"dashboard": "Kundendashboard"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"missingSlug": "Kein Event ausgewählt.",
|
||||
"loadFailed": "Event konnte nicht geladen werden.",
|
||||
@@ -1775,6 +1808,32 @@
|
||||
"draft": "Entwurf",
|
||||
"archived": "Archiviert"
|
||||
},
|
||||
"list": {
|
||||
"title": "Deine Events",
|
||||
"subtitle": "Plane besondere Momente. Verwalte alles rund um deine Events.",
|
||||
"filters": {
|
||||
"all": "Alle",
|
||||
"upcoming": "Bevorstehend",
|
||||
"draft": "Entwurf",
|
||||
"past": "Vergangen"
|
||||
},
|
||||
"empty": {
|
||||
"filtered": "Keine Events passen zu diesem Filter.",
|
||||
"filteredHint": "Wähle einen anderen Status oder lösche die Suche."
|
||||
},
|
||||
"actions": {
|
||||
"create": "Neues Event",
|
||||
"settings": "Einstellungen"
|
||||
},
|
||||
"overview": {
|
||||
"title": "Übersicht",
|
||||
"empty": "Noch keine Events – lege dein erstes an.",
|
||||
"count": "{{count}} {{count, plural, one {Event} other {Events}}} verwaltet.",
|
||||
"badge": {
|
||||
"dashboard": "Kundendashboard"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"missingSlug": "Kein Event-Slug angegeben.",
|
||||
"missingType": "Event-Typ fehlt. Bitte speichere das Event erneut im Admin.",
|
||||
@@ -1864,6 +1923,19 @@
|
||||
"disabledTitle": "Task-Modus ist für dieses Event aus",
|
||||
"disabledBody": "Gäste sehen nur den Fotofeed. Aktiviere Tasks in den Event-Einstellungen, um sie wieder anzuzeigen.",
|
||||
"title": "Tasks & Checklisten",
|
||||
"quickNav": "Schnellzugriff",
|
||||
"sections": {
|
||||
"assigned": "Zugewiesen",
|
||||
"library": "Bibliothek",
|
||||
"collections": "Sammlungen",
|
||||
"emotions": "Emotionen"
|
||||
},
|
||||
"summary": {
|
||||
"assigned": "Zugewiesen",
|
||||
"library": "Bibliothek",
|
||||
"collections": "Sammlungen",
|
||||
"emotions": "Emotionen"
|
||||
},
|
||||
"actions": "Aktionen",
|
||||
"assigned": "Task hinzugefügt",
|
||||
"updateFailed": "Task konnte nicht gespeichert werden.",
|
||||
@@ -2077,7 +2149,9 @@
|
||||
"prompt": "Berechtigung nötig",
|
||||
"unsupported": "Nicht unterstützt",
|
||||
"persisted": "Geschützt",
|
||||
"available": "Nicht geschützt"
|
||||
"available": "Nicht geschützt",
|
||||
"online": "Online",
|
||||
"offline": "Offline"
|
||||
},
|
||||
"deviceStatus": {
|
||||
"notifications": {
|
||||
@@ -2091,6 +2165,10 @@
|
||||
"storage": {
|
||||
"label": "Offline-Speicher",
|
||||
"description": "Schützt zwischengespeicherte Daten vor Löschung."
|
||||
},
|
||||
"connection": {
|
||||
"label": "Verbindung",
|
||||
"description": "Zeigt an, ob die App online oder offline ist."
|
||||
}
|
||||
},
|
||||
"experienceTitle": "Erlebnis",
|
||||
|
||||
@@ -281,6 +281,7 @@
|
||||
"general": "General"
|
||||
},
|
||||
"markAllRead": "Mark all read",
|
||||
"markScopeRead": "Mark read",
|
||||
"markFailed": "Could not update notifications.",
|
||||
"unread": "Unread"
|
||||
},
|
||||
@@ -315,9 +316,25 @@
|
||||
"list": {
|
||||
"title": "Your events",
|
||||
"subtitle": "Plan memorable moments. Manage everything around your events here.",
|
||||
"filters": {
|
||||
"all": "All",
|
||||
"upcoming": "Upcoming",
|
||||
"draft": "Draft",
|
||||
"past": "Past"
|
||||
},
|
||||
"empty": {
|
||||
"filtered": "No events match this filter.",
|
||||
"filteredHint": "Try a different status or clear your search."
|
||||
},
|
||||
"actions": {
|
||||
"create": "New event",
|
||||
"settings": "Settings"
|
||||
"settings": "Settings",
|
||||
"open": "Open event"
|
||||
},
|
||||
"stats": {
|
||||
"photos": "Photos",
|
||||
"guests": "Guests",
|
||||
"tasks": "Tasks"
|
||||
},
|
||||
"overview": {
|
||||
"title": "Overview",
|
||||
@@ -1795,6 +1812,32 @@
|
||||
"draft": "Draft",
|
||||
"archived": "Archived"
|
||||
},
|
||||
"list": {
|
||||
"title": "Your events",
|
||||
"subtitle": "Plan memorable moments. Manage everything around your events here.",
|
||||
"filters": {
|
||||
"all": "All",
|
||||
"upcoming": "Upcoming",
|
||||
"draft": "Draft",
|
||||
"past": "Past"
|
||||
},
|
||||
"empty": {
|
||||
"filtered": "No events match this filter.",
|
||||
"filteredHint": "Try a different status or clear your search."
|
||||
},
|
||||
"actions": {
|
||||
"create": "New event",
|
||||
"settings": "Settings"
|
||||
},
|
||||
"overview": {
|
||||
"title": "Overview",
|
||||
"empty": "No events yet – create your first one to get started.",
|
||||
"count": "{{count}} {{count, plural, one {event} other {events}}} managed.",
|
||||
"badge": {
|
||||
"dashboard": "Customer dashboard"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"missingSlug": "No event slug provided.",
|
||||
"missingType": "Event type is missing. Please save the event again in the admin.",
|
||||
@@ -1884,6 +1927,19 @@
|
||||
"disabledTitle": "Task mode is off for this event",
|
||||
"disabledBody": "Guests only see the photo feed. Enable tasks in the event settings to show them again.",
|
||||
"title": "Tasks & checklists",
|
||||
"quickNav": "Quick jump",
|
||||
"sections": {
|
||||
"assigned": "Assigned",
|
||||
"library": "Library",
|
||||
"collections": "Collections",
|
||||
"emotions": "Emotions"
|
||||
},
|
||||
"summary": {
|
||||
"assigned": "Assigned",
|
||||
"library": "Library",
|
||||
"collections": "Collections",
|
||||
"emotions": "Emotions"
|
||||
},
|
||||
"actions": "Actions",
|
||||
"assigned": "Task added",
|
||||
"updateFailed": "Task could not be saved.",
|
||||
@@ -2097,7 +2153,9 @@
|
||||
"prompt": "Needs permission",
|
||||
"unsupported": "Not supported",
|
||||
"persisted": "Protected",
|
||||
"available": "Not protected"
|
||||
"available": "Not protected",
|
||||
"online": "Online",
|
||||
"offline": "Offline"
|
||||
},
|
||||
"deviceStatus": {
|
||||
"notifications": {
|
||||
@@ -2111,6 +2169,10 @@
|
||||
"storage": {
|
||||
"label": "Offline storage",
|
||||
"description": "Protect cached data from eviction."
|
||||
},
|
||||
"connection": {
|
||||
"label": "Connection",
|
||||
"description": "Shows if the app is online or offline."
|
||||
}
|
||||
},
|
||||
"experienceTitle": "Experience",
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import React from 'react';
|
||||
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Image as ImageIcon, RefreshCcw, Filter, Check } from 'lucide-react';
|
||||
import { Image as ImageIcon, RefreshCcw, Filter, Check, Eye, EyeOff } from 'lucide-react';
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { AnimatePresence, motion, useAnimationControls, type PanInfo } from 'framer-motion';
|
||||
import { MobileShell, HeaderActionButton } from './components/MobileShell';
|
||||
import { MobileCard, PillBadge, CTAButton, SkeletonCard } from './components/Primitives';
|
||||
import { MobileField, MobileInput, MobileSelect } from './components/FormControls';
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
replacePhotoQueue,
|
||||
type PhotoModerationAction,
|
||||
} from './lib/photoModerationQueue';
|
||||
import { resolvePhotoSwipeAction, type SwipeModerationAction } from './lib/photoModerationSwipe';
|
||||
import { ADMIN_EVENT_PHOTOS_PATH } from '../constants';
|
||||
|
||||
type FilterKey = 'all' | 'featured' | 'hidden' | 'pending';
|
||||
@@ -775,10 +776,14 @@ export default function MobileEventPhotosPage() {
|
||||
>
|
||||
{photos.map((photo) => {
|
||||
const isSelected = selectedIds.includes(photo.id);
|
||||
const swipeDisabled = selectionMode || busyId === photo.id;
|
||||
return (
|
||||
<Pressable
|
||||
<PhotoSwipeCard
|
||||
key={photo.id}
|
||||
onPress={() => (selectionMode ? toggleSelection(photo.id) : setLightboxWithUrl(photo.id))}
|
||||
photo={photo}
|
||||
disabled={swipeDisabled}
|
||||
onOpen={() => (selectionMode ? toggleSelection(photo.id) : setLightboxWithUrl(photo.id))}
|
||||
onModerate={(action) => handleModerationAction(action, photo)}
|
||||
>
|
||||
<YStack
|
||||
borderRadius={10}
|
||||
@@ -817,7 +822,7 @@ export default function MobileEventPhotosPage() {
|
||||
</XStack>
|
||||
) : null}
|
||||
</YStack>
|
||||
</Pressable>
|
||||
</PhotoSwipeCard>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -1087,6 +1092,153 @@ export default function MobileEventPhotosPage() {
|
||||
);
|
||||
}
|
||||
|
||||
type PhotoSwipeCardProps = {
|
||||
photo: TenantPhoto;
|
||||
disabled?: boolean;
|
||||
onOpen: () => void;
|
||||
onModerate: (action: PhotoModerationAction['action']) => void;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
type SwipeActionConfig = {
|
||||
label: string;
|
||||
bg: string;
|
||||
text: string;
|
||||
icon: typeof Eye;
|
||||
};
|
||||
|
||||
function PhotoSwipeCard({ photo, disabled = false, onOpen, onModerate, children }: PhotoSwipeCardProps) {
|
||||
const { t } = useTranslation('management');
|
||||
const theme = useTheme();
|
||||
const controls = useAnimationControls();
|
||||
const dragged = React.useRef(false);
|
||||
const leftAction = resolvePhotoSwipeAction(photo, 'left');
|
||||
const rightAction = resolvePhotoSwipeAction(photo, 'right');
|
||||
const canSwipe = !disabled && (leftAction || rightAction);
|
||||
|
||||
const resolveActionConfig = (action: SwipeModerationAction): SwipeActionConfig | null => {
|
||||
if (!action) {
|
||||
return null;
|
||||
}
|
||||
if (action === 'approve') {
|
||||
return {
|
||||
label: t('photos.actions.approve', 'Approve'),
|
||||
bg: String(theme.green3?.val ?? '#dcfce7'),
|
||||
text: String(theme.green11?.val ?? '#166534'),
|
||||
icon: Check,
|
||||
};
|
||||
}
|
||||
if (action === 'hide') {
|
||||
return {
|
||||
label: t('photos.actions.hide', 'Hide'),
|
||||
bg: String(theme.red3?.val ?? '#fee2e2'),
|
||||
text: String(theme.red11?.val ?? '#b91c1c'),
|
||||
icon: EyeOff,
|
||||
};
|
||||
}
|
||||
return {
|
||||
label: t('photos.actions.show', 'Show'),
|
||||
bg: String(theme.blue3?.val ?? '#dbeafe'),
|
||||
text: String(theme.blue11?.val ?? '#1d4ed8'),
|
||||
icon: Eye,
|
||||
};
|
||||
};
|
||||
|
||||
const leftConfig = resolveActionConfig(leftAction);
|
||||
const rightConfig = resolveActionConfig(rightAction);
|
||||
|
||||
const handleDrag = (_event: PointerEvent, info: PanInfo) => {
|
||||
if (!canSwipe) {
|
||||
return;
|
||||
}
|
||||
dragged.current = Math.abs(info.offset.x) > 6;
|
||||
};
|
||||
|
||||
const handleDragEnd = (_event: PointerEvent, info: PanInfo) => {
|
||||
if (!canSwipe) {
|
||||
return;
|
||||
}
|
||||
const swipeThreshold = 64;
|
||||
if (info.offset.x > swipeThreshold && rightAction) {
|
||||
onModerate(rightAction);
|
||||
} else if (info.offset.x < -swipeThreshold && leftAction) {
|
||||
onModerate(leftAction);
|
||||
}
|
||||
dragged.current = false;
|
||||
void controls.start({ x: 0, transition: { type: 'spring', stiffness: 320, damping: 26 } });
|
||||
};
|
||||
|
||||
const handlePress = () => {
|
||||
if (dragged.current) {
|
||||
dragged.current = false;
|
||||
return;
|
||||
}
|
||||
onOpen();
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative' }}>
|
||||
{leftConfig || rightConfig ? (
|
||||
<XStack
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
paddingHorizontal="$2"
|
||||
borderRadius="$3"
|
||||
pointerEvents="none"
|
||||
style={{ position: 'absolute', inset: 0 }}
|
||||
>
|
||||
<XStack flex={1} alignItems="center" justifyContent="flex-start">
|
||||
{rightConfig ? (
|
||||
<XStack
|
||||
alignItems="center"
|
||||
space="$1"
|
||||
paddingHorizontal="$2"
|
||||
paddingVertical="$1"
|
||||
borderRadius={999}
|
||||
backgroundColor={rightConfig.bg}
|
||||
>
|
||||
<rightConfig.icon size={12} color={rightConfig.text} />
|
||||
<Text fontSize="$xs" fontWeight="700" color={rightConfig.text}>
|
||||
{rightConfig.label}
|
||||
</Text>
|
||||
</XStack>
|
||||
) : null}
|
||||
</XStack>
|
||||
<XStack flex={1} alignItems="center" justifyContent="flex-end">
|
||||
{leftConfig ? (
|
||||
<XStack
|
||||
alignItems="center"
|
||||
space="$1"
|
||||
paddingHorizontal="$2"
|
||||
paddingVertical="$1"
|
||||
borderRadius={999}
|
||||
backgroundColor={leftConfig.bg}
|
||||
>
|
||||
<leftConfig.icon size={12} color={leftConfig.text} />
|
||||
<Text fontSize="$xs" fontWeight="700" color={leftConfig.text}>
|
||||
{leftConfig.label}
|
||||
</Text>
|
||||
</XStack>
|
||||
) : null}
|
||||
</XStack>
|
||||
</XStack>
|
||||
) : null}
|
||||
<motion.div
|
||||
drag={canSwipe ? 'x' : false}
|
||||
dragElastic={0.2}
|
||||
dragConstraints={{ left: -80, right: 80 }}
|
||||
onDrag={handleDrag}
|
||||
onDragEnd={handleDragEnd}
|
||||
animate={controls}
|
||||
initial={{ x: 0 }}
|
||||
style={{ touchAction: 'pan-y', position: 'relative', zIndex: 1 }}
|
||||
>
|
||||
<Pressable onPress={handlePress}>{children}</Pressable>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type LimitTranslator = (key: string, options?: Record<string, unknown>) => string;
|
||||
|
||||
function translateLimits(t: (key: string, defaultValue?: string, options?: Record<string, unknown>) => string): LimitTranslator {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { SizableText as Text } from '@tamagui/text';
|
||||
import { ListItem } from '@tamagui/list-item';
|
||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||
import { MobileShell, HeaderActionButton } from './components/MobileShell';
|
||||
import { MobileCard, CTAButton, SkeletonCard } from './components/Primitives';
|
||||
import { MobileCard, CTAButton, SkeletonCard, PillBadge } from './components/Primitives';
|
||||
import { MobileField, MobileInput, MobileSelect, MobileTextArea } from './components/FormControls';
|
||||
import {
|
||||
getEvent,
|
||||
@@ -39,12 +39,63 @@ import { useEventContext } from '../context/EventContext';
|
||||
import { useTheme } from '@tamagui/core';
|
||||
import { RadioGroup } from '@tamagui/radio-group';
|
||||
import { useBackNavigation } from './hooks/useBackNavigation';
|
||||
import { buildTaskSummary } from './lib/taskSummary';
|
||||
import { buildTaskSectionCounts, type TaskSectionKey } from './lib/taskSectionCounts';
|
||||
|
||||
function InlineSeparator() {
|
||||
const theme = useTheme();
|
||||
return <XStack height={1} opacity={0.7} marginLeft="$3" backgroundColor={theme.borderColor?.val ?? '#e5e7eb'} />;
|
||||
}
|
||||
|
||||
function TaskSummaryCard({
|
||||
summary,
|
||||
text,
|
||||
muted,
|
||||
border,
|
||||
}: {
|
||||
summary: ReturnType<typeof buildTaskSummary>;
|
||||
text: string;
|
||||
muted: string;
|
||||
border: 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} />
|
||||
</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} />
|
||||
</XStack>
|
||||
</MobileCard>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryItem({
|
||||
label,
|
||||
value,
|
||||
text,
|
||||
muted,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
text: string;
|
||||
muted: string;
|
||||
}) {
|
||||
return (
|
||||
<YStack flex={1} padding="$2" borderRadius={12} backgroundColor="rgba(15, 23, 42, 0.03)" space="$1">
|
||||
<Text fontSize={11} color={muted}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text fontSize={16} fontWeight="800" color={text}>
|
||||
{value}
|
||||
</Text>
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MobileEventTasksPage() {
|
||||
const { slug: slugParam } = useParams<{ slug?: string }>();
|
||||
const { activeEvent, selectEvent } = useEventContext();
|
||||
@@ -89,7 +140,16 @@ export default function MobileEventTasksPage() {
|
||||
const [emotionForm, setEmotionForm] = React.useState({ name: '', color: String(border) });
|
||||
const [savingEmotion, setSavingEmotion] = React.useState(false);
|
||||
const [showEmotionFilterSheet, setShowEmotionFilterSheet] = React.useState(false);
|
||||
const assignedRef = React.useRef<HTMLDivElement>(null);
|
||||
const libraryRef = React.useRef<HTMLDivElement>(null);
|
||||
const back = useBackNavigation(slug ? adminPath(`/mobile/events/${slug}`) : adminPath('/mobile/events'));
|
||||
const summary = buildTaskSummary({
|
||||
assigned: assignedTasks.length,
|
||||
library: library.length,
|
||||
collections: collections.length,
|
||||
emotions: emotions.length,
|
||||
});
|
||||
const sectionCounts = React.useMemo(() => buildTaskSectionCounts(summary), [summary]);
|
||||
React.useEffect(() => {
|
||||
if (slugParam && activeEvent?.slug !== slugParam) {
|
||||
selectEvent(slugParam);
|
||||
@@ -102,6 +162,28 @@ export default function MobileEventTasksPage() {
|
||||
setSearchTerm('');
|
||||
}, [slug]);
|
||||
|
||||
const scrollToSection = (ref: React.RefObject<HTMLDivElement>) => {
|
||||
if (ref.current) {
|
||||
ref.current.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleQuickNav = (key: TaskSectionKey) => {
|
||||
if (key === 'assigned') {
|
||||
scrollToSection(assignedRef);
|
||||
return;
|
||||
}
|
||||
if (key === 'library') {
|
||||
scrollToSection(libraryRef);
|
||||
return;
|
||||
}
|
||||
if (key === 'collections') {
|
||||
setShowCollectionSheet(true);
|
||||
return;
|
||||
}
|
||||
setShowEmotionSheet(true);
|
||||
};
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
if (!slug) {
|
||||
try {
|
||||
@@ -374,6 +456,44 @@ export default function MobileEventTasksPage() {
|
||||
</MobileCard>
|
||||
) : null}
|
||||
|
||||
{!loading ? (
|
||||
<TaskSummaryCard
|
||||
summary={summary}
|
||||
text={text}
|
||||
muted={muted}
|
||||
border={border}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!loading ? (
|
||||
<YStack space="$2">
|
||||
<Text fontSize={12} fontWeight="700" color={muted}>
|
||||
{t('events.tasks.quickNav', 'Quick jump')}
|
||||
</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}
|
||||
>
|
||||
<Text fontSize="$xs" fontWeight="700" color={text}>
|
||||
{t(`events.tasks.sections.${section.key}`, section.key)}
|
||||
</Text>
|
||||
<PillBadge tone="muted">{section.count}</PillBadge>
|
||||
</XStack>
|
||||
</Pressable>
|
||||
))}
|
||||
</XStack>
|
||||
</YStack>
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<YStack space="$2">
|
||||
{Array.from({ length: 4 }).map((_, idx) => (
|
||||
@@ -471,6 +591,7 @@ export default function MobileEventTasksPage() {
|
||||
</YStack>
|
||||
) : (
|
||||
<YStack space="$2">
|
||||
<div ref={assignedRef} />
|
||||
<YStack space="$2">
|
||||
<MobileInput
|
||||
type="search"
|
||||
@@ -540,6 +661,7 @@ export default function MobileEventTasksPage() {
|
||||
))}
|
||||
</YStack>
|
||||
<XStack justifyContent="space-between" alignItems="center" marginTop="$2">
|
||||
<div ref={libraryRef} />
|
||||
<Text fontSize={12.5} fontWeight="600" color={text}>
|
||||
{t('events.tasks.library', 'Weitere Aufgaben')}
|
||||
</Text>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { CalendarDays, MapPin, Plus, Search } from 'lucide-react';
|
||||
import { CalendarDays, MapPin, Plus, Search, Camera, Users, Sparkles } from 'lucide-react';
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||
@@ -14,6 +14,8 @@ import { isAuthError } from '../auth/tokens';
|
||||
import { getApiErrorMessage } from '../lib/apiError';
|
||||
import { useTheme } from '@tamagui/core';
|
||||
import { useBackNavigation } from './hooks/useBackNavigation';
|
||||
import { buildEventStatusCounts, filterEventsByStatus, resolveEventStatusKey, type EventStatusKey } from './lib/eventFilters';
|
||||
import { buildEventListStats } from './lib/eventListStats';
|
||||
|
||||
export default function MobileEventsPage() {
|
||||
const { t } = useTranslation('management');
|
||||
@@ -22,6 +24,7 @@ export default function MobileEventsPage() {
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [query, setQuery] = React.useState('');
|
||||
const [statusFilter, setStatusFilter] = React.useState<EventStatusKey>('all');
|
||||
const searchRef = React.useRef<HTMLInputElement>(null);
|
||||
const back = useBackNavigation();
|
||||
const theme = useTheme();
|
||||
@@ -92,27 +95,14 @@ export default function MobileEventsPage() {
|
||||
<CTAButton label={t('events.actions.create', 'Create New Event')} onPress={() => navigate(adminPath('/events/new'))} />
|
||||
</MobileCard>
|
||||
) : (
|
||||
<YStack space="$3">
|
||||
{events
|
||||
.filter((event) => {
|
||||
if (!query.trim()) return true;
|
||||
const hay = `${event.name ?? ''} ${event.location ?? ''}`.toLowerCase();
|
||||
return hay.includes(query.toLowerCase());
|
||||
})
|
||||
.map((event) => (
|
||||
<EventRow
|
||||
key={event.id}
|
||||
event={event}
|
||||
text={text}
|
||||
muted={muted}
|
||||
subtle={subtle}
|
||||
border={border}
|
||||
primary={primary}
|
||||
onOpen={(slug) => navigate(adminPath(`/mobile/events/${slug}`))}
|
||||
onEdit={(slug) => navigate(adminPath(`/mobile/events/${slug}/edit`))}
|
||||
/>
|
||||
))}
|
||||
</YStack>
|
||||
<EventsList
|
||||
events={events}
|
||||
query={query}
|
||||
statusFilter={statusFilter}
|
||||
onStatusChange={setStatusFilter}
|
||||
onOpen={(slug) => navigate(adminPath(`/mobile/events/${slug}`))}
|
||||
onEdit={(slug) => navigate(adminPath(`/mobile/events/${slug}/edit`))}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FloatingActionButton
|
||||
@@ -124,6 +114,127 @@ export default function MobileEventsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function EventsList({
|
||||
events,
|
||||
query,
|
||||
statusFilter,
|
||||
onStatusChange,
|
||||
onOpen,
|
||||
onEdit,
|
||||
}: {
|
||||
events: TenantEvent[];
|
||||
query: string;
|
||||
statusFilter: EventStatusKey;
|
||||
onStatusChange: (value: EventStatusKey) => void;
|
||||
onOpen: (slug: string) => void;
|
||||
onEdit: (slug: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation('management');
|
||||
const theme = useTheme();
|
||||
const text = String(theme.color?.val ?? '#111827');
|
||||
const muted = String(theme.gray?.val ?? '#4b5563');
|
||||
const subtle = String(theme.gray8?.val ?? '#6b7280');
|
||||
const border = String(theme.borderColor?.val ?? '#e5e7eb');
|
||||
const primary = String(theme.primary?.val ?? '#007AFF');
|
||||
const surface = String(theme.surface?.val ?? '#ffffff');
|
||||
const activeBg = String(theme.blue3?.val ?? '#e0f2fe');
|
||||
const activeBorder = String(theme.blue6?.val ?? '#bfdbfe');
|
||||
|
||||
const statusCounts = React.useMemo(() => buildEventStatusCounts(events), [events]);
|
||||
const filteredByStatus = React.useMemo(
|
||||
() => filterEventsByStatus(events, statusFilter),
|
||||
[events, statusFilter]
|
||||
);
|
||||
const filteredEvents = React.useMemo(() => {
|
||||
if (!query.trim()) return filteredByStatus;
|
||||
const needle = query.toLowerCase();
|
||||
return filteredByStatus.filter((event) => {
|
||||
const hay = `${event.name ?? ''} ${event.location ?? ''}`.toLowerCase();
|
||||
return hay.includes(needle);
|
||||
});
|
||||
}, [filteredByStatus, query]);
|
||||
|
||||
const filters: Array<{ key: EventStatusKey; label: string; count: number }> = [
|
||||
{ key: 'all', label: t('events.list.filters.all', 'All'), count: statusCounts.all },
|
||||
{ key: 'upcoming', label: t('events.list.filters.upcoming', 'Upcoming'), count: statusCounts.upcoming },
|
||||
{ key: 'draft', label: t('events.list.filters.draft', 'Draft'), count: statusCounts.draft },
|
||||
{ key: 'past', label: t('events.list.filters.past', 'Past'), count: statusCounts.past },
|
||||
];
|
||||
|
||||
return (
|
||||
<YStack space="$3">
|
||||
<XStack space="$2" flexWrap="wrap">
|
||||
{filters.map((filter) => {
|
||||
const active = filter.key === statusFilter;
|
||||
return (
|
||||
<Pressable key={filter.key} onPress={() => onStatusChange(filter.key)} style={{ flexGrow: 1 }}>
|
||||
<XStack
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
space="$1.5"
|
||||
paddingVertical="$2"
|
||||
paddingHorizontal="$3"
|
||||
borderRadius={14}
|
||||
backgroundColor={active ? activeBg : surface}
|
||||
borderWidth={1}
|
||||
borderColor={active ? activeBorder : border}
|
||||
>
|
||||
<Text fontSize="$xs" fontWeight="700" color={active ? primary : muted}>
|
||||
{filter.label}
|
||||
</Text>
|
||||
<PillBadge tone={active ? 'success' : 'muted'}>{filter.count}</PillBadge>
|
||||
</XStack>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</XStack>
|
||||
|
||||
{filteredEvents.length === 0 ? (
|
||||
<MobileCard alignItems="center" justifyContent="center" space="$2">
|
||||
<Text fontSize="$sm" fontWeight="700" color={text}>
|
||||
{t('events.list.empty.filtered', 'No events match this filter.')}
|
||||
</Text>
|
||||
<Text fontSize="$xs" color={muted} textAlign="center">
|
||||
{t('events.list.empty.filteredHint', 'Try a different status or clear your search.')}
|
||||
</Text>
|
||||
<CTAButton
|
||||
label={t('events.list.filters.all', 'All')}
|
||||
tone="ghost"
|
||||
fullWidth={false}
|
||||
onPress={() => onStatusChange('all')}
|
||||
/>
|
||||
</MobileCard>
|
||||
) : (
|
||||
filteredEvents.map((event) => {
|
||||
const statusKey = resolveEventStatusKey(event);
|
||||
const statusLabel =
|
||||
statusKey === 'draft'
|
||||
? t('events.list.filters.draft', 'Draft')
|
||||
: statusKey === 'past'
|
||||
? t('events.list.filters.past', 'Past')
|
||||
: t('events.list.filters.upcoming', 'Upcoming');
|
||||
const statusTone = statusKey === 'draft' ? 'warning' : statusKey === 'past' ? 'muted' : 'success';
|
||||
return (
|
||||
<EventRow
|
||||
key={event.id}
|
||||
event={event}
|
||||
text={text}
|
||||
muted={muted}
|
||||
subtle={subtle}
|
||||
border={border}
|
||||
primary={primary}
|
||||
statusLabel={statusLabel}
|
||||
statusTone={statusTone}
|
||||
onOpen={onOpen}
|
||||
onEdit={onEdit}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
|
||||
function EventRow({
|
||||
event,
|
||||
text,
|
||||
@@ -131,6 +242,8 @@ function EventRow({
|
||||
subtle,
|
||||
border,
|
||||
primary,
|
||||
statusLabel,
|
||||
statusTone,
|
||||
onOpen,
|
||||
onEdit,
|
||||
}: {
|
||||
@@ -140,10 +253,13 @@ function EventRow({
|
||||
subtle: string;
|
||||
border: string;
|
||||
primary: string;
|
||||
statusLabel: string;
|
||||
statusTone: 'success' | 'warning' | 'muted';
|
||||
onOpen: (slug: string) => void;
|
||||
onEdit: (slug: string) => void;
|
||||
}) {
|
||||
const status = resolveStatus(event);
|
||||
const { t } = useTranslation('management');
|
||||
const stats = buildEventListStats(event);
|
||||
return (
|
||||
<MobileCard borderColor={border}>
|
||||
<XStack justifyContent="space-between" alignItems="flex-start" space="$2">
|
||||
@@ -163,7 +279,27 @@ function EventRow({
|
||||
{resolveLocation(event)}
|
||||
</Text>
|
||||
</XStack>
|
||||
<PillBadge tone={status.tone}>{status.label}</PillBadge>
|
||||
<PillBadge tone={statusTone}>{statusLabel}</PillBadge>
|
||||
<XStack alignItems="center" space="$2" flexWrap="wrap">
|
||||
<EventStatChip
|
||||
icon={Camera}
|
||||
label={t('events.list.stats.photos', 'Photos')}
|
||||
value={stats.photos}
|
||||
muted={subtle}
|
||||
/>
|
||||
<EventStatChip
|
||||
icon={Users}
|
||||
label={t('events.list.stats.guests', 'Guests')}
|
||||
value={stats.guests}
|
||||
muted={subtle}
|
||||
/>
|
||||
<EventStatChip
|
||||
icon={Sparkles}
|
||||
label={t('events.list.stats.tasks', 'Tasks')}
|
||||
value={stats.tasks}
|
||||
muted={subtle}
|
||||
/>
|
||||
</XStack>
|
||||
</YStack>
|
||||
<Pressable onPress={() => onEdit(event.slug)}>
|
||||
<Text fontSize="$xl" color={muted}>
|
||||
@@ -176,7 +312,7 @@ function EventRow({
|
||||
<XStack alignItems="center" justifyContent="flex-start" space="$2">
|
||||
<Plus size={16} color={primary} />
|
||||
<Text fontSize="$sm" color={primary} fontWeight="700">
|
||||
Open event
|
||||
{t('events.list.actions.open', 'Open event')}
|
||||
</Text>
|
||||
</XStack>
|
||||
</Pressable>
|
||||
@@ -184,14 +320,25 @@ function EventRow({
|
||||
);
|
||||
}
|
||||
|
||||
function resolveStatus(event: TenantEvent): { label: string; tone: 'success' | 'warning' | 'muted' } {
|
||||
if (event.status === 'published') {
|
||||
return { label: 'Upcoming', tone: 'success' };
|
||||
}
|
||||
if (event.status === 'draft') {
|
||||
return { label: 'Draft', tone: 'warning' };
|
||||
}
|
||||
return { label: 'Past', tone: 'muted' };
|
||||
function EventStatChip({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
muted,
|
||||
}: {
|
||||
icon: typeof Camera;
|
||||
label: string;
|
||||
value: number;
|
||||
muted: string;
|
||||
}) {
|
||||
return (
|
||||
<XStack alignItems="center" space="$1">
|
||||
<Icon size={12} color={muted} />
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{value} {label}
|
||||
</Text>
|
||||
</XStack>
|
||||
);
|
||||
}
|
||||
|
||||
function renderName(name: TenantEvent['name']): string {
|
||||
|
||||
@@ -19,6 +19,9 @@ import { useTheme } from '@tamagui/core';
|
||||
import { triggerHaptic } from './lib/haptics';
|
||||
import { adminPath } from '../constants';
|
||||
import { useBackNavigation } from './hooks/useBackNavigation';
|
||||
import { groupNotificationsByScope, type NotificationScope, type NotificationGroup } from './lib/notificationGrouping';
|
||||
import { collectUnreadIds } from './lib/notificationUnread';
|
||||
import { formatRelativeTime } from './lib/relativeTime';
|
||||
|
||||
type NotificationItem = {
|
||||
id: string;
|
||||
@@ -27,8 +30,9 @@ type NotificationItem = {
|
||||
time: string;
|
||||
tone: 'info' | 'warning';
|
||||
eventId?: number | null;
|
||||
eventName?: string | null;
|
||||
is_read?: boolean;
|
||||
scope: 'photos' | 'guests' | 'gallery' | 'events' | 'package' | 'general';
|
||||
scope: NotificationScope;
|
||||
};
|
||||
|
||||
type NotificationSwipeRowProps = {
|
||||
@@ -167,6 +171,7 @@ function formatLog(
|
||||
time: log.sent_at ?? log.failed_at ?? '',
|
||||
tone: 'warning',
|
||||
eventId,
|
||||
eventName,
|
||||
is_read: isRead,
|
||||
scope,
|
||||
};
|
||||
@@ -181,6 +186,7 @@ function formatLog(
|
||||
time: log.sent_at ?? log.failed_at ?? '',
|
||||
tone: 'warning',
|
||||
eventId,
|
||||
eventName,
|
||||
is_read: isRead,
|
||||
scope,
|
||||
};
|
||||
@@ -194,6 +200,7 @@ function formatLog(
|
||||
time: log.sent_at ?? log.failed_at ?? '',
|
||||
tone: 'warning',
|
||||
eventId,
|
||||
eventName,
|
||||
is_read: isRead,
|
||||
scope,
|
||||
};
|
||||
@@ -208,6 +215,7 @@ function formatLog(
|
||||
time: log.sent_at ?? log.failed_at ?? '',
|
||||
tone: 'warning',
|
||||
eventId,
|
||||
eventName,
|
||||
is_read: isRead,
|
||||
scope,
|
||||
};
|
||||
@@ -221,6 +229,7 @@ function formatLog(
|
||||
time: log.sent_at ?? log.failed_at ?? '',
|
||||
tone: 'warning',
|
||||
eventId,
|
||||
eventName,
|
||||
is_read: isRead,
|
||||
scope,
|
||||
};
|
||||
@@ -236,6 +245,7 @@ function formatLog(
|
||||
time: log.sent_at ?? log.failed_at ?? '',
|
||||
tone: 'info',
|
||||
eventId,
|
||||
eventName,
|
||||
is_read: isRead,
|
||||
scope,
|
||||
};
|
||||
@@ -251,6 +261,7 @@ function formatLog(
|
||||
time: log.sent_at ?? log.failed_at ?? '',
|
||||
tone: 'info',
|
||||
eventId,
|
||||
eventName,
|
||||
is_read: isRead,
|
||||
scope,
|
||||
};
|
||||
@@ -262,6 +273,7 @@ function formatLog(
|
||||
time: log.sent_at ?? log.failed_at ?? '',
|
||||
tone: 'info',
|
||||
eventId,
|
||||
eventName,
|
||||
is_read: isRead,
|
||||
scope,
|
||||
};
|
||||
@@ -389,10 +401,9 @@ export default function MobileNotificationsPage() {
|
||||
return scoped.filter((item) => !item.is_read);
|
||||
}, [scoped, statusParam]);
|
||||
|
||||
const unreadIds = React.useMemo(
|
||||
() => scoped.filter((item) => !item.is_read).map((item) => Number(item.id)).filter((id) => Number.isFinite(id)),
|
||||
[scoped]
|
||||
);
|
||||
const grouped = React.useMemo(() => groupNotificationsByScope(statusFiltered), [statusFiltered]);
|
||||
|
||||
const unreadIds = React.useMemo(() => collectUnreadIds(scoped), [scoped]);
|
||||
|
||||
const showFilterNotice = Boolean(slug) && filtered.length === 0 && notifications.length > 0;
|
||||
|
||||
@@ -421,6 +432,34 @@ export default function MobileNotificationsPage() {
|
||||
}, [markNotificationRead, selectedNotification]);
|
||||
|
||||
const notificationListPath = adminPath('/mobile/notifications');
|
||||
const updateFilters = React.useCallback(
|
||||
(params: { scope?: string; status?: string; event?: string | null }) => {
|
||||
const next = new URLSearchParams({
|
||||
status: params.status ?? statusParam,
|
||||
scope: params.scope ?? scopeParam,
|
||||
event: params.event ?? slug ?? '',
|
||||
});
|
||||
navigate(`${notificationListPath}?${next.toString()}`, { replace: false });
|
||||
},
|
||||
[navigate, notificationListPath, scopeParam, slug, statusParam],
|
||||
);
|
||||
|
||||
const markGroupRead = React.useCallback(
|
||||
async (group: NotificationGroup<NotificationItem>) => {
|
||||
const ids = collectUnreadIds(group.items);
|
||||
if (!ids.length) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await markNotificationLogs(ids, 'read');
|
||||
void reload();
|
||||
triggerHaptic('success');
|
||||
} catch {
|
||||
toast.error(t('notificationLogs.markFailed', 'Could not update notifications.'));
|
||||
}
|
||||
},
|
||||
[reload, t],
|
||||
);
|
||||
|
||||
const openNotification = React.useCallback(
|
||||
(item: NotificationItem) => {
|
||||
@@ -475,7 +514,7 @@ export default function MobileNotificationsPage() {
|
||||
</Text>
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
navigate(notificationListPath, { replace: true });
|
||||
updateFilters({ event: '' });
|
||||
}}
|
||||
>
|
||||
<Text fontSize="$sm" color={primary} fontWeight="700">
|
||||
@@ -488,15 +527,7 @@ export default function MobileNotificationsPage() {
|
||||
<XStack space="$2" marginBottom="$2">
|
||||
<MobileSelect
|
||||
value={statusParam}
|
||||
onChange={(e) =>
|
||||
navigate(
|
||||
`${notificationListPath}?${new URLSearchParams({
|
||||
status: e.target.value,
|
||||
scope: scopeParam,
|
||||
event: slug ?? '',
|
||||
}).toString()}`,
|
||||
)
|
||||
}
|
||||
onChange={(e) => updateFilters({ status: e.target.value })}
|
||||
compact
|
||||
style={{ minWidth: 120 }}
|
||||
>
|
||||
@@ -504,28 +535,6 @@ export default function MobileNotificationsPage() {
|
||||
<option value="read">{t('notificationLogs.filter.read', 'Read')}</option>
|
||||
<option value="all">{t('notificationLogs.filter.all', 'All')}</option>
|
||||
</MobileSelect>
|
||||
<MobileSelect
|
||||
value={scopeParam}
|
||||
onChange={(e) =>
|
||||
navigate(
|
||||
`${notificationListPath}?${new URLSearchParams({
|
||||
scope: e.target.value,
|
||||
status: statusParam,
|
||||
event: slug ?? '',
|
||||
}).toString()}`,
|
||||
)
|
||||
}
|
||||
compact
|
||||
style={{ minWidth: 140 }}
|
||||
>
|
||||
<option value="all">{t('notificationLogs.scope.all', 'All scopes')}</option>
|
||||
<option value="photos">{t('notificationLogs.scope.photos', 'Photos')}</option>
|
||||
<option value="guests">{t('notificationLogs.scope.guests', 'Guests')}</option>
|
||||
<option value="gallery">{t('notificationLogs.scope.gallery', 'Gallery')}</option>
|
||||
<option value="events">{t('notificationLogs.scope.events', 'Events')}</option>
|
||||
<option value="package">{t('notificationLogs.scope.package', 'Package')}</option>
|
||||
<option value="general">{t('notificationLogs.scope.general', 'General')}</option>
|
||||
</MobileSelect>
|
||||
{unreadIds.length ? (
|
||||
<CTAButton
|
||||
label={t('notificationLogs.markAllRead', 'Mark all read')}
|
||||
@@ -543,6 +552,38 @@ export default function MobileNotificationsPage() {
|
||||
) : null}
|
||||
</XStack>
|
||||
|
||||
<XStack space="$2" flexWrap="wrap" marginBottom="$2">
|
||||
{([
|
||||
{ key: 'all', label: t('notificationLogs.scope.all', 'All scopes') },
|
||||
{ key: 'photos', label: t('notificationLogs.scope.photos', 'Photos') },
|
||||
{ key: 'guests', label: t('notificationLogs.scope.guests', 'Guests') },
|
||||
{ key: 'gallery', label: t('notificationLogs.scope.gallery', 'Gallery') },
|
||||
{ key: 'events', label: t('notificationLogs.scope.events', 'Events') },
|
||||
{ key: 'package', label: t('notificationLogs.scope.package', 'Package') },
|
||||
{ key: 'general', label: t('notificationLogs.scope.general', 'General') },
|
||||
] as Array<{ key: NotificationScope | 'all'; label: string }>).map((filter) => {
|
||||
const active = scopeParam === filter.key;
|
||||
return (
|
||||
<Pressable key={filter.key} onPress={() => updateFilters({ scope: filter.key })} style={{ flexGrow: 1 }}>
|
||||
<XStack
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
paddingVertical="$2"
|
||||
paddingHorizontal="$3"
|
||||
borderRadius={14}
|
||||
borderWidth={1}
|
||||
borderColor={active ? primary : border}
|
||||
backgroundColor={active ? String(theme.blue3?.val ?? '#e0f2fe') : 'transparent'}
|
||||
>
|
||||
<Text fontSize="$xs" fontWeight="700" color={active ? primary : muted}>
|
||||
{filter.label}
|
||||
</Text>
|
||||
</XStack>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</XStack>
|
||||
|
||||
{loading ? (
|
||||
<YStack space="$2">
|
||||
{Array.from({ length: 4 }).map((_, idx) => (
|
||||
@@ -574,38 +615,68 @@ export default function MobileNotificationsPage() {
|
||||
</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
{statusFiltered.map((item) => (
|
||||
<NotificationSwipeRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
onOpen={openNotification}
|
||||
onMarkRead={markNotificationRead}
|
||||
>
|
||||
<MobileCard space="$2" borderColor={item.is_read ? border : primary}>
|
||||
<XStack alignItems="center" space="$2">
|
||||
<XStack
|
||||
width={36}
|
||||
height={36}
|
||||
borderRadius={12}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
backgroundColor={item.tone === 'warning' ? warningBg : infoBg}
|
||||
>
|
||||
<Bell size={18} color={item.tone === 'warning' ? warningIcon : infoIcon} />
|
||||
</XStack>
|
||||
<YStack space="$0.5" flex={1}>
|
||||
<Text fontSize="$sm" fontWeight="700" color={text}>
|
||||
{item.title}
|
||||
</Text>
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{item.body}
|
||||
</Text>
|
||||
</YStack>
|
||||
{!item.is_read ? <PillBadge tone="warning">{t('notificationLogs.unread', 'Unread')}</PillBadge> : null}
|
||||
<PillBadge tone={item.tone === 'warning' ? 'warning' : 'muted'}>{item.time}</PillBadge>
|
||||
{grouped.map((group) => (
|
||||
<YStack key={group.scope} space="$2">
|
||||
<XStack alignItems="center" justifyContent="space-between">
|
||||
<Text fontSize="$xs" fontWeight="700" color={muted}>
|
||||
{t(`notificationLogs.scope.${group.scope}`, group.scope)}
|
||||
</Text>
|
||||
<XStack space="$2" alignItems="center">
|
||||
{group.unread > 0 ? (
|
||||
<Pressable onPress={() => markGroupRead(group)}>
|
||||
<Text fontSize="$xs" color={primary} fontWeight="700">
|
||||
{t('notificationLogs.markScopeRead', 'Mark read')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
{group.unread > 0 ? (
|
||||
<PillBadge tone="warning">
|
||||
{t('notificationLogs.unread', 'Unread')} {group.unread}
|
||||
</PillBadge>
|
||||
) : null}
|
||||
<PillBadge tone="muted">{group.items.length}</PillBadge>
|
||||
</XStack>
|
||||
</MobileCard>
|
||||
</NotificationSwipeRow>
|
||||
</XStack>
|
||||
{group.items.map((item) => {
|
||||
const formattedTime = formatRelativeTime(item.time) || item.time || '—';
|
||||
return (
|
||||
<NotificationSwipeRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
onOpen={openNotification}
|
||||
onMarkRead={markNotificationRead}
|
||||
>
|
||||
<MobileCard space="$2" borderColor={item.is_read ? border : primary}>
|
||||
<XStack alignItems="center" space="$2">
|
||||
<XStack
|
||||
width={36}
|
||||
height={36}
|
||||
borderRadius={12}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
backgroundColor={item.tone === 'warning' ? warningBg : infoBg}
|
||||
>
|
||||
<Bell size={18} color={item.tone === 'warning' ? warningIcon : infoIcon} />
|
||||
</XStack>
|
||||
<YStack space="$0.5" flex={1}>
|
||||
<Text fontSize="$sm" fontWeight="700" color={text}>
|
||||
{item.title}
|
||||
</Text>
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{item.body}
|
||||
</Text>
|
||||
{item.eventName ? (
|
||||
<PillBadge tone="muted">{item.eventName}</PillBadge>
|
||||
) : null}
|
||||
</YStack>
|
||||
{!item.is_read ? <PillBadge tone="warning">{t('notificationLogs.unread', 'Unread')}</PillBadge> : null}
|
||||
<PillBadge tone={item.tone === 'warning' ? 'warning' : 'muted'}>{formattedTime}</PillBadge>
|
||||
</XStack>
|
||||
</MobileCard>
|
||||
</NotificationSwipeRow>
|
||||
);
|
||||
})}
|
||||
</YStack>
|
||||
))}
|
||||
</YStack>
|
||||
)}
|
||||
@@ -641,7 +712,7 @@ export default function MobileNotificationsPage() {
|
||||
{!selectedNotification.is_read ? <PillBadge tone="warning">{t('notificationLogs.unread', 'Unread')}</PillBadge> : null}
|
||||
</XStack>
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{selectedNotification.time}
|
||||
{formatRelativeTime(selectedNotification.time) || selectedNotification.time || '—'}
|
||||
</Text>
|
||||
</YStack>
|
||||
) : null}
|
||||
@@ -665,7 +736,7 @@ export default function MobileNotificationsPage() {
|
||||
onPress={() => {
|
||||
setShowEventPicker(false);
|
||||
if (ev.slug) {
|
||||
navigate(`${notificationListPath}?event=${ev.slug}`);
|
||||
updateFilters({ event: ev.slug });
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -26,6 +26,7 @@ import { getInstallBannerDismissed, setInstallBannerDismissed, shouldShowInstall
|
||||
import { MobileInstallBanner } from './components/MobileInstallBanner';
|
||||
import { setTourSeen } from './lib/mobileTour';
|
||||
import { useBackNavigation } from './hooks/useBackNavigation';
|
||||
import { useOnlineStatus } from './hooks/useOnlineStatus';
|
||||
|
||||
type PreferenceKey = keyof NotificationPreferences;
|
||||
|
||||
@@ -59,6 +60,7 @@ export default function MobileSettingsPage() {
|
||||
const [storageError, setStorageError] = React.useState<string | null>(null);
|
||||
const pushState = useAdminPushSubscription();
|
||||
const devicePermissions = useDevicePermissions();
|
||||
const online = useOnlineStatus();
|
||||
const installPrompt = useInstallPrompt();
|
||||
const back = useBackNavigation(adminPath('/mobile/profile'));
|
||||
const [installBannerDismissed, setInstallBannerDismissedState] = React.useState(() => getInstallBannerDismissed());
|
||||
@@ -109,6 +111,9 @@ export default function MobileSettingsPage() {
|
||||
t(`mobileSettings.deviceStatusValues.${status}`, status);
|
||||
const storageLabel = (status: StorageStatus) =>
|
||||
t(`mobileSettings.deviceStatusValues.${status}`, status);
|
||||
const connectionLabel = online
|
||||
? t('mobileSettings.deviceStatusValues.online', 'Online')
|
||||
: t('mobileSettings.deviceStatusValues.offline', 'Offline');
|
||||
|
||||
React.useEffect(() => {
|
||||
(async () => {
|
||||
@@ -375,6 +380,17 @@ export default function MobileSettingsPage() {
|
||||
{storageLabel(devicePermissions.storage)}
|
||||
</PillBadge>
|
||||
</XStack>
|
||||
<XStack alignItems="center" justifyContent="space-between" gap="$2">
|
||||
<YStack flex={1} space="$1">
|
||||
<Text fontSize="$sm" fontWeight="700" color={text}>
|
||||
{t('mobileSettings.deviceStatus.connection.label', 'Connection')}
|
||||
</Text>
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t('mobileSettings.deviceStatus.connection.description', 'Shows if the app is online or offline.')}
|
||||
</Text>
|
||||
</YStack>
|
||||
<PillBadge tone={online ? 'success' : 'warning'}>{connectionLabel}</PillBadge>
|
||||
</XStack>
|
||||
</YStack>
|
||||
)}
|
||||
{devicePermissions.storage === 'available' ? (
|
||||
|
||||
62
resources/js/admin/mobile/lib/eventFilters.test.ts
Normal file
62
resources/js/admin/mobile/lib/eventFilters.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { TenantEvent } from '../../api';
|
||||
import { buildEventStatusCounts, filterEventsByStatus, resolveEventStatusKey } from './eventFilters';
|
||||
|
||||
const baseEvent = (overrides: Partial<TenantEvent>): TenantEvent => ({
|
||||
id: overrides.id ?? 1,
|
||||
slug: overrides.slug ?? 'event',
|
||||
name: overrides.name ?? 'Event',
|
||||
event_date: overrides.event_date ?? null,
|
||||
status: overrides.status ?? 'published',
|
||||
settings: overrides.settings ?? {},
|
||||
tasks_count: overrides.tasks_count ?? 0,
|
||||
active_invites_count: overrides.active_invites_count ?? 0,
|
||||
total_invites_count: overrides.total_invites_count ?? 0,
|
||||
photo_count: overrides.photo_count ?? 0,
|
||||
likes_sum: overrides.likes_sum ?? 0,
|
||||
engagement_mode: overrides.engagement_mode ?? 'tasks',
|
||||
});
|
||||
|
||||
describe('resolveEventStatusKey', () => {
|
||||
it('returns draft when event is draft', () => {
|
||||
const event = baseEvent({ status: 'draft' });
|
||||
expect(resolveEventStatusKey(event)).toBe('draft');
|
||||
});
|
||||
|
||||
it('returns past when published event date is in the past', () => {
|
||||
const event = baseEvent({ status: 'published', event_date: new Date(Date.now() - 86400_000).toISOString() });
|
||||
expect(resolveEventStatusKey(event)).toBe('past');
|
||||
});
|
||||
|
||||
it('returns upcoming when published event date is in the future', () => {
|
||||
const event = baseEvent({ status: 'published', event_date: new Date(Date.now() + 86400_000).toISOString() });
|
||||
expect(resolveEventStatusKey(event)).toBe('upcoming');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildEventStatusCounts', () => {
|
||||
it('counts events by status', () => {
|
||||
const events: TenantEvent[] = [
|
||||
baseEvent({ id: 1, status: 'draft' }),
|
||||
baseEvent({ id: 2, status: 'published', event_date: new Date(Date.now() + 86400_000).toISOString() }),
|
||||
baseEvent({ id: 3, status: 'published', event_date: new Date(Date.now() - 86400_000).toISOString() }),
|
||||
];
|
||||
const counts = buildEventStatusCounts(events);
|
||||
expect(counts.all).toBe(3);
|
||||
expect(counts.draft).toBe(1);
|
||||
expect(counts.upcoming).toBe(1);
|
||||
expect(counts.past).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterEventsByStatus', () => {
|
||||
it('filters by status', () => {
|
||||
const events: TenantEvent[] = [
|
||||
baseEvent({ id: 1, status: 'draft' }),
|
||||
baseEvent({ id: 2, status: 'published', event_date: new Date(Date.now() - 86400_000).toISOString() }),
|
||||
];
|
||||
const filtered = filterEventsByStatus(events, 'draft');
|
||||
expect(filtered).toHaveLength(1);
|
||||
expect(filtered[0]?.id).toBe(1);
|
||||
});
|
||||
});
|
||||
43
resources/js/admin/mobile/lib/eventFilters.ts
Normal file
43
resources/js/admin/mobile/lib/eventFilters.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { TenantEvent } from '../../api';
|
||||
import { isPastEvent } from '../eventDate';
|
||||
|
||||
export type EventStatusKey = 'all' | 'upcoming' | 'draft' | 'past';
|
||||
|
||||
export function resolveEventStatusKey(event: TenantEvent): Exclude<EventStatusKey, 'all'> {
|
||||
if (event.status === 'draft') {
|
||||
return 'draft';
|
||||
}
|
||||
if (event.status === 'published') {
|
||||
if (event.event_date && isPastEvent(event.event_date)) {
|
||||
return 'past';
|
||||
}
|
||||
return 'upcoming';
|
||||
}
|
||||
if (event.event_date && isPastEvent(event.event_date)) {
|
||||
return 'past';
|
||||
}
|
||||
return 'upcoming';
|
||||
}
|
||||
|
||||
export function buildEventStatusCounts(events: TenantEvent[]): Record<EventStatusKey, number> {
|
||||
const counts: Record<EventStatusKey, number> = {
|
||||
all: events.length,
|
||||
upcoming: 0,
|
||||
draft: 0,
|
||||
past: 0,
|
||||
};
|
||||
|
||||
events.forEach((event) => {
|
||||
const key = resolveEventStatusKey(event);
|
||||
counts[key] += 1;
|
||||
});
|
||||
|
||||
return counts;
|
||||
}
|
||||
|
||||
export function filterEventsByStatus(events: TenantEvent[], status: EventStatusKey): TenantEvent[] {
|
||||
if (status === 'all') {
|
||||
return events;
|
||||
}
|
||||
return events.filter((event) => resolveEventStatusKey(event) === status);
|
||||
}
|
||||
36
resources/js/admin/mobile/lib/eventListStats.test.ts
Normal file
36
resources/js/admin/mobile/lib/eventListStats.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { TenantEvent } from '../../api';
|
||||
import { buildEventListStats } from './eventListStats';
|
||||
|
||||
const baseEvent = (overrides: Partial<TenantEvent>): TenantEvent => ({
|
||||
id: overrides.id ?? 1,
|
||||
slug: overrides.slug ?? 'event',
|
||||
name: overrides.name ?? 'Event',
|
||||
event_date: overrides.event_date ?? null,
|
||||
status: overrides.status ?? 'published',
|
||||
settings: overrides.settings ?? {},
|
||||
tasks_count: overrides.tasks_count ?? 0,
|
||||
active_invites_count: overrides.active_invites_count ?? 0,
|
||||
total_invites_count: overrides.total_invites_count ?? 0,
|
||||
photo_count: overrides.photo_count ?? 0,
|
||||
likes_sum: overrides.likes_sum ?? 0,
|
||||
engagement_mode: overrides.engagement_mode ?? 'tasks',
|
||||
});
|
||||
|
||||
describe('buildEventListStats', () => {
|
||||
it('uses active invite count when available', () => {
|
||||
const stats = buildEventListStats(
|
||||
baseEvent({ photo_count: 12, tasks_count: 4, active_invites_count: 9, total_invites_count: 20 }),
|
||||
);
|
||||
expect(stats).toEqual({ photos: 12, guests: 9, tasks: 4 });
|
||||
});
|
||||
|
||||
it('falls back to total invite count when active is missing', () => {
|
||||
const event = {
|
||||
...baseEvent({ photo_count: 3, total_invites_count: 6 }),
|
||||
active_invites_count: undefined,
|
||||
} as TenantEvent;
|
||||
const stats = buildEventListStats(event);
|
||||
expect(stats.guests).toBe(6);
|
||||
});
|
||||
});
|
||||
15
resources/js/admin/mobile/lib/eventListStats.ts
Normal file
15
resources/js/admin/mobile/lib/eventListStats.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { TenantEvent } from '../../api';
|
||||
|
||||
export type EventListStats = {
|
||||
photos: number;
|
||||
guests: number;
|
||||
tasks: number;
|
||||
};
|
||||
|
||||
export function buildEventListStats(event: TenantEvent): EventListStats {
|
||||
return {
|
||||
photos: event.photo_count ?? 0,
|
||||
guests: event.active_invites_count ?? event.total_invites_count ?? 0,
|
||||
tasks: event.tasks_count ?? 0,
|
||||
};
|
||||
}
|
||||
33
resources/js/admin/mobile/lib/notificationGrouping.test.ts
Normal file
33
resources/js/admin/mobile/lib/notificationGrouping.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { groupNotificationsByScope, type NotificationScope } from './notificationGrouping';
|
||||
|
||||
type Item = { id: string; scope: NotificationScope; is_read?: boolean };
|
||||
|
||||
describe('groupNotificationsByScope', () => {
|
||||
it('groups items and counts unread', () => {
|
||||
const items: Item[] = [
|
||||
{ id: '1', scope: 'photos', is_read: false },
|
||||
{ id: '2', scope: 'photos', is_read: true },
|
||||
{ id: '3', scope: 'package', is_read: false },
|
||||
];
|
||||
|
||||
const grouped = groupNotificationsByScope(items);
|
||||
|
||||
expect(grouped[0]?.scope).toBe('photos');
|
||||
expect(grouped[0]?.items).toHaveLength(2);
|
||||
expect(grouped[0]?.unread).toBe(1);
|
||||
expect(grouped[1]?.scope).toBe('package');
|
||||
expect(grouped[1]?.unread).toBe(1);
|
||||
});
|
||||
|
||||
it('sorts groups by predefined scope order', () => {
|
||||
const items: Item[] = [
|
||||
{ id: '1', scope: 'general', is_read: true },
|
||||
{ id: '2', scope: 'events', is_read: true },
|
||||
{ id: '3', scope: 'photos', is_read: true },
|
||||
];
|
||||
|
||||
const grouped = groupNotificationsByScope(items);
|
||||
expect(grouped.map((group) => group.scope)).toEqual(['photos', 'events', 'general']);
|
||||
});
|
||||
});
|
||||
49
resources/js/admin/mobile/lib/notificationGrouping.ts
Normal file
49
resources/js/admin/mobile/lib/notificationGrouping.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
export type NotificationScope = 'photos' | 'guests' | 'gallery' | 'events' | 'package' | 'general';
|
||||
|
||||
export type ScopedNotification = {
|
||||
scope: NotificationScope;
|
||||
is_read?: boolean;
|
||||
};
|
||||
|
||||
export type NotificationGroup<T extends ScopedNotification> = {
|
||||
scope: NotificationScope;
|
||||
items: T[];
|
||||
unread: number;
|
||||
};
|
||||
|
||||
const SCOPE_ORDER: NotificationScope[] = [
|
||||
'photos',
|
||||
'guests',
|
||||
'gallery',
|
||||
'events',
|
||||
'package',
|
||||
'general',
|
||||
];
|
||||
|
||||
export function groupNotificationsByScope<T extends ScopedNotification>(items: T[]): NotificationGroup<T>[] {
|
||||
const groups = new Map<NotificationScope, NotificationGroup<T>>();
|
||||
|
||||
items.forEach((item) => {
|
||||
const scope = item.scope ?? 'general';
|
||||
const existing = groups.get(scope);
|
||||
if (existing) {
|
||||
existing.items.push(item);
|
||||
if (!item.is_read) {
|
||||
existing.unread += 1;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
groups.set(scope, {
|
||||
scope,
|
||||
items: [item],
|
||||
unread: item.is_read ? 0 : 1,
|
||||
});
|
||||
});
|
||||
|
||||
return Array.from(groups.values()).sort((a, b) => {
|
||||
const aIndex = SCOPE_ORDER.indexOf(a.scope);
|
||||
const bIndex = SCOPE_ORDER.indexOf(b.scope);
|
||||
return aIndex - bIndex;
|
||||
});
|
||||
}
|
||||
24
resources/js/admin/mobile/lib/notificationUnread.test.ts
Normal file
24
resources/js/admin/mobile/lib/notificationUnread.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { collectUnreadIds } from './notificationUnread';
|
||||
|
||||
describe('collectUnreadIds', () => {
|
||||
it('returns numeric ids for unread items', () => {
|
||||
const ids = collectUnreadIds([
|
||||
{ id: '12', is_read: false },
|
||||
{ id: 5, is_read: false },
|
||||
{ id: 'x', is_read: false },
|
||||
{ id: 9, is_read: true },
|
||||
]);
|
||||
|
||||
expect(ids).toEqual([12, 5]);
|
||||
});
|
||||
|
||||
it('returns empty array when all items are read', () => {
|
||||
const ids = collectUnreadIds([
|
||||
{ id: 1, is_read: true },
|
||||
{ id: '2', is_read: true },
|
||||
]);
|
||||
|
||||
expect(ids).toEqual([]);
|
||||
});
|
||||
});
|
||||
11
resources/js/admin/mobile/lib/notificationUnread.ts
Normal file
11
resources/js/admin/mobile/lib/notificationUnread.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export type NotificationReadItem = {
|
||||
id: string | number;
|
||||
is_read?: boolean;
|
||||
};
|
||||
|
||||
export function collectUnreadIds(items: NotificationReadItem[]): number[] {
|
||||
return items
|
||||
.filter((item) => !item.is_read)
|
||||
.map((item) => (typeof item.id === 'string' ? Number(item.id) : item.id))
|
||||
.filter((id) => Number.isFinite(id));
|
||||
}
|
||||
52
resources/js/admin/mobile/lib/photoModerationSwipe.test.ts
Normal file
52
resources/js/admin/mobile/lib/photoModerationSwipe.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { TenantPhoto } from '../../api';
|
||||
import { resolvePhotoSwipeAction } from './photoModerationSwipe';
|
||||
|
||||
const basePhoto = (overrides: Partial<TenantPhoto>): TenantPhoto => ({
|
||||
id: overrides.id ?? 1,
|
||||
filename: overrides.filename ?? null,
|
||||
original_name: overrides.original_name ?? null,
|
||||
mime_type: overrides.mime_type ?? null,
|
||||
size: overrides.size ?? 1024,
|
||||
url: overrides.url ?? null,
|
||||
thumbnail_url: overrides.thumbnail_url ?? null,
|
||||
status: overrides.status ?? 'approved',
|
||||
is_featured: overrides.is_featured ?? false,
|
||||
likes_count: overrides.likes_count ?? 0,
|
||||
uploaded_at: overrides.uploaded_at ?? new Date().toISOString(),
|
||||
uploader_name: overrides.uploader_name ?? null,
|
||||
ingest_source: overrides.ingest_source ?? null,
|
||||
caption: overrides.caption ?? null,
|
||||
});
|
||||
|
||||
describe('resolvePhotoSwipeAction', () => {
|
||||
it('approves pending photos on right swipe', () => {
|
||||
const photo = basePhoto({ status: 'pending' });
|
||||
expect(resolvePhotoSwipeAction(photo, 'right')).toBe('approve');
|
||||
});
|
||||
|
||||
it('hides pending photos on left swipe', () => {
|
||||
const photo = basePhoto({ status: 'pending' });
|
||||
expect(resolvePhotoSwipeAction(photo, 'left')).toBe('hide');
|
||||
});
|
||||
|
||||
it('shows hidden photos on right swipe', () => {
|
||||
const photo = basePhoto({ status: 'hidden' });
|
||||
expect(resolvePhotoSwipeAction(photo, 'right')).toBe('show');
|
||||
});
|
||||
|
||||
it('returns null for hidden photos on left swipe', () => {
|
||||
const photo = basePhoto({ status: 'hidden' });
|
||||
expect(resolvePhotoSwipeAction(photo, 'left')).toBeNull();
|
||||
});
|
||||
|
||||
it('hides visible photos on left swipe', () => {
|
||||
const photo = basePhoto({ status: 'approved' });
|
||||
expect(resolvePhotoSwipeAction(photo, 'left')).toBe('hide');
|
||||
});
|
||||
|
||||
it('returns null for visible photos on right swipe', () => {
|
||||
const photo = basePhoto({ status: 'approved' });
|
||||
expect(resolvePhotoSwipeAction(photo, 'right')).toBeNull();
|
||||
});
|
||||
});
|
||||
24
resources/js/admin/mobile/lib/photoModerationSwipe.ts
Normal file
24
resources/js/admin/mobile/lib/photoModerationSwipe.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { TenantPhoto } from '../../api';
|
||||
import type { PhotoModerationAction } from './photoModerationQueue';
|
||||
|
||||
export type SwipeDirection = 'left' | 'right';
|
||||
|
||||
export type SwipeModerationAction = PhotoModerationAction['action'] | null;
|
||||
|
||||
export function resolvePhotoSwipeAction(photo: TenantPhoto, direction: SwipeDirection): SwipeModerationAction {
|
||||
if (direction === 'right') {
|
||||
if (photo.status === 'pending') {
|
||||
return 'approve';
|
||||
}
|
||||
if (photo.status === 'hidden') {
|
||||
return 'show';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (photo.status !== 'hidden') {
|
||||
return 'hide';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
29
resources/js/admin/mobile/lib/relativeTime.test.ts
Normal file
29
resources/js/admin/mobile/lib/relativeTime.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { formatRelativeTime } from './relativeTime';
|
||||
|
||||
describe('formatRelativeTime', () => {
|
||||
const now = new Date('2024-01-10T12:00:00Z');
|
||||
|
||||
it('returns empty string when value is missing', () => {
|
||||
expect(formatRelativeTime(null, { now })).toBe('');
|
||||
});
|
||||
|
||||
it('returns original value for invalid date strings', () => {
|
||||
expect(formatRelativeTime('not-a-date', { now })).toBe('not-a-date');
|
||||
});
|
||||
|
||||
it('formats recent timestamps', () => {
|
||||
const result = formatRelativeTime('2024-01-10T11:59:30Z', { now, locale: 'en' });
|
||||
expect(result).toContain('second');
|
||||
});
|
||||
|
||||
it('formats hours ago', () => {
|
||||
const result = formatRelativeTime('2024-01-10T11:00:00Z', { now, locale: 'en' });
|
||||
expect(result).toBe('1 hour ago');
|
||||
});
|
||||
|
||||
it('formats future days', () => {
|
||||
const result = formatRelativeTime('2024-01-13T12:00:00Z', { now, locale: 'en' });
|
||||
expect(result).toBe('in 3 days');
|
||||
});
|
||||
});
|
||||
52
resources/js/admin/mobile/lib/relativeTime.ts
Normal file
52
resources/js/admin/mobile/lib/relativeTime.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
type RelativeTimeOptions = {
|
||||
now?: Date;
|
||||
locale?: string;
|
||||
};
|
||||
|
||||
export function formatRelativeTime(value: string | null | undefined, options?: RelativeTimeOptions): string {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const now = options?.now ?? new Date();
|
||||
const diffSeconds = Math.round((date.getTime() - now.getTime()) / 1000);
|
||||
const absSeconds = Math.abs(diffSeconds);
|
||||
const rtf = new Intl.RelativeTimeFormat(options?.locale, { numeric: 'auto' });
|
||||
|
||||
if (absSeconds < 60) {
|
||||
return rtf.format(diffSeconds, 'second');
|
||||
}
|
||||
|
||||
const diffMinutes = Math.round(diffSeconds / 60);
|
||||
if (Math.abs(diffMinutes) < 60) {
|
||||
return rtf.format(diffMinutes, 'minute');
|
||||
}
|
||||
|
||||
const diffHours = Math.round(diffMinutes / 60);
|
||||
if (Math.abs(diffHours) < 24) {
|
||||
return rtf.format(diffHours, 'hour');
|
||||
}
|
||||
|
||||
const diffDays = Math.round(diffHours / 24);
|
||||
if (Math.abs(diffDays) < 7) {
|
||||
return rtf.format(diffDays, 'day');
|
||||
}
|
||||
|
||||
const diffWeeks = Math.round(diffDays / 7);
|
||||
if (Math.abs(diffWeeks) < 4) {
|
||||
return rtf.format(diffWeeks, 'week');
|
||||
}
|
||||
|
||||
const diffMonths = Math.round(diffDays / 30);
|
||||
if (Math.abs(diffMonths) < 12) {
|
||||
return rtf.format(diffMonths, 'month');
|
||||
}
|
||||
|
||||
const diffYears = Math.round(diffDays / 365);
|
||||
return rtf.format(diffYears, 'year');
|
||||
}
|
||||
14
resources/js/admin/mobile/lib/taskSectionCounts.test.ts
Normal file
14
resources/js/admin/mobile/lib/taskSectionCounts.test.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildTaskSectionCounts } from './taskSectionCounts';
|
||||
|
||||
describe('buildTaskSectionCounts', () => {
|
||||
it('maps summary values in a stable order', () => {
|
||||
const counts = buildTaskSectionCounts({ assigned: 2, library: 5, collections: 3, emotions: 1 });
|
||||
expect(counts).toEqual([
|
||||
{ key: 'assigned', count: 2 },
|
||||
{ key: 'library', count: 5 },
|
||||
{ key: 'collections', count: 3 },
|
||||
{ key: 'emotions', count: 1 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
17
resources/js/admin/mobile/lib/taskSectionCounts.ts
Normal file
17
resources/js/admin/mobile/lib/taskSectionCounts.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { TaskSummary } from './taskSummary';
|
||||
|
||||
export type TaskSectionKey = 'assigned' | 'library' | 'collections' | 'emotions';
|
||||
|
||||
export type TaskSectionCount = {
|
||||
key: TaskSectionKey;
|
||||
count: number;
|
||||
};
|
||||
|
||||
export function buildTaskSectionCounts(summary: TaskSummary): TaskSectionCount[] {
|
||||
return [
|
||||
{ key: 'assigned', count: summary.assigned },
|
||||
{ key: 'library', count: summary.library },
|
||||
{ key: 'collections', count: summary.collections },
|
||||
{ key: 'emotions', count: summary.emotions },
|
||||
];
|
||||
}
|
||||
9
resources/js/admin/mobile/lib/taskSummary.test.ts
Normal file
9
resources/js/admin/mobile/lib/taskSummary.test.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildTaskSummary } from './taskSummary';
|
||||
|
||||
describe('buildTaskSummary', () => {
|
||||
it('returns summary counts', () => {
|
||||
const summary = buildTaskSummary({ assigned: 3, library: 5, collections: 2, emotions: 4 });
|
||||
expect(summary).toEqual({ assigned: 3, library: 5, collections: 2, emotions: 4 });
|
||||
});
|
||||
});
|
||||
20
resources/js/admin/mobile/lib/taskSummary.ts
Normal file
20
resources/js/admin/mobile/lib/taskSummary.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export type TaskSummary = {
|
||||
assigned: number;
|
||||
library: number;
|
||||
collections: number;
|
||||
emotions: number;
|
||||
};
|
||||
|
||||
export function buildTaskSummary(params: {
|
||||
assigned: number;
|
||||
library: number;
|
||||
collections: number;
|
||||
emotions: number;
|
||||
}): TaskSummary {
|
||||
return {
|
||||
assigned: params.assigned,
|
||||
library: params.library,
|
||||
collections: params.collections,
|
||||
emotions: params.emotions,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user