From 1e0c38fce489bcbee891a15346ecb56c6b96ec57 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Sun, 28 Dec 2025 20:48:32 +0100 Subject: [PATCH] =?UTF-8?q?I=20finished=20the=20remaining=20polish=20so=20?= =?UTF-8?q?the=20admin=20app=20now=20feels=20fully=20=E2=80=9Capp=E2=80=91?= =?UTF-8?q?like=E2=80=9D=20across=20the=20core=20=20screens.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../js/admin/i18n/locales/de/management.json | 80 ++++++- .../js/admin/i18n/locales/en/management.json | 66 +++++- resources/js/admin/mobile/EventPhotosPage.tsx | 162 ++++++++++++- resources/js/admin/mobile/EventTasksPage.tsx | 124 +++++++++- resources/js/admin/mobile/EventsPage.tsx | 213 +++++++++++++++--- .../js/admin/mobile/NotificationsPage.tsx | 211 +++++++++++------ resources/js/admin/mobile/SettingsPage.tsx | 16 ++ .../js/admin/mobile/lib/eventFilters.test.ts | 62 +++++ resources/js/admin/mobile/lib/eventFilters.ts | 43 ++++ .../admin/mobile/lib/eventListStats.test.ts | 36 +++ .../js/admin/mobile/lib/eventListStats.ts | 15 ++ .../mobile/lib/notificationGrouping.test.ts | 33 +++ .../admin/mobile/lib/notificationGrouping.ts | 49 ++++ .../mobile/lib/notificationUnread.test.ts | 24 ++ .../js/admin/mobile/lib/notificationUnread.ts | 11 + .../mobile/lib/photoModerationSwipe.test.ts | 52 +++++ .../admin/mobile/lib/photoModerationSwipe.ts | 24 ++ .../js/admin/mobile/lib/relativeTime.test.ts | 29 +++ resources/js/admin/mobile/lib/relativeTime.ts | 52 +++++ .../mobile/lib/taskSectionCounts.test.ts | 14 ++ .../js/admin/mobile/lib/taskSectionCounts.ts | 17 ++ .../js/admin/mobile/lib/taskSummary.test.ts | 9 + resources/js/admin/mobile/lib/taskSummary.ts | 20 ++ 23 files changed, 1250 insertions(+), 112 deletions(-) create mode 100644 resources/js/admin/mobile/lib/eventFilters.test.ts create mode 100644 resources/js/admin/mobile/lib/eventFilters.ts create mode 100644 resources/js/admin/mobile/lib/eventListStats.test.ts create mode 100644 resources/js/admin/mobile/lib/eventListStats.ts create mode 100644 resources/js/admin/mobile/lib/notificationGrouping.test.ts create mode 100644 resources/js/admin/mobile/lib/notificationGrouping.ts create mode 100644 resources/js/admin/mobile/lib/notificationUnread.test.ts create mode 100644 resources/js/admin/mobile/lib/notificationUnread.ts create mode 100644 resources/js/admin/mobile/lib/photoModerationSwipe.test.ts create mode 100644 resources/js/admin/mobile/lib/photoModerationSwipe.ts create mode 100644 resources/js/admin/mobile/lib/relativeTime.test.ts create mode 100644 resources/js/admin/mobile/lib/relativeTime.ts create mode 100644 resources/js/admin/mobile/lib/taskSectionCounts.test.ts create mode 100644 resources/js/admin/mobile/lib/taskSectionCounts.ts create mode 100644 resources/js/admin/mobile/lib/taskSummary.test.ts create mode 100644 resources/js/admin/mobile/lib/taskSummary.ts diff --git a/resources/js/admin/i18n/locales/de/management.json b/resources/js/admin/i18n/locales/de/management.json index eb72d5b..ea49a17 100644 --- a/resources/js/admin/i18n/locales/de/management.json +++ b/resources/js/admin/i18n/locales/de/management.json @@ -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", diff --git a/resources/js/admin/i18n/locales/en/management.json b/resources/js/admin/i18n/locales/en/management.json index 03b1c25..a4f9e11 100644 --- a/resources/js/admin/i18n/locales/en/management.json +++ b/resources/js/admin/i18n/locales/en/management.json @@ -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", diff --git a/resources/js/admin/mobile/EventPhotosPage.tsx b/resources/js/admin/mobile/EventPhotosPage.tsx index e682169..69e592a 100644 --- a/resources/js/admin/mobile/EventPhotosPage.tsx +++ b/resources/js/admin/mobile/EventPhotosPage.tsx @@ -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 ( - (selectionMode ? toggleSelection(photo.id) : setLightboxWithUrl(photo.id))} + photo={photo} + disabled={swipeDisabled} + onOpen={() => (selectionMode ? toggleSelection(photo.id) : setLightboxWithUrl(photo.id))} + onModerate={(action) => handleModerationAction(action, photo)} > ) : null} - + ); })} @@ -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 ( +
+ {leftConfig || rightConfig ? ( + + + {rightConfig ? ( + + + + {rightConfig.label} + + + ) : null} + + + {leftConfig ? ( + + + + {leftConfig.label} + + + ) : null} + + + ) : null} + + {children} + +
+ ); +} + type LimitTranslator = (key: string, options?: Record) => string; function translateLimits(t: (key: string, defaultValue?: string, options?: Record) => string): LimitTranslator { diff --git a/resources/js/admin/mobile/EventTasksPage.tsx b/resources/js/admin/mobile/EventTasksPage.tsx index c614d51..444fcbb 100644 --- a/resources/js/admin/mobile/EventTasksPage.tsx +++ b/resources/js/admin/mobile/EventTasksPage.tsx @@ -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 ; } +function TaskSummaryCard({ + summary, + text, + muted, + border, +}: { + summary: ReturnType; + text: string; + muted: string; + border: string; +}) { + const { t } = useTranslation('management'); + return ( + + + + + + + + + + + ); +} + +function SummaryItem({ + label, + value, + text, + muted, +}: { + label: string; + value: number; + text: string; + muted: string; +}) { + return ( + + + {label} + + + {value} + + + ); +} + 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(null); + const libraryRef = React.useRef(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) => { + 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() { ) : null} + {!loading ? ( + + ) : null} + + {!loading ? ( + + + {t('events.tasks.quickNav', 'Quick jump')} + + + {sectionCounts.map((section) => ( + handleQuickNav(section.key)} style={{ flexGrow: 1 }}> + + + {t(`events.tasks.sections.${section.key}`, section.key)} + + {section.count} + + + ))} + + + ) : null} + {loading ? ( {Array.from({ length: 4 }).map((_, idx) => ( @@ -471,6 +591,7 @@ export default function MobileEventTasksPage() { ) : ( +
+
{t('events.tasks.library', 'Weitere Aufgaben')} diff --git a/resources/js/admin/mobile/EventsPage.tsx b/resources/js/admin/mobile/EventsPage.tsx index 899f7ad..a0e7237 100644 --- a/resources/js/admin/mobile/EventsPage.tsx +++ b/resources/js/admin/mobile/EventsPage.tsx @@ -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(null); const [query, setQuery] = React.useState(''); + const [statusFilter, setStatusFilter] = React.useState('all'); const searchRef = React.useRef(null); const back = useBackNavigation(); const theme = useTheme(); @@ -92,27 +95,14 @@ export default function MobileEventsPage() { navigate(adminPath('/events/new'))} /> ) : ( - - {events - .filter((event) => { - if (!query.trim()) return true; - const hay = `${event.name ?? ''} ${event.location ?? ''}`.toLowerCase(); - return hay.includes(query.toLowerCase()); - }) - .map((event) => ( - navigate(adminPath(`/mobile/events/${slug}`))} - onEdit={(slug) => navigate(adminPath(`/mobile/events/${slug}/edit`))} - /> - ))} - + navigate(adminPath(`/mobile/events/${slug}`))} + onEdit={(slug) => navigate(adminPath(`/mobile/events/${slug}/edit`))} + /> )} 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 ( + + + {filters.map((filter) => { + const active = filter.key === statusFilter; + return ( + onStatusChange(filter.key)} style={{ flexGrow: 1 }}> + + + {filter.label} + + {filter.count} + + + ); + })} + + + {filteredEvents.length === 0 ? ( + + + {t('events.list.empty.filtered', 'No events match this filter.')} + + + {t('events.list.empty.filteredHint', 'Try a different status or clear your search.')} + + onStatusChange('all')} + /> + + ) : ( + 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 ( + + ); + }) + )} + + ); +} + 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 ( @@ -163,7 +279,27 @@ function EventRow({ {resolveLocation(event)} - {status.label} + {statusLabel} + + + + + onEdit(event.slug)}> @@ -176,7 +312,7 @@ function EventRow({ - Open event + {t('events.list.actions.open', 'Open event')} @@ -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 ( + + + + {value} {label} + + + ); } function renderName(name: TenantEvent['name']): string { diff --git a/resources/js/admin/mobile/NotificationsPage.tsx b/resources/js/admin/mobile/NotificationsPage.tsx index 147c1b8..57d4c50 100644 --- a/resources/js/admin/mobile/NotificationsPage.tsx +++ b/resources/js/admin/mobile/NotificationsPage.tsx @@ -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) => { + 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() { { - navigate(notificationListPath, { replace: true }); + updateFilters({ event: '' }); }} > @@ -488,15 +527,7 @@ export default function MobileNotificationsPage() { - 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() { - - navigate( - `${notificationListPath}?${new URLSearchParams({ - scope: e.target.value, - status: statusParam, - event: slug ?? '', - }).toString()}`, - ) - } - compact - style={{ minWidth: 140 }} - > - - - - - - - - {unreadIds.length ? ( + + {([ + { 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 ( + updateFilters({ scope: filter.key })} style={{ flexGrow: 1 }}> + + + {filter.label} + + + + ); + })} + + {loading ? ( {Array.from({ length: 4 }).map((_, idx) => ( @@ -574,38 +615,68 @@ export default function MobileNotificationsPage() { ) : null} - {statusFiltered.map((item) => ( - - - - - - - - - {item.title} - - - {item.body} - - - {!item.is_read ? {t('notificationLogs.unread', 'Unread')} : null} - {item.time} + {grouped.map((group) => ( + + + + {t(`notificationLogs.scope.${group.scope}`, group.scope)} + + + {group.unread > 0 ? ( + markGroupRead(group)}> + + {t('notificationLogs.markScopeRead', 'Mark read')} + + + ) : null} + {group.unread > 0 ? ( + + {t('notificationLogs.unread', 'Unread')} {group.unread} + + ) : null} + {group.items.length} - - + + {group.items.map((item) => { + const formattedTime = formatRelativeTime(item.time) || item.time || '—'; + return ( + + + + + + + + + {item.title} + + + {item.body} + + {item.eventName ? ( + {item.eventName} + ) : null} + + {!item.is_read ? {t('notificationLogs.unread', 'Unread')} : null} + {formattedTime} + + + + ); + })} + ))} )} @@ -641,7 +712,7 @@ export default function MobileNotificationsPage() { {!selectedNotification.is_read ? {t('notificationLogs.unread', 'Unread')} : null} - {selectedNotification.time} + {formatRelativeTime(selectedNotification.time) || selectedNotification.time || '—'} ) : null} @@ -665,7 +736,7 @@ export default function MobileNotificationsPage() { onPress={() => { setShowEventPicker(false); if (ev.slug) { - navigate(`${notificationListPath}?event=${ev.slug}`); + updateFilters({ event: ev.slug }); } }} > diff --git a/resources/js/admin/mobile/SettingsPage.tsx b/resources/js/admin/mobile/SettingsPage.tsx index 54d840d..054e834 100644 --- a/resources/js/admin/mobile/SettingsPage.tsx +++ b/resources/js/admin/mobile/SettingsPage.tsx @@ -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(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)} + + + + {t('mobileSettings.deviceStatus.connection.label', 'Connection')} + + + {t('mobileSettings.deviceStatus.connection.description', 'Shows if the app is online or offline.')} + + + {connectionLabel} + )} {devicePermissions.storage === 'available' ? ( diff --git a/resources/js/admin/mobile/lib/eventFilters.test.ts b/resources/js/admin/mobile/lib/eventFilters.test.ts new file mode 100644 index 0000000..c1a7273 --- /dev/null +++ b/resources/js/admin/mobile/lib/eventFilters.test.ts @@ -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 => ({ + 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); + }); +}); diff --git a/resources/js/admin/mobile/lib/eventFilters.ts b/resources/js/admin/mobile/lib/eventFilters.ts new file mode 100644 index 0000000..893de3d --- /dev/null +++ b/resources/js/admin/mobile/lib/eventFilters.ts @@ -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 { + 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 { + const counts: Record = { + 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); +} diff --git a/resources/js/admin/mobile/lib/eventListStats.test.ts b/resources/js/admin/mobile/lib/eventListStats.test.ts new file mode 100644 index 0000000..c47af37 --- /dev/null +++ b/resources/js/admin/mobile/lib/eventListStats.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import type { TenantEvent } from '../../api'; +import { buildEventListStats } from './eventListStats'; + +const baseEvent = (overrides: Partial): 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); + }); +}); diff --git a/resources/js/admin/mobile/lib/eventListStats.ts b/resources/js/admin/mobile/lib/eventListStats.ts new file mode 100644 index 0000000..ed32fad --- /dev/null +++ b/resources/js/admin/mobile/lib/eventListStats.ts @@ -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, + }; +} diff --git a/resources/js/admin/mobile/lib/notificationGrouping.test.ts b/resources/js/admin/mobile/lib/notificationGrouping.test.ts new file mode 100644 index 0000000..85e89c9 --- /dev/null +++ b/resources/js/admin/mobile/lib/notificationGrouping.test.ts @@ -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']); + }); +}); diff --git a/resources/js/admin/mobile/lib/notificationGrouping.ts b/resources/js/admin/mobile/lib/notificationGrouping.ts new file mode 100644 index 0000000..7fc386f --- /dev/null +++ b/resources/js/admin/mobile/lib/notificationGrouping.ts @@ -0,0 +1,49 @@ +export type NotificationScope = 'photos' | 'guests' | 'gallery' | 'events' | 'package' | 'general'; + +export type ScopedNotification = { + scope: NotificationScope; + is_read?: boolean; +}; + +export type NotificationGroup = { + scope: NotificationScope; + items: T[]; + unread: number; +}; + +const SCOPE_ORDER: NotificationScope[] = [ + 'photos', + 'guests', + 'gallery', + 'events', + 'package', + 'general', +]; + +export function groupNotificationsByScope(items: T[]): NotificationGroup[] { + const groups = new Map>(); + + 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; + }); +} diff --git a/resources/js/admin/mobile/lib/notificationUnread.test.ts b/resources/js/admin/mobile/lib/notificationUnread.test.ts new file mode 100644 index 0000000..ba9b37c --- /dev/null +++ b/resources/js/admin/mobile/lib/notificationUnread.test.ts @@ -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([]); + }); +}); diff --git a/resources/js/admin/mobile/lib/notificationUnread.ts b/resources/js/admin/mobile/lib/notificationUnread.ts new file mode 100644 index 0000000..21af28f --- /dev/null +++ b/resources/js/admin/mobile/lib/notificationUnread.ts @@ -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)); +} diff --git a/resources/js/admin/mobile/lib/photoModerationSwipe.test.ts b/resources/js/admin/mobile/lib/photoModerationSwipe.test.ts new file mode 100644 index 0000000..37a9d04 --- /dev/null +++ b/resources/js/admin/mobile/lib/photoModerationSwipe.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import type { TenantPhoto } from '../../api'; +import { resolvePhotoSwipeAction } from './photoModerationSwipe'; + +const basePhoto = (overrides: Partial): 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(); + }); +}); diff --git a/resources/js/admin/mobile/lib/photoModerationSwipe.ts b/resources/js/admin/mobile/lib/photoModerationSwipe.ts new file mode 100644 index 0000000..177ccd2 --- /dev/null +++ b/resources/js/admin/mobile/lib/photoModerationSwipe.ts @@ -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; +} diff --git a/resources/js/admin/mobile/lib/relativeTime.test.ts b/resources/js/admin/mobile/lib/relativeTime.test.ts new file mode 100644 index 0000000..236a095 --- /dev/null +++ b/resources/js/admin/mobile/lib/relativeTime.test.ts @@ -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'); + }); +}); diff --git a/resources/js/admin/mobile/lib/relativeTime.ts b/resources/js/admin/mobile/lib/relativeTime.ts new file mode 100644 index 0000000..f50579f --- /dev/null +++ b/resources/js/admin/mobile/lib/relativeTime.ts @@ -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'); +} diff --git a/resources/js/admin/mobile/lib/taskSectionCounts.test.ts b/resources/js/admin/mobile/lib/taskSectionCounts.test.ts new file mode 100644 index 0000000..4b5004f --- /dev/null +++ b/resources/js/admin/mobile/lib/taskSectionCounts.test.ts @@ -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 }, + ]); + }); +}); diff --git a/resources/js/admin/mobile/lib/taskSectionCounts.ts b/resources/js/admin/mobile/lib/taskSectionCounts.ts new file mode 100644 index 0000000..272cf00 --- /dev/null +++ b/resources/js/admin/mobile/lib/taskSectionCounts.ts @@ -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 }, + ]; +} diff --git a/resources/js/admin/mobile/lib/taskSummary.test.ts b/resources/js/admin/mobile/lib/taskSummary.test.ts new file mode 100644 index 0000000..bf0d0bf --- /dev/null +++ b/resources/js/admin/mobile/lib/taskSummary.test.ts @@ -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 }); + }); +}); diff --git a/resources/js/admin/mobile/lib/taskSummary.ts b/resources/js/admin/mobile/lib/taskSummary.ts new file mode 100644 index 0000000..59977a5 --- /dev/null +++ b/resources/js/admin/mobile/lib/taskSummary.ts @@ -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, + }; +}