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"
|
"general": "Allgemein"
|
||||||
},
|
},
|
||||||
"markAllRead": "Alle als gelesen markieren",
|
"markAllRead": "Alle als gelesen markieren",
|
||||||
|
"markScopeRead": "Als gelesen markieren",
|
||||||
"markFailed": "Benachrichtigungen konnten nicht aktualisiert werden.",
|
"markFailed": "Benachrichtigungen konnten nicht aktualisiert werden.",
|
||||||
"unread": "Ungelesen"
|
"unread": "Ungelesen"
|
||||||
},
|
},
|
||||||
@@ -317,6 +318,38 @@
|
|||||||
"draft": "Entwurf",
|
"draft": "Entwurf",
|
||||||
"archived": "Archiviert"
|
"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": {
|
"errors": {
|
||||||
"missingSlug": "Kein Event ausgewählt.",
|
"missingSlug": "Kein Event ausgewählt.",
|
||||||
"loadFailed": "Event konnte nicht geladen werden.",
|
"loadFailed": "Event konnte nicht geladen werden.",
|
||||||
@@ -1775,6 +1808,32 @@
|
|||||||
"draft": "Entwurf",
|
"draft": "Entwurf",
|
||||||
"archived": "Archiviert"
|
"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": {
|
"errors": {
|
||||||
"missingSlug": "Kein Event-Slug angegeben.",
|
"missingSlug": "Kein Event-Slug angegeben.",
|
||||||
"missingType": "Event-Typ fehlt. Bitte speichere das Event erneut im Admin.",
|
"missingType": "Event-Typ fehlt. Bitte speichere das Event erneut im Admin.",
|
||||||
@@ -1864,6 +1923,19 @@
|
|||||||
"disabledTitle": "Task-Modus ist für dieses Event aus",
|
"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.",
|
"disabledBody": "Gäste sehen nur den Fotofeed. Aktiviere Tasks in den Event-Einstellungen, um sie wieder anzuzeigen.",
|
||||||
"title": "Tasks & Checklisten",
|
"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",
|
"actions": "Aktionen",
|
||||||
"assigned": "Task hinzugefügt",
|
"assigned": "Task hinzugefügt",
|
||||||
"updateFailed": "Task konnte nicht gespeichert werden.",
|
"updateFailed": "Task konnte nicht gespeichert werden.",
|
||||||
@@ -2077,7 +2149,9 @@
|
|||||||
"prompt": "Berechtigung nötig",
|
"prompt": "Berechtigung nötig",
|
||||||
"unsupported": "Nicht unterstützt",
|
"unsupported": "Nicht unterstützt",
|
||||||
"persisted": "Geschützt",
|
"persisted": "Geschützt",
|
||||||
"available": "Nicht geschützt"
|
"available": "Nicht geschützt",
|
||||||
|
"online": "Online",
|
||||||
|
"offline": "Offline"
|
||||||
},
|
},
|
||||||
"deviceStatus": {
|
"deviceStatus": {
|
||||||
"notifications": {
|
"notifications": {
|
||||||
@@ -2091,6 +2165,10 @@
|
|||||||
"storage": {
|
"storage": {
|
||||||
"label": "Offline-Speicher",
|
"label": "Offline-Speicher",
|
||||||
"description": "Schützt zwischengespeicherte Daten vor Löschung."
|
"description": "Schützt zwischengespeicherte Daten vor Löschung."
|
||||||
|
},
|
||||||
|
"connection": {
|
||||||
|
"label": "Verbindung",
|
||||||
|
"description": "Zeigt an, ob die App online oder offline ist."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"experienceTitle": "Erlebnis",
|
"experienceTitle": "Erlebnis",
|
||||||
|
|||||||
@@ -281,6 +281,7 @@
|
|||||||
"general": "General"
|
"general": "General"
|
||||||
},
|
},
|
||||||
"markAllRead": "Mark all read",
|
"markAllRead": "Mark all read",
|
||||||
|
"markScopeRead": "Mark read",
|
||||||
"markFailed": "Could not update notifications.",
|
"markFailed": "Could not update notifications.",
|
||||||
"unread": "Unread"
|
"unread": "Unread"
|
||||||
},
|
},
|
||||||
@@ -315,9 +316,25 @@
|
|||||||
"list": {
|
"list": {
|
||||||
"title": "Your events",
|
"title": "Your events",
|
||||||
"subtitle": "Plan memorable moments. Manage everything around your events here.",
|
"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": {
|
"actions": {
|
||||||
"create": "New event",
|
"create": "New event",
|
||||||
"settings": "Settings"
|
"settings": "Settings",
|
||||||
|
"open": "Open event"
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"photos": "Photos",
|
||||||
|
"guests": "Guests",
|
||||||
|
"tasks": "Tasks"
|
||||||
},
|
},
|
||||||
"overview": {
|
"overview": {
|
||||||
"title": "Overview",
|
"title": "Overview",
|
||||||
@@ -1795,6 +1812,32 @@
|
|||||||
"draft": "Draft",
|
"draft": "Draft",
|
||||||
"archived": "Archived"
|
"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": {
|
"errors": {
|
||||||
"missingSlug": "No event slug provided.",
|
"missingSlug": "No event slug provided.",
|
||||||
"missingType": "Event type is missing. Please save the event again in the admin.",
|
"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",
|
"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.",
|
"disabledBody": "Guests only see the photo feed. Enable tasks in the event settings to show them again.",
|
||||||
"title": "Tasks & checklists",
|
"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",
|
"actions": "Actions",
|
||||||
"assigned": "Task added",
|
"assigned": "Task added",
|
||||||
"updateFailed": "Task could not be saved.",
|
"updateFailed": "Task could not be saved.",
|
||||||
@@ -2097,7 +2153,9 @@
|
|||||||
"prompt": "Needs permission",
|
"prompt": "Needs permission",
|
||||||
"unsupported": "Not supported",
|
"unsupported": "Not supported",
|
||||||
"persisted": "Protected",
|
"persisted": "Protected",
|
||||||
"available": "Not protected"
|
"available": "Not protected",
|
||||||
|
"online": "Online",
|
||||||
|
"offline": "Offline"
|
||||||
},
|
},
|
||||||
"deviceStatus": {
|
"deviceStatus": {
|
||||||
"notifications": {
|
"notifications": {
|
||||||
@@ -2111,6 +2169,10 @@
|
|||||||
"storage": {
|
"storage": {
|
||||||
"label": "Offline storage",
|
"label": "Offline storage",
|
||||||
"description": "Protect cached data from eviction."
|
"description": "Protect cached data from eviction."
|
||||||
|
},
|
||||||
|
"connection": {
|
||||||
|
"label": "Connection",
|
||||||
|
"description": "Shows if the app is online or offline."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"experienceTitle": "Experience",
|
"experienceTitle": "Experience",
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 { YStack, XStack } from '@tamagui/stacks';
|
||||||
import { SizableText as Text } from '@tamagui/text';
|
import { SizableText as Text } from '@tamagui/text';
|
||||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
import { AnimatePresence, motion, useAnimationControls, type PanInfo } from 'framer-motion';
|
||||||
import { MobileShell, HeaderActionButton } from './components/MobileShell';
|
import { MobileShell, HeaderActionButton } from './components/MobileShell';
|
||||||
import { MobileCard, PillBadge, CTAButton, SkeletonCard } from './components/Primitives';
|
import { MobileCard, PillBadge, CTAButton, SkeletonCard } from './components/Primitives';
|
||||||
import { MobileField, MobileInput, MobileSelect } from './components/FormControls';
|
import { MobileField, MobileInput, MobileSelect } from './components/FormControls';
|
||||||
@@ -44,6 +44,7 @@ import {
|
|||||||
replacePhotoQueue,
|
replacePhotoQueue,
|
||||||
type PhotoModerationAction,
|
type PhotoModerationAction,
|
||||||
} from './lib/photoModerationQueue';
|
} from './lib/photoModerationQueue';
|
||||||
|
import { resolvePhotoSwipeAction, type SwipeModerationAction } from './lib/photoModerationSwipe';
|
||||||
import { ADMIN_EVENT_PHOTOS_PATH } from '../constants';
|
import { ADMIN_EVENT_PHOTOS_PATH } from '../constants';
|
||||||
|
|
||||||
type FilterKey = 'all' | 'featured' | 'hidden' | 'pending';
|
type FilterKey = 'all' | 'featured' | 'hidden' | 'pending';
|
||||||
@@ -775,10 +776,14 @@ export default function MobileEventPhotosPage() {
|
|||||||
>
|
>
|
||||||
{photos.map((photo) => {
|
{photos.map((photo) => {
|
||||||
const isSelected = selectedIds.includes(photo.id);
|
const isSelected = selectedIds.includes(photo.id);
|
||||||
|
const swipeDisabled = selectionMode || busyId === photo.id;
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<PhotoSwipeCard
|
||||||
key={photo.id}
|
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
|
<YStack
|
||||||
borderRadius={10}
|
borderRadius={10}
|
||||||
@@ -817,7 +822,7 @@ export default function MobileEventPhotosPage() {
|
|||||||
</XStack>
|
</XStack>
|
||||||
) : null}
|
) : null}
|
||||||
</YStack>
|
</YStack>
|
||||||
</Pressable>
|
</PhotoSwipeCard>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</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;
|
type LimitTranslator = (key: string, options?: Record<string, unknown>) => string;
|
||||||
|
|
||||||
function translateLimits(t: (key: string, defaultValue?: string, options?: Record<string, unknown>) => string): LimitTranslator {
|
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 { ListItem } from '@tamagui/list-item';
|
||||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||||
import { MobileShell, HeaderActionButton } from './components/MobileShell';
|
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 { MobileField, MobileInput, MobileSelect, MobileTextArea } from './components/FormControls';
|
||||||
import {
|
import {
|
||||||
getEvent,
|
getEvent,
|
||||||
@@ -39,12 +39,63 @@ import { useEventContext } from '../context/EventContext';
|
|||||||
import { useTheme } from '@tamagui/core';
|
import { useTheme } from '@tamagui/core';
|
||||||
import { RadioGroup } from '@tamagui/radio-group';
|
import { RadioGroup } from '@tamagui/radio-group';
|
||||||
import { useBackNavigation } from './hooks/useBackNavigation';
|
import { useBackNavigation } from './hooks/useBackNavigation';
|
||||||
|
import { buildTaskSummary } from './lib/taskSummary';
|
||||||
|
import { buildTaskSectionCounts, type TaskSectionKey } from './lib/taskSectionCounts';
|
||||||
|
|
||||||
function InlineSeparator() {
|
function InlineSeparator() {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
return <XStack height={1} opacity={0.7} marginLeft="$3" backgroundColor={theme.borderColor?.val ?? '#e5e7eb'} />;
|
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() {
|
export default function MobileEventTasksPage() {
|
||||||
const { slug: slugParam } = useParams<{ slug?: string }>();
|
const { slug: slugParam } = useParams<{ slug?: string }>();
|
||||||
const { activeEvent, selectEvent } = useEventContext();
|
const { activeEvent, selectEvent } = useEventContext();
|
||||||
@@ -89,7 +140,16 @@ export default function MobileEventTasksPage() {
|
|||||||
const [emotionForm, setEmotionForm] = React.useState({ name: '', color: String(border) });
|
const [emotionForm, setEmotionForm] = React.useState({ name: '', color: String(border) });
|
||||||
const [savingEmotion, setSavingEmotion] = React.useState(false);
|
const [savingEmotion, setSavingEmotion] = React.useState(false);
|
||||||
const [showEmotionFilterSheet, setShowEmotionFilterSheet] = 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 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(() => {
|
React.useEffect(() => {
|
||||||
if (slugParam && activeEvent?.slug !== slugParam) {
|
if (slugParam && activeEvent?.slug !== slugParam) {
|
||||||
selectEvent(slugParam);
|
selectEvent(slugParam);
|
||||||
@@ -102,6 +162,28 @@ export default function MobileEventTasksPage() {
|
|||||||
setSearchTerm('');
|
setSearchTerm('');
|
||||||
}, [slug]);
|
}, [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 () => {
|
const load = React.useCallback(async () => {
|
||||||
if (!slug) {
|
if (!slug) {
|
||||||
try {
|
try {
|
||||||
@@ -374,6 +456,44 @@ export default function MobileEventTasksPage() {
|
|||||||
</MobileCard>
|
</MobileCard>
|
||||||
) : null}
|
) : 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 ? (
|
{loading ? (
|
||||||
<YStack space="$2">
|
<YStack space="$2">
|
||||||
{Array.from({ length: 4 }).map((_, idx) => (
|
{Array.from({ length: 4 }).map((_, idx) => (
|
||||||
@@ -471,6 +591,7 @@ export default function MobileEventTasksPage() {
|
|||||||
</YStack>
|
</YStack>
|
||||||
) : (
|
) : (
|
||||||
<YStack space="$2">
|
<YStack space="$2">
|
||||||
|
<div ref={assignedRef} />
|
||||||
<YStack space="$2">
|
<YStack space="$2">
|
||||||
<MobileInput
|
<MobileInput
|
||||||
type="search"
|
type="search"
|
||||||
@@ -540,6 +661,7 @@ export default function MobileEventTasksPage() {
|
|||||||
))}
|
))}
|
||||||
</YStack>
|
</YStack>
|
||||||
<XStack justifyContent="space-between" alignItems="center" marginTop="$2">
|
<XStack justifyContent="space-between" alignItems="center" marginTop="$2">
|
||||||
|
<div ref={libraryRef} />
|
||||||
<Text fontSize={12.5} fontWeight="600" color={text}>
|
<Text fontSize={12.5} fontWeight="600" color={text}>
|
||||||
{t('events.tasks.library', 'Weitere Aufgaben')}
|
{t('events.tasks.library', 'Weitere Aufgaben')}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
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 { YStack, XStack } from '@tamagui/stacks';
|
||||||
import { SizableText as Text } from '@tamagui/text';
|
import { SizableText as Text } from '@tamagui/text';
|
||||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||||
@@ -14,6 +14,8 @@ import { isAuthError } from '../auth/tokens';
|
|||||||
import { getApiErrorMessage } from '../lib/apiError';
|
import { getApiErrorMessage } from '../lib/apiError';
|
||||||
import { useTheme } from '@tamagui/core';
|
import { useTheme } from '@tamagui/core';
|
||||||
import { useBackNavigation } from './hooks/useBackNavigation';
|
import { useBackNavigation } from './hooks/useBackNavigation';
|
||||||
|
import { buildEventStatusCounts, filterEventsByStatus, resolveEventStatusKey, type EventStatusKey } from './lib/eventFilters';
|
||||||
|
import { buildEventListStats } from './lib/eventListStats';
|
||||||
|
|
||||||
export default function MobileEventsPage() {
|
export default function MobileEventsPage() {
|
||||||
const { t } = useTranslation('management');
|
const { t } = useTranslation('management');
|
||||||
@@ -22,6 +24,7 @@ export default function MobileEventsPage() {
|
|||||||
const [loading, setLoading] = React.useState(true);
|
const [loading, setLoading] = React.useState(true);
|
||||||
const [error, setError] = React.useState<string | null>(null);
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
const [query, setQuery] = React.useState('');
|
const [query, setQuery] = React.useState('');
|
||||||
|
const [statusFilter, setStatusFilter] = React.useState<EventStatusKey>('all');
|
||||||
const searchRef = React.useRef<HTMLInputElement>(null);
|
const searchRef = React.useRef<HTMLInputElement>(null);
|
||||||
const back = useBackNavigation();
|
const back = useBackNavigation();
|
||||||
const theme = useTheme();
|
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'))} />
|
<CTAButton label={t('events.actions.create', 'Create New Event')} onPress={() => navigate(adminPath('/events/new'))} />
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
) : (
|
) : (
|
||||||
<YStack space="$3">
|
<EventsList
|
||||||
{events
|
events={events}
|
||||||
.filter((event) => {
|
query={query}
|
||||||
if (!query.trim()) return true;
|
statusFilter={statusFilter}
|
||||||
const hay = `${event.name ?? ''} ${event.location ?? ''}`.toLowerCase();
|
onStatusChange={setStatusFilter}
|
||||||
return hay.includes(query.toLowerCase());
|
onOpen={(slug) => navigate(adminPath(`/mobile/events/${slug}`))}
|
||||||
})
|
onEdit={(slug) => navigate(adminPath(`/mobile/events/${slug}/edit`))}
|
||||||
.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>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<FloatingActionButton
|
<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({
|
function EventRow({
|
||||||
event,
|
event,
|
||||||
text,
|
text,
|
||||||
@@ -131,6 +242,8 @@ function EventRow({
|
|||||||
subtle,
|
subtle,
|
||||||
border,
|
border,
|
||||||
primary,
|
primary,
|
||||||
|
statusLabel,
|
||||||
|
statusTone,
|
||||||
onOpen,
|
onOpen,
|
||||||
onEdit,
|
onEdit,
|
||||||
}: {
|
}: {
|
||||||
@@ -140,10 +253,13 @@ function EventRow({
|
|||||||
subtle: string;
|
subtle: string;
|
||||||
border: string;
|
border: string;
|
||||||
primary: string;
|
primary: string;
|
||||||
|
statusLabel: string;
|
||||||
|
statusTone: 'success' | 'warning' | 'muted';
|
||||||
onOpen: (slug: string) => void;
|
onOpen: (slug: string) => void;
|
||||||
onEdit: (slug: string) => void;
|
onEdit: (slug: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const status = resolveStatus(event);
|
const { t } = useTranslation('management');
|
||||||
|
const stats = buildEventListStats(event);
|
||||||
return (
|
return (
|
||||||
<MobileCard borderColor={border}>
|
<MobileCard borderColor={border}>
|
||||||
<XStack justifyContent="space-between" alignItems="flex-start" space="$2">
|
<XStack justifyContent="space-between" alignItems="flex-start" space="$2">
|
||||||
@@ -163,7 +279,27 @@ function EventRow({
|
|||||||
{resolveLocation(event)}
|
{resolveLocation(event)}
|
||||||
</Text>
|
</Text>
|
||||||
</XStack>
|
</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>
|
</YStack>
|
||||||
<Pressable onPress={() => onEdit(event.slug)}>
|
<Pressable onPress={() => onEdit(event.slug)}>
|
||||||
<Text fontSize="$xl" color={muted}>
|
<Text fontSize="$xl" color={muted}>
|
||||||
@@ -176,7 +312,7 @@ function EventRow({
|
|||||||
<XStack alignItems="center" justifyContent="flex-start" space="$2">
|
<XStack alignItems="center" justifyContent="flex-start" space="$2">
|
||||||
<Plus size={16} color={primary} />
|
<Plus size={16} color={primary} />
|
||||||
<Text fontSize="$sm" color={primary} fontWeight="700">
|
<Text fontSize="$sm" color={primary} fontWeight="700">
|
||||||
Open event
|
{t('events.list.actions.open', 'Open event')}
|
||||||
</Text>
|
</Text>
|
||||||
</XStack>
|
</XStack>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
@@ -184,14 +320,25 @@ function EventRow({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveStatus(event: TenantEvent): { label: string; tone: 'success' | 'warning' | 'muted' } {
|
function EventStatChip({
|
||||||
if (event.status === 'published') {
|
icon: Icon,
|
||||||
return { label: 'Upcoming', tone: 'success' };
|
label,
|
||||||
}
|
value,
|
||||||
if (event.status === 'draft') {
|
muted,
|
||||||
return { label: 'Draft', tone: 'warning' };
|
}: {
|
||||||
}
|
icon: typeof Camera;
|
||||||
return { label: 'Past', tone: 'muted' };
|
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 {
|
function renderName(name: TenantEvent['name']): string {
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ import { useTheme } from '@tamagui/core';
|
|||||||
import { triggerHaptic } from './lib/haptics';
|
import { triggerHaptic } from './lib/haptics';
|
||||||
import { adminPath } from '../constants';
|
import { adminPath } from '../constants';
|
||||||
import { useBackNavigation } from './hooks/useBackNavigation';
|
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 = {
|
type NotificationItem = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -27,8 +30,9 @@ type NotificationItem = {
|
|||||||
time: string;
|
time: string;
|
||||||
tone: 'info' | 'warning';
|
tone: 'info' | 'warning';
|
||||||
eventId?: number | null;
|
eventId?: number | null;
|
||||||
|
eventName?: string | null;
|
||||||
is_read?: boolean;
|
is_read?: boolean;
|
||||||
scope: 'photos' | 'guests' | 'gallery' | 'events' | 'package' | 'general';
|
scope: NotificationScope;
|
||||||
};
|
};
|
||||||
|
|
||||||
type NotificationSwipeRowProps = {
|
type NotificationSwipeRowProps = {
|
||||||
@@ -167,6 +171,7 @@ function formatLog(
|
|||||||
time: log.sent_at ?? log.failed_at ?? '',
|
time: log.sent_at ?? log.failed_at ?? '',
|
||||||
tone: 'warning',
|
tone: 'warning',
|
||||||
eventId,
|
eventId,
|
||||||
|
eventName,
|
||||||
is_read: isRead,
|
is_read: isRead,
|
||||||
scope,
|
scope,
|
||||||
};
|
};
|
||||||
@@ -181,6 +186,7 @@ function formatLog(
|
|||||||
time: log.sent_at ?? log.failed_at ?? '',
|
time: log.sent_at ?? log.failed_at ?? '',
|
||||||
tone: 'warning',
|
tone: 'warning',
|
||||||
eventId,
|
eventId,
|
||||||
|
eventName,
|
||||||
is_read: isRead,
|
is_read: isRead,
|
||||||
scope,
|
scope,
|
||||||
};
|
};
|
||||||
@@ -194,6 +200,7 @@ function formatLog(
|
|||||||
time: log.sent_at ?? log.failed_at ?? '',
|
time: log.sent_at ?? log.failed_at ?? '',
|
||||||
tone: 'warning',
|
tone: 'warning',
|
||||||
eventId,
|
eventId,
|
||||||
|
eventName,
|
||||||
is_read: isRead,
|
is_read: isRead,
|
||||||
scope,
|
scope,
|
||||||
};
|
};
|
||||||
@@ -208,6 +215,7 @@ function formatLog(
|
|||||||
time: log.sent_at ?? log.failed_at ?? '',
|
time: log.sent_at ?? log.failed_at ?? '',
|
||||||
tone: 'warning',
|
tone: 'warning',
|
||||||
eventId,
|
eventId,
|
||||||
|
eventName,
|
||||||
is_read: isRead,
|
is_read: isRead,
|
||||||
scope,
|
scope,
|
||||||
};
|
};
|
||||||
@@ -221,6 +229,7 @@ function formatLog(
|
|||||||
time: log.sent_at ?? log.failed_at ?? '',
|
time: log.sent_at ?? log.failed_at ?? '',
|
||||||
tone: 'warning',
|
tone: 'warning',
|
||||||
eventId,
|
eventId,
|
||||||
|
eventName,
|
||||||
is_read: isRead,
|
is_read: isRead,
|
||||||
scope,
|
scope,
|
||||||
};
|
};
|
||||||
@@ -236,6 +245,7 @@ function formatLog(
|
|||||||
time: log.sent_at ?? log.failed_at ?? '',
|
time: log.sent_at ?? log.failed_at ?? '',
|
||||||
tone: 'info',
|
tone: 'info',
|
||||||
eventId,
|
eventId,
|
||||||
|
eventName,
|
||||||
is_read: isRead,
|
is_read: isRead,
|
||||||
scope,
|
scope,
|
||||||
};
|
};
|
||||||
@@ -251,6 +261,7 @@ function formatLog(
|
|||||||
time: log.sent_at ?? log.failed_at ?? '',
|
time: log.sent_at ?? log.failed_at ?? '',
|
||||||
tone: 'info',
|
tone: 'info',
|
||||||
eventId,
|
eventId,
|
||||||
|
eventName,
|
||||||
is_read: isRead,
|
is_read: isRead,
|
||||||
scope,
|
scope,
|
||||||
};
|
};
|
||||||
@@ -262,6 +273,7 @@ function formatLog(
|
|||||||
time: log.sent_at ?? log.failed_at ?? '',
|
time: log.sent_at ?? log.failed_at ?? '',
|
||||||
tone: 'info',
|
tone: 'info',
|
||||||
eventId,
|
eventId,
|
||||||
|
eventName,
|
||||||
is_read: isRead,
|
is_read: isRead,
|
||||||
scope,
|
scope,
|
||||||
};
|
};
|
||||||
@@ -389,10 +401,9 @@ export default function MobileNotificationsPage() {
|
|||||||
return scoped.filter((item) => !item.is_read);
|
return scoped.filter((item) => !item.is_read);
|
||||||
}, [scoped, statusParam]);
|
}, [scoped, statusParam]);
|
||||||
|
|
||||||
const unreadIds = React.useMemo(
|
const grouped = React.useMemo(() => groupNotificationsByScope(statusFiltered), [statusFiltered]);
|
||||||
() => scoped.filter((item) => !item.is_read).map((item) => Number(item.id)).filter((id) => Number.isFinite(id)),
|
|
||||||
[scoped]
|
const unreadIds = React.useMemo(() => collectUnreadIds(scoped), [scoped]);
|
||||||
);
|
|
||||||
|
|
||||||
const showFilterNotice = Boolean(slug) && filtered.length === 0 && notifications.length > 0;
|
const showFilterNotice = Boolean(slug) && filtered.length === 0 && notifications.length > 0;
|
||||||
|
|
||||||
@@ -421,6 +432,34 @@ export default function MobileNotificationsPage() {
|
|||||||
}, [markNotificationRead, selectedNotification]);
|
}, [markNotificationRead, selectedNotification]);
|
||||||
|
|
||||||
const notificationListPath = adminPath('/mobile/notifications');
|
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(
|
const openNotification = React.useCallback(
|
||||||
(item: NotificationItem) => {
|
(item: NotificationItem) => {
|
||||||
@@ -475,7 +514,7 @@ export default function MobileNotificationsPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
navigate(notificationListPath, { replace: true });
|
updateFilters({ event: '' });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text fontSize="$sm" color={primary} fontWeight="700">
|
<Text fontSize="$sm" color={primary} fontWeight="700">
|
||||||
@@ -488,15 +527,7 @@ export default function MobileNotificationsPage() {
|
|||||||
<XStack space="$2" marginBottom="$2">
|
<XStack space="$2" marginBottom="$2">
|
||||||
<MobileSelect
|
<MobileSelect
|
||||||
value={statusParam}
|
value={statusParam}
|
||||||
onChange={(e) =>
|
onChange={(e) => updateFilters({ status: e.target.value })}
|
||||||
navigate(
|
|
||||||
`${notificationListPath}?${new URLSearchParams({
|
|
||||||
status: e.target.value,
|
|
||||||
scope: scopeParam,
|
|
||||||
event: slug ?? '',
|
|
||||||
}).toString()}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
compact
|
compact
|
||||||
style={{ minWidth: 120 }}
|
style={{ minWidth: 120 }}
|
||||||
>
|
>
|
||||||
@@ -504,28 +535,6 @@ export default function MobileNotificationsPage() {
|
|||||||
<option value="read">{t('notificationLogs.filter.read', 'Read')}</option>
|
<option value="read">{t('notificationLogs.filter.read', 'Read')}</option>
|
||||||
<option value="all">{t('notificationLogs.filter.all', 'All')}</option>
|
<option value="all">{t('notificationLogs.filter.all', 'All')}</option>
|
||||||
</MobileSelect>
|
</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 ? (
|
{unreadIds.length ? (
|
||||||
<CTAButton
|
<CTAButton
|
||||||
label={t('notificationLogs.markAllRead', 'Mark all read')}
|
label={t('notificationLogs.markAllRead', 'Mark all read')}
|
||||||
@@ -543,6 +552,38 @@ export default function MobileNotificationsPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</XStack>
|
</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 ? (
|
{loading ? (
|
||||||
<YStack space="$2">
|
<YStack space="$2">
|
||||||
{Array.from({ length: 4 }).map((_, idx) => (
|
{Array.from({ length: 4 }).map((_, idx) => (
|
||||||
@@ -574,38 +615,68 @@ export default function MobileNotificationsPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
) : null}
|
) : null}
|
||||||
{statusFiltered.map((item) => (
|
{grouped.map((group) => (
|
||||||
<NotificationSwipeRow
|
<YStack key={group.scope} space="$2">
|
||||||
key={item.id}
|
<XStack alignItems="center" justifyContent="space-between">
|
||||||
item={item}
|
<Text fontSize="$xs" fontWeight="700" color={muted}>
|
||||||
onOpen={openNotification}
|
{t(`notificationLogs.scope.${group.scope}`, group.scope)}
|
||||||
onMarkRead={markNotificationRead}
|
</Text>
|
||||||
>
|
<XStack space="$2" alignItems="center">
|
||||||
<MobileCard space="$2" borderColor={item.is_read ? border : primary}>
|
{group.unread > 0 ? (
|
||||||
<XStack alignItems="center" space="$2">
|
<Pressable onPress={() => markGroupRead(group)}>
|
||||||
<XStack
|
<Text fontSize="$xs" color={primary} fontWeight="700">
|
||||||
width={36}
|
{t('notificationLogs.markScopeRead', 'Mark read')}
|
||||||
height={36}
|
</Text>
|
||||||
borderRadius={12}
|
</Pressable>
|
||||||
alignItems="center"
|
) : null}
|
||||||
justifyContent="center"
|
{group.unread > 0 ? (
|
||||||
backgroundColor={item.tone === 'warning' ? warningBg : infoBg}
|
<PillBadge tone="warning">
|
||||||
>
|
{t('notificationLogs.unread', 'Unread')} {group.unread}
|
||||||
<Bell size={18} color={item.tone === 'warning' ? warningIcon : infoIcon} />
|
</PillBadge>
|
||||||
</XStack>
|
) : null}
|
||||||
<YStack space="$0.5" flex={1}>
|
<PillBadge tone="muted">{group.items.length}</PillBadge>
|
||||||
<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>
|
|
||||||
</XStack>
|
</XStack>
|
||||||
</MobileCard>
|
</XStack>
|
||||||
</NotificationSwipeRow>
|
{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>
|
</YStack>
|
||||||
)}
|
)}
|
||||||
@@ -641,7 +712,7 @@ export default function MobileNotificationsPage() {
|
|||||||
{!selectedNotification.is_read ? <PillBadge tone="warning">{t('notificationLogs.unread', 'Unread')}</PillBadge> : null}
|
{!selectedNotification.is_read ? <PillBadge tone="warning">{t('notificationLogs.unread', 'Unread')}</PillBadge> : null}
|
||||||
</XStack>
|
</XStack>
|
||||||
<Text fontSize="$xs" color={muted}>
|
<Text fontSize="$xs" color={muted}>
|
||||||
{selectedNotification.time}
|
{formatRelativeTime(selectedNotification.time) || selectedNotification.time || '—'}
|
||||||
</Text>
|
</Text>
|
||||||
</YStack>
|
</YStack>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -665,7 +736,7 @@ export default function MobileNotificationsPage() {
|
|||||||
onPress={() => {
|
onPress={() => {
|
||||||
setShowEventPicker(false);
|
setShowEventPicker(false);
|
||||||
if (ev.slug) {
|
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 { MobileInstallBanner } from './components/MobileInstallBanner';
|
||||||
import { setTourSeen } from './lib/mobileTour';
|
import { setTourSeen } from './lib/mobileTour';
|
||||||
import { useBackNavigation } from './hooks/useBackNavigation';
|
import { useBackNavigation } from './hooks/useBackNavigation';
|
||||||
|
import { useOnlineStatus } from './hooks/useOnlineStatus';
|
||||||
|
|
||||||
type PreferenceKey = keyof NotificationPreferences;
|
type PreferenceKey = keyof NotificationPreferences;
|
||||||
|
|
||||||
@@ -59,6 +60,7 @@ export default function MobileSettingsPage() {
|
|||||||
const [storageError, setStorageError] = React.useState<string | null>(null);
|
const [storageError, setStorageError] = React.useState<string | null>(null);
|
||||||
const pushState = useAdminPushSubscription();
|
const pushState = useAdminPushSubscription();
|
||||||
const devicePermissions = useDevicePermissions();
|
const devicePermissions = useDevicePermissions();
|
||||||
|
const online = useOnlineStatus();
|
||||||
const installPrompt = useInstallPrompt();
|
const installPrompt = useInstallPrompt();
|
||||||
const back = useBackNavigation(adminPath('/mobile/profile'));
|
const back = useBackNavigation(adminPath('/mobile/profile'));
|
||||||
const [installBannerDismissed, setInstallBannerDismissedState] = React.useState(() => getInstallBannerDismissed());
|
const [installBannerDismissed, setInstallBannerDismissedState] = React.useState(() => getInstallBannerDismissed());
|
||||||
@@ -109,6 +111,9 @@ export default function MobileSettingsPage() {
|
|||||||
t(`mobileSettings.deviceStatusValues.${status}`, status);
|
t(`mobileSettings.deviceStatusValues.${status}`, status);
|
||||||
const storageLabel = (status: StorageStatus) =>
|
const storageLabel = (status: StorageStatus) =>
|
||||||
t(`mobileSettings.deviceStatusValues.${status}`, status);
|
t(`mobileSettings.deviceStatusValues.${status}`, status);
|
||||||
|
const connectionLabel = online
|
||||||
|
? t('mobileSettings.deviceStatusValues.online', 'Online')
|
||||||
|
: t('mobileSettings.deviceStatusValues.offline', 'Offline');
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -375,6 +380,17 @@ export default function MobileSettingsPage() {
|
|||||||
{storageLabel(devicePermissions.storage)}
|
{storageLabel(devicePermissions.storage)}
|
||||||
</PillBadge>
|
</PillBadge>
|
||||||
</XStack>
|
</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>
|
</YStack>
|
||||||
)}
|
)}
|
||||||
{devicePermissions.storage === 'available' ? (
|
{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