From 73e550ee87deba7bf3bbc2f5dafdb321232118e7 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Wed, 10 Dec 2025 16:13:44 +0100 Subject: [PATCH] Implemented a shared mobile shell and navigation aligned to the new architecture, plus refactored the dashboard and tab flows. - Added a dynamic MobileShell with sticky header (notification bell with badge, quick QR when an event is active, event switcher for multi-event users) and stabilized bottom tabs (home, tasks, uploads, profile) driven by useMobileNav (resources/js/admin/mobile/components/MobileShell.tsx, components/BottomNav.tsx, hooks/ useMobileNav.ts). - Centralized event handling now supports 0/1/many-event states without auto-selecting in multi-tenant mode and exposes helper flags/activeSlug for consumers (resources/js/admin/context/EventContext.tsx). - Rebuilt the mobile dashboard into explicit states: onboarding/no-event, single-event focus, and multi-event picker with featured/secondary actions, KPI strip, and alerts (resources/js/admin/mobile/DashboardPage.tsx). - Introduced tab entry points that respect event context and prompt selection when needed (resources/js/admin/ mobile/TasksTabPage.tsx, UploadsTabPage.tsx). Refreshed tasks/uploads detail screens to use the new shell and sync event selection (resources/js/admin/mobile/EventTasksPage.tsx, EventPhotosPage.tsx). - Updated mobile routes and existing screens to the new tab keys and header/footer behavior (resources/js/admin/ router.tsx, mobile/* pages, i18n nav/header strings). --- resources/js/admin/context/EventContext.tsx | 33 +- .../js/admin/i18n/locales/de/mobile.json | 20 +- .../js/admin/i18n/locales/en/mobile.json | 20 +- resources/js/admin/mobile/AlertsPage.tsx | 2 +- resources/js/admin/mobile/BrandingPage.tsx | 2 +- resources/js/admin/mobile/DashboardPage.tsx | 515 ++++++++++++------ resources/js/admin/mobile/EventDetailPage.tsx | 2 +- resources/js/admin/mobile/EventFormPage.tsx | 2 +- .../js/admin/mobile/EventMembersPage.tsx | 2 +- resources/js/admin/mobile/EventPhotosPage.tsx | 24 +- resources/js/admin/mobile/EventTasksPage.tsx | 24 +- resources/js/admin/mobile/EventsPage.tsx | 2 +- resources/js/admin/mobile/QrPrintPage.tsx | 2 +- resources/js/admin/mobile/TasksTabPage.tsx | 79 +++ resources/js/admin/mobile/UploadsTabPage.tsx | 79 +++ .../js/admin/mobile/components/BottomNav.tsx | 28 +- .../admin/mobile/components/MobileShell.tsx | 231 ++++++++ .../js/admin/mobile/hooks/useMobileNav.ts | 18 +- resources/js/admin/router.tsx | 4 + 19 files changed, 840 insertions(+), 249 deletions(-) create mode 100644 resources/js/admin/mobile/TasksTabPage.tsx create mode 100644 resources/js/admin/mobile/UploadsTabPage.tsx create mode 100644 resources/js/admin/mobile/components/MobileShell.tsx diff --git a/resources/js/admin/context/EventContext.tsx b/resources/js/admin/context/EventContext.tsx index b5565a3..dcbf418 100644 --- a/resources/js/admin/context/EventContext.tsx +++ b/resources/js/admin/context/EventContext.tsx @@ -11,6 +11,9 @@ export interface EventContextValue { isLoading: boolean; isError: boolean; activeEvent: TenantEvent | null; + activeSlug: string | null; + hasEvents: boolean; + hasMultipleEvents: boolean; selectEvent: (slug: string | null) => void; refetch: () => void; } @@ -58,11 +61,16 @@ export function EventProvider({ children }: { children: React.ReactNode }) { const hasStored = Boolean(storedSlug); const slugExists = hasStored && events.some((event) => event.slug === storedSlug); - const fallbackSlug = events[0]?.slug; - if (!slugExists && fallbackSlug) { + if (!slugExists) { + const shouldAutoselect = events.length === 1; + const fallbackSlug = shouldAutoselect ? events[0]?.slug : null; setStoredSlug(fallbackSlug); - window.localStorage.setItem(STORAGE_KEY, fallbackSlug); + if (fallbackSlug) { + window.localStorage.setItem(STORAGE_KEY, fallbackSlug); + } else { + window.localStorage.removeItem(STORAGE_KEY); + } } }, [events, storedSlug]); @@ -76,10 +84,20 @@ export function EventProvider({ children }: { children: React.ReactNode }) { return matched; } - // Fallback to the first event if the stored slug is missing or stale. - return events[0]; + // Only auto-select the single available event. When multiple events exist and + // no stored slug is present we intentionally return null to let the UI prompt + // for a selection. + if (events.length === 1) { + return events[0]; + } + + return null; }, [events, storedSlug]); + const hasEvents = events.length > 0; + const hasMultipleEvents = events.length > 1; + const activeSlug = activeEvent?.slug ?? null; + const selectEvent = React.useCallback((slug: string | null) => { setStoredSlug(slug); if (typeof window !== 'undefined') { @@ -97,10 +115,13 @@ export function EventProvider({ children }: { children: React.ReactNode }) { isLoading, isError, activeEvent, + activeSlug, + hasEvents, + hasMultipleEvents, selectEvent, refetch, }), - [events, isLoading, isError, activeEvent, selectEvent, refetch] + [events, isLoading, isError, activeEvent, activeSlug, hasEvents, hasMultipleEvents, selectEvent, refetch] ); return {children}; diff --git a/resources/js/admin/i18n/locales/de/mobile.json b/resources/js/admin/i18n/locales/de/mobile.json index 31d09d2..5e92092 100644 --- a/resources/js/admin/i18n/locales/de/mobile.json +++ b/resources/js/admin/i18n/locales/de/mobile.json @@ -1,14 +1,28 @@ { "nav": { - "dashboard": "Übersicht", - "events": "Events", + "home": "Start", "tasks": "Aufgaben", + "uploads": "Uploads", + "profile": "Profil", "alerts": "Alerts", - "profile": "Profil" + "events": "Events" }, "actions": { "back": "Zurück", "close": "Schließen", "refresh": "Aktualisieren" + }, + "header": { + "appName": "Event Admin", + "selectEvent": "Wähle ein Event, um fortzufahren", + "empty": "Lege dein erstes Event an, um zu starten", + "eventSwitcher": "Event auswählen", + "noEventsTitle": "Erstes Event erstellen", + "noEventsBody": "Starte ein Event, um Aufgaben, Uploads und QR-Poster zu nutzen.", + "createEvent": "Event erstellen", + "noDate": "Datum folgt", + "active": "Aktiv", + "quickQr": "QR öffnen", + "clearSelection": "Auswahl entfernen" } } diff --git a/resources/js/admin/i18n/locales/en/mobile.json b/resources/js/admin/i18n/locales/en/mobile.json index 80309a6..da143df 100644 --- a/resources/js/admin/i18n/locales/en/mobile.json +++ b/resources/js/admin/i18n/locales/en/mobile.json @@ -1,14 +1,28 @@ { "nav": { - "dashboard": "Dashboard", - "events": "Events", + "home": "Home", "tasks": "Tasks", + "uploads": "Uploads", + "profile": "Profile", "alerts": "Alerts", - "profile": "Profile" + "events": "Events" }, "actions": { "back": "Back", "close": "Close", "refresh": "Refresh" + }, + "header": { + "appName": "Event Admin", + "selectEvent": "Select an event to continue", + "empty": "Create your first event to get started", + "eventSwitcher": "Choose an event", + "noEventsTitle": "Create your first event", + "noEventsBody": "Start an event to access tasks, uploads, QR posters and more.", + "createEvent": "Create event", + "noDate": "Date tbd", + "active": "Active", + "quickQr": "Quick QR", + "clearSelection": "Clear selection" } } diff --git a/resources/js/admin/mobile/AlertsPage.tsx b/resources/js/admin/mobile/AlertsPage.tsx index 055a7c2..cade079 100644 --- a/resources/js/admin/mobile/AlertsPage.tsx +++ b/resources/js/admin/mobile/AlertsPage.tsx @@ -93,7 +93,7 @@ export default function MobileAlertsPage() { } footer={ - + } > {error ? ( diff --git a/resources/js/admin/mobile/BrandingPage.tsx b/resources/js/admin/mobile/BrandingPage.tsx index 53b7da0..50069c0 100644 --- a/resources/js/admin/mobile/BrandingPage.tsx +++ b/resources/js/admin/mobile/BrandingPage.tsx @@ -117,7 +117,7 @@ export default function MobileBrandingPage() { } footer={ - + } > {error ? ( diff --git a/resources/js/admin/mobile/DashboardPage.tsx b/resources/js/admin/mobile/DashboardPage.tsx index 98e8004..b0b6568 100644 --- a/resources/js/admin/mobile/DashboardPage.tsx +++ b/resources/js/admin/mobile/DashboardPage.tsx @@ -1,204 +1,365 @@ import React from 'react'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { CalendarDays, MapPin, Settings, Plus, Bell, ListTodo, Image as ImageIcon } from 'lucide-react'; +import { useQuery } from '@tanstack/react-query'; +import { CalendarDays, Image as ImageIcon, ListTodo, QrCode, Settings, 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'; -import { MobileScaffold } from './components/Scaffold'; -import { MobileCard, PillBadge, CTAButton, KpiTile, ActionTile } from './components/Primitives'; -import { BottomNav } from './components/BottomNav'; -import { getEvents, TenantEvent } from '../api'; +import { MobileShell, renderEventLocation } from './components/MobileShell'; +import { MobileCard, CTAButton, KpiTile, ActionTile, PillBadge } from './components/Primitives'; import { adminPath } from '../constants'; -import { isAuthError } from '../auth/tokens'; -import { getApiErrorMessage } from '../lib/apiError'; -import { useMobileNav } from './hooks/useMobileNav'; -import { getEventStats, EventStats } from '../api'; +import { useEventContext } from '../context/EventContext'; +import { getEventStats, EventStats, TenantEvent } from '../api'; +import { formatEventDate, resolveEventDisplayName } from '../lib/events'; export default function MobileDashboardPage() { const navigate = useNavigate(); - const { t } = useTranslation('management'); - const [events, setEvents] = React.useState([]); - const [stats, setStats] = React.useState>({}); - const [loading, setLoading] = React.useState(true); - const [error, setError] = React.useState(null); - const { go } = useMobileNav(); + const { t, i18n } = useTranslation('management'); + const { events, activeEvent, hasEvents, hasMultipleEvents, isLoading } = useEventContext(); - React.useEffect(() => { - (async () => { - try { - setEvents(await getEvents()); - const fetched: Record = {}; - const list = await getEvents(); - await Promise.all( - (list || []).map(async (ev) => { - if (!ev.slug) return; - try { - fetched[ev.slug] = await getEventStats(ev.slug); - } catch { - // ignore per-event stat failures - } - }) - ); - setStats(fetched); - setError(null); - } catch (err) { - if (!isAuthError(err)) { - setError(getApiErrorMessage(err, t('events.errors.loadFailed', 'Events konnten nicht geladen werden.'))); - } - } finally { - setLoading(false); - } - })(); - }, [t]); + const { data: stats, isLoading: statsLoading } = useQuery({ + queryKey: ['mobile', 'dashboard', 'stats', activeEvent?.slug], + enabled: Boolean(activeEvent?.slug), + queryFn: async () => { + if (!activeEvent?.slug) return null; + return await getEventStats(activeEvent.slug); + }, + }); - return ( - navigate(-1)} - rightSlot={ - navigate(adminPath('/settings'))}> - - - } - footer={ - - } - > - {error ? ( - - - {error} - - - ) : null} + const locale = i18n.language?.startsWith('en') ? 'en-GB' : 'de-DE'; - navigate(adminPath('/mobile/events/new'))} /> - - {loading ? ( + if (isLoading) { + return ( + {Array.from({ length: 3 }).map((_, idx) => ( - + ))} - ) : events.length === 0 ? ( - - - {t('events.list.empty.title', 'Noch kein Event angelegt')} - - - {t('events.list.empty.description', 'Starte jetzt mit deinem ersten Event.')} - - navigate(adminPath('/mobile/events/new'))} /> - - ) : ( - - - - {t('dashboard.kpis', 'Key Performance Indicators')} - - - - - - - - {events.map((event) => ( - - - - - {renderName(event.name)} - - - - - {formatDate(event.event_date)} - - - - - - {resolveLocation(event)} - - - {resolveStatus(event, t)} - - navigate(adminPath(`/mobile/events/${event.slug}`))}> - - ˅ - - - + + ); + } - - navigate(adminPath(`/mobile/events/${event.slug}/tasks`))} - width="32%" - /> - navigate(adminPath(`/mobile/events/${event.slug}/photos`))} - width="32%" - /> - navigate(adminPath(`/mobile/alerts?event=${event.slug}`))} - width="32%" - /> - - - ))} - - )} - + if (!hasEvents) { + return ( + + + + ); + } + + if (hasMultipleEvents && !activeEvent) { + return ( + + + + ); + } + + return ( + + activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/photos`))} + onManageTasks={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/tasks`))} + onShowQr={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/qr`))} + /> + + activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/members`))} + onPrint={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/qr`))} + onInvites={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/members`))} + onSettings={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}`))} + /> + + + + + ); } -function renderName(name: TenantEvent['name']): string { - if (typeof name === 'string') return name; - if (name && typeof name === 'object') { - return name.de ?? name.en ?? Object.values(name)[0] ?? 'Unbenanntes Event'; +function OnboardingEmptyState() { + const { t } = useTranslation('management'); + const navigate = useNavigate(); + return ( + + + + {t('events.list.empty.title', 'Create your first event')} + + + {t('events.list.empty.description', 'Start an event to manage tasks, QR posters and uploads.')} + + navigate(adminPath('/mobile/events/new'))} /> + navigate(adminPath('/mobile/events'))} /> + + + + {t('events.list.empty.highlights', 'What you can do')} + + + {[ + t('events.quick.images', 'Review photos & uploads'), + t('events.quick.tasks', 'Assign tasks & challenges'), + t('events.quick.qr', 'Share QR posters'), + t('events.quick.guests', 'Invite helpers & guests'), + ].map((item) => ( + + {item} + + ))} + + + + ); +} + +function EventPickerList({ events, locale }: { events: TenantEvent[]; locale: string }) { + const { t } = useTranslation('management'); + const { selectEvent } = useEventContext(); + + return ( + + + {t('events.detail.pickEvent', 'Select an event')} + + {events.map((event) => ( + selectEvent(event.slug ?? null)} + > + + + + + {resolveEventDisplayName(event)} + + + {formatEventDate(event.event_date, locale) ?? t('events.status.draft', 'Draft')} + + + + {event.status === 'published' ? t('events.status.published', 'Live') : t('events.status.draft', 'Draft')} + + + + + ))} + + ); +} + +function FeaturedActions({ + onReviewPhotos, + onManageTasks, + onShowQr, +}: { + onReviewPhotos: () => void; + onManageTasks: () => void; + onShowQr: () => void; +}) { + const { t } = useTranslation('management'); + const cards = [ + { + key: 'photos', + label: t('events.quick.images', 'Review Photos'), + desc: t('events.quick.images.desc', 'Moderate uploads and highlights'), + icon: ImageIcon, + color: '#0ea5e9', + action: onReviewPhotos, + }, + { + key: 'tasks', + label: t('events.quick.tasks', 'Manage Tasks & Challenges'), + desc: t('events.quick.tasks.desc', 'Assign and track progress'), + icon: ListTodo, + color: '#22c55e', + action: onManageTasks, + }, + { + key: 'qr', + label: t('events.quick.qr', 'Show / Share QR Code'), + desc: t('events.quick.qr.desc', 'Posters, cards, and links'), + icon: QrCode, + color: '#f59e0b', + action: onShowQr, + }, + ]; + + return ( + + {cards.map((card) => ( + + + + + + + + + {card.label} + + + {card.desc} + + + + ˃ + + + + + ))} + + ); +} + +function SecondaryGrid({ + event, + onGuests, + onPrint, + onInvites, + onSettings, +}: { + event: TenantEvent | null; + onGuests: () => void; + onPrint: () => void; + onInvites: () => void; + onSettings: () => void; +}) { + const { t } = useTranslation('management'); + const tiles = [ + { + icon: Users, + label: t('events.quick.guests', 'Guest management'), + color: '#60a5fa', + action: onGuests, + }, + { + icon: QrCode, + label: t('events.quick.prints', 'Print & poster downloads'), + color: '#fbbf24', + action: onPrint, + }, + { + icon: Sparkles, + label: t('events.quick.invites', 'Team / helper invites'), + color: '#a855f7', + action: onInvites, + }, + { + icon: Settings, + label: t('events.quick.settings', 'Event settings'), + color: '#10b981', + action: onSettings, + }, + ]; + + return ( + + + {t('events.quick.more', 'Shortcuts')} + + + {tiles.map((tile) => ( + + ))} + + {event ? ( + + + {resolveEventDisplayName(event)} + + + {renderEventLocation(event)} + + + ) : null} + + ); +} + +function KpiStrip({ event, stats, loading, locale }: { event: TenantEvent | null; stats: EventStats | null | undefined; loading: boolean; locale: string }) { + const { t } = useTranslation('management'); + if (!event) return null; + + const kpis = [ + { + label: t('events.detail.kpi.tasks', 'Open tasks'), + value: event.tasks_count ?? '—', + icon: ListTodo, + }, + { + label: t('events.detail.kpi.photos', 'Photos'), + value: stats?.uploads_total ?? event.photo_count ?? '—', + icon: ImageIcon, + }, + { + label: t('events.detail.kpi.guests', 'Guests'), + value: event.active_invites_count ?? event.total_invites_count ?? '—', + icon: Users, + }, + ]; + + return ( + + + {t('dashboard.kpis', 'Key Performance Indicators')} + + {loading ? ( + + {Array.from({ length: 3 }).map((_, idx) => ( + + ))} + + ) : ( + + {kpis.map((kpi) => ( + + ))} + + )} + + {formatEventDate(event.event_date, locale) ?? ''} + + + ); +} + +function AlertsAndHints({ event, stats }: { event: TenantEvent | null; stats: EventStats | null | undefined }) { + const { t } = useTranslation('management'); + if (!event) return null; + + const alerts: string[] = []; + if (stats?.pending_photos) { + alerts.push(t('events.alerts.pendingPhotos', '{{count}} new uploads awaiting moderation', { count: stats.pending_photos })); } - return 'Unbenanntes Event'; -} - -function formatDate(iso: string | null): string { - if (!iso) return 'Date tbd'; - const date = new Date(iso); - if (Number.isNaN(date.getTime())) { - return 'Date tbd'; + if (event.tasks_count) { + alerts.push(t('events.alerts.tasksOpen', '{{count}} tasks due or open', { count: event.tasks_count })); } - return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); -} -function resolveLocation(event: TenantEvent): string { - const settings = (event.settings ?? {}) as Record; - const candidate = - (settings.location as string | undefined) ?? - (settings.address as string | undefined) ?? - (settings.city as string | undefined); - if (candidate && candidate.trim()) { - return candidate; + if (alerts.length === 0) { + return null; } - return 'Location'; -} -function resolveStatus(event: TenantEvent, t: ReturnType['t']): string { - if (event.status === 'published') return t('events.status.published', 'Upcoming'); - if (event.status === 'draft') return t('events.status.draft', 'Draft'); - return t('events.status.archived', 'Past'); -} - -function resolveTone(event: TenantEvent): 'success' | 'warning' | 'muted' { - if (event.status === 'published') return 'success'; - if (event.status === 'draft') return 'warning'; - return 'muted'; + return ( + + + {t('alerts.title', 'Alerts')} + + {alerts.map((alert) => ( + + + {alert} + + + ))} + + ); } diff --git a/resources/js/admin/mobile/EventDetailPage.tsx b/resources/js/admin/mobile/EventDetailPage.tsx index 517b94c..4c3761c 100644 --- a/resources/js/admin/mobile/EventDetailPage.tsx +++ b/resources/js/admin/mobile/EventDetailPage.tsx @@ -92,7 +92,7 @@ export default function MobileEventDetailPage() { } footer={ - + } > {error ? ( diff --git a/resources/js/admin/mobile/EventFormPage.tsx b/resources/js/admin/mobile/EventFormPage.tsx index 696281b..90b7c24 100644 --- a/resources/js/admin/mobile/EventFormPage.tsx +++ b/resources/js/admin/mobile/EventFormPage.tsx @@ -106,7 +106,7 @@ export default function MobileEventFormPage() { title={isEdit ? t('events.form.editTitle', 'Edit Event') : t('events.form.createTitle', 'Create New Event')} onBack={() => navigate(-1)} footer={ - + } > {error ? ( diff --git a/resources/js/admin/mobile/EventMembersPage.tsx b/resources/js/admin/mobile/EventMembersPage.tsx index 3ceaf0a..dd8869c 100644 --- a/resources/js/admin/mobile/EventMembersPage.tsx +++ b/resources/js/admin/mobile/EventMembersPage.tsx @@ -105,7 +105,7 @@ export default function MobileEventMembersPage() { } footer={ - + } > {error ? ( diff --git a/resources/js/admin/mobile/EventPhotosPage.tsx b/resources/js/admin/mobile/EventPhotosPage.tsx index 154f534..fe82c2f 100644 --- a/resources/js/admin/mobile/EventPhotosPage.tsx +++ b/resources/js/admin/mobile/EventPhotosPage.tsx @@ -5,21 +5,21 @@ import { Image as ImageIcon, RefreshCcw, Search, Filter } 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 { MobileScaffold } from './components/Scaffold'; +import { MobileShell } from './components/MobileShell'; import { MobileCard, PillBadge, CTAButton } from './components/Primitives'; -import { BottomNav } from './components/BottomNav'; import { getEventPhotos, updatePhotoVisibility, featurePhoto, unfeaturePhoto, TenantPhoto } from '../api'; import toast from 'react-hot-toast'; import { isAuthError } from '../auth/tokens'; import { getApiErrorMessage } from '../lib/apiError'; -import { useMobileNav } from './hooks/useMobileNav'; import { MobileSheet } from './components/Sheet'; +import { useEventContext } from '../context/EventContext'; type FilterKey = 'all' | 'featured' | 'hidden'; export default function MobileEventPhotosPage() { const { slug: slugParam } = useParams<{ slug?: string }>(); - const slug = slugParam ?? null; + const { activeEvent, selectEvent } = useEventContext(); + const slug = slugParam ?? activeEvent?.slug ?? null; const navigate = useNavigate(); const { t } = useTranslation('management'); @@ -31,13 +31,17 @@ export default function MobileEventPhotosPage() { const [busyId, setBusyId] = React.useState(null); const [totalCount, setTotalCount] = React.useState(0); const [hasMore, setHasMore] = React.useState(false); - const { go } = useMobileNav(slug); const [search, setSearch] = React.useState(''); const [showFilters, setShowFilters] = React.useState(false); const [uploaderFilter, setUploaderFilter] = React.useState(''); const [onlyFeatured, setOnlyFeatured] = React.useState(false); const [onlyHidden, setOnlyHidden] = React.useState(false); const [lightbox, setLightbox] = React.useState(null); + React.useEffect(() => { + if (slugParam && activeEvent?.slug !== slugParam) { + selectEvent(slugParam); + } + }, [slugParam, activeEvent?.slug, selectEvent]); const load = React.useCallback(async () => { if (!slug) return; @@ -117,10 +121,11 @@ export default function MobileEventPhotosPage() { } return ( - navigate(-1)} - rightSlot={ + headerActions={ setShowFilters(true)}> @@ -130,9 +135,6 @@ export default function MobileEventPhotosPage() { } - footer={ - - } > {error ? ( @@ -334,6 +336,6 @@ export default function MobileEventPhotosPage() { /> - + ); } diff --git a/resources/js/admin/mobile/EventTasksPage.tsx b/resources/js/admin/mobile/EventTasksPage.tsx index 4deb4b6..5e2faa5 100644 --- a/resources/js/admin/mobile/EventTasksPage.tsx +++ b/resources/js/admin/mobile/EventTasksPage.tsx @@ -6,9 +6,8 @@ import { YStack, XStack } from '@tamagui/stacks'; import { SizableText as Text } from '@tamagui/text'; import { ListItem } from '@tamagui/list-item'; import { Pressable } from '@tamagui/react-native-web-lite'; -import { MobileScaffold } from './components/Scaffold'; +import { MobileShell } from './components/MobileShell'; import { MobileCard, CTAButton } from './components/Primitives'; -import { BottomNav } from './components/BottomNav'; import { getEvent, getEventTasks, @@ -33,7 +32,7 @@ import { getApiErrorMessage } from '../lib/apiError'; import toast from 'react-hot-toast'; import { MobileSheet } from './components/Sheet'; import { Tag } from './components/Tag'; -import { useMobileNav } from './hooks/useMobileNav'; +import { useEventContext } from '../context/EventContext'; const inputStyle: React.CSSProperties = { width: '100%', @@ -51,7 +50,8 @@ function InlineSeparator() { export default function MobileEventTasksPage() { const { slug: slugParam } = useParams<{ slug?: string }>(); - const slug = slugParam ?? null; + const { activeEvent, selectEvent } = useEventContext(); + const slug = slugParam ?? activeEvent?.slug ?? null; const navigate = useNavigate(); const { t } = useTranslation('management'); @@ -67,7 +67,6 @@ export default function MobileEventTasksPage() { const [busyId, setBusyId] = React.useState(null); const [assigningId, setAssigningId] = React.useState(null); const [eventId, setEventId] = React.useState(null); - const { go } = useMobileNav(slug); const [searchTerm, setSearchTerm] = React.useState(''); const [emotionFilter, setEmotionFilter] = React.useState(''); const [expandedLibrary, setExpandedLibrary] = React.useState(false); @@ -79,6 +78,11 @@ export default function MobileEventTasksPage() { const [editingEmotion, setEditingEmotion] = React.useState(null); const [emotionForm, setEmotionForm] = React.useState({ name: '', color: '#e5e7eb' }); const [savingEmotion, setSavingEmotion] = React.useState(false); + React.useEffect(() => { + if (slugParam && activeEvent?.slug !== slugParam) { + selectEvent(slugParam); + } + }, [slugParam, activeEvent?.slug, selectEvent]); const load = React.useCallback(async () => { if (!slug) { @@ -273,10 +277,11 @@ export default function MobileEventTasksPage() { } return ( - navigate(-1)} - rightSlot={ + headerActions={ load()}> @@ -286,9 +291,6 @@ export default function MobileEventTasksPage() { } - footer={ - - } > {error ? ( @@ -734,7 +736,7 @@ export default function MobileEventTasksPage() { > - + ); } diff --git a/resources/js/admin/mobile/EventsPage.tsx b/resources/js/admin/mobile/EventsPage.tsx index 3005d44..d8a8e07 100644 --- a/resources/js/admin/mobile/EventsPage.tsx +++ b/resources/js/admin/mobile/EventsPage.tsx @@ -47,7 +47,7 @@ export default function MobileEventsPage() { } footer={ - + } > {error ? ( diff --git a/resources/js/admin/mobile/QrPrintPage.tsx b/resources/js/admin/mobile/QrPrintPage.tsx index fff0357..67a613c 100644 --- a/resources/js/admin/mobile/QrPrintPage.tsx +++ b/resources/js/admin/mobile/QrPrintPage.tsx @@ -68,7 +68,7 @@ export default function MobileQrPrintPage() { } footer={ - + } > {error ? ( diff --git a/resources/js/admin/mobile/TasksTabPage.tsx b/resources/js/admin/mobile/TasksTabPage.tsx new file mode 100644 index 0000000..5aca479 --- /dev/null +++ b/resources/js/admin/mobile/TasksTabPage.tsx @@ -0,0 +1,79 @@ +import React from 'react'; +import { Navigate, useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { YStack, XStack } from '@tamagui/stacks'; +import { SizableText as Text } from '@tamagui/text'; +import { Pressable } from '@tamagui/react-native-web-lite'; +import { MobileShell } from './components/MobileShell'; +import { MobileCard, CTAButton } from './components/Primitives'; +import { useEventContext } from '../context/EventContext'; +import { formatEventDate, resolveEventDisplayName } from '../lib/events'; +import { adminPath } from '../constants'; + +export default function MobileTasksTabPage() { + const { events, activeEvent, hasEvents, selectEvent } = useEventContext(); + const { t, i18n } = useTranslation('management'); + const navigate = useNavigate(); + + if (activeEvent?.slug) { + return ; + } + + if (!hasEvents) { + return ( + + + + {t('events.tasks.emptyTitle', 'Create an event first')} + + + {t('events.tasks.emptyBody', 'Start an event to add tasks, challenges, and checklists.')} + + navigate(adminPath('/mobile/events/new'))} + /> + + + ); + } + + const locale = i18n.language?.startsWith('en') ? 'en-GB' : 'de-DE'; + + return ( + + + + {t('events.tasks.pickEvent', 'Pick an event to manage tasks')} + + {events.map((event) => ( + { + selectEvent(event.slug ?? null); + if (event.slug) { + navigate(adminPath(`/mobile/events/${event.slug}/tasks`)); + } + }} + > + + + + + {resolveEventDisplayName(event)} + + + {formatEventDate(event.event_date, locale) ?? t('events.status.draft', 'Draft')} + + + + {t('events.actions.open', 'Open')} + + + + + ))} + + + ); +} diff --git a/resources/js/admin/mobile/UploadsTabPage.tsx b/resources/js/admin/mobile/UploadsTabPage.tsx new file mode 100644 index 0000000..df5eb4e --- /dev/null +++ b/resources/js/admin/mobile/UploadsTabPage.tsx @@ -0,0 +1,79 @@ +import React from 'react'; +import { Navigate, useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { YStack, XStack } from '@tamagui/stacks'; +import { SizableText as Text } from '@tamagui/text'; +import { Pressable } from '@tamagui/react-native-web-lite'; +import { MobileShell } from './components/MobileShell'; +import { MobileCard, CTAButton } from './components/Primitives'; +import { useEventContext } from '../context/EventContext'; +import { formatEventDate, resolveEventDisplayName } from '../lib/events'; +import { adminPath } from '../constants'; + +export default function MobileUploadsTabPage() { + const { events, activeEvent, hasEvents, selectEvent } = useEventContext(); + const { t, i18n } = useTranslation('management'); + const navigate = useNavigate(); + + if (activeEvent?.slug) { + return ; + } + + if (!hasEvents) { + return ( + + + + {t('events.photos.emptyTitle', 'Create an event first')} + + + {t('events.photos.emptyBody', 'Add your first event to review uploads and manage QR sharing.')} + + navigate(adminPath('/mobile/events/new'))} + /> + + + ); + } + + const locale = i18n.language?.startsWith('en') ? 'en-GB' : 'de-DE'; + + return ( + + + + {t('events.photos.pickEvent', 'Pick an event to manage uploads')} + + {events.map((event) => ( + { + selectEvent(event.slug ?? null); + if (event.slug) { + navigate(adminPath(`/mobile/events/${event.slug}/photos`)); + } + }} + > + + + + + {resolveEventDisplayName(event)} + + + {formatEventDate(event.event_date, locale) ?? t('events.status.draft', 'Draft')} + + + + {t('events.actions.open', 'Open')} + + + + + ))} + + + ); +} diff --git a/resources/js/admin/mobile/components/BottomNav.tsx b/resources/js/admin/mobile/components/BottomNav.tsx index ab083aa..26a8949 100644 --- a/resources/js/admin/mobile/components/BottomNav.tsx +++ b/resources/js/admin/mobile/components/BottomNav.tsx @@ -2,21 +2,19 @@ import React from 'react'; import { YStack, XStack } from '@tamagui/stacks'; import { SizableText as Text } from '@tamagui/text'; import { Pressable } from '@tamagui/react-native-web-lite'; -import { Home, CheckSquare, Bell, User } from 'lucide-react'; +import { Home, CheckSquare, Image as ImageIcon, User } from 'lucide-react'; import { useTheme } from '@tamagui/core'; import { useTranslation } from 'react-i18next'; -import { useAlertsBadge } from '../hooks/useAlertsBadge'; -export type NavKey = 'events' | 'tasks' | 'alerts' | 'profile'; +export type NavKey = 'home' | 'tasks' | 'uploads' | 'profile'; export function BottomNav({ active, onNavigate }: { active: NavKey; onNavigate: (key: NavKey) => void }) { const { t } = useTranslation('mobile'); const theme = useTheme(); - const { count: alertCount } = useAlertsBadge(); const items: Array<{ key: NavKey; icon: React.ComponentType<{ size?: number; color?: string }>; label: string }> = [ - { key: 'events', icon: Home, label: t('nav.events', 'Events') }, + { key: 'home', icon: Home, label: t('nav.home', 'Home') }, { key: 'tasks', icon: CheckSquare, label: t('nav.tasks', 'Tasks') }, - { key: 'alerts', icon: Bell, label: t('nav.alerts', 'Alerts') }, + { key: 'uploads', icon: ImageIcon, label: t('nav.uploads', 'Uploads') }, { key: 'profile', icon: User, label: t('nav.profile', 'Profile') }, ]; @@ -50,24 +48,6 @@ export function BottomNav({ active, onNavigate }: { active: NavKey; onNavigate: {item.label} - {item.key === 'alerts' && alertCount > 0 ? ( - - - {alertCount > 9 ? '9+' : alertCount} - - - ) : null} ); diff --git a/resources/js/admin/mobile/components/MobileShell.tsx b/resources/js/admin/mobile/components/MobileShell.tsx new file mode 100644 index 0000000..816558b --- /dev/null +++ b/resources/js/admin/mobile/components/MobileShell.tsx @@ -0,0 +1,231 @@ +import React from 'react'; +import { useNavigate } from 'react-router-dom'; +import { ChevronDown, ChevronLeft, Bell, QrCode } 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 { useTranslation } from 'react-i18next'; +import { useEventContext } from '../../context/EventContext'; +import { BottomNav, NavKey } from './BottomNav'; +import { useMobileNav } from '../hooks/useMobileNav'; +import { adminPath } from '../../constants'; +import { MobileSheet } from './Sheet'; +import { MobileCard, PillBadge } from './Primitives'; +import { useAlertsBadge } from '../hooks/useAlertsBadge'; +import { formatEventDate, resolveEventDisplayName } from '../../lib/events'; +import { TenantEvent } from '../../api'; + +type MobileShellProps = { + title?: string; + subtitle?: string; + children: React.ReactNode; + activeTab: NavKey; + onBack?: () => void; + headerActions?: React.ReactNode; +}; + +export function MobileShell({ title, subtitle, children, activeTab, onBack, headerActions }: MobileShellProps) { + const { events, activeEvent, hasMultipleEvents, hasEvents, selectEvent } = useEventContext(); + const { go } = useMobileNav(activeEvent?.slug); + const navigate = useNavigate(); + const { t, i18n } = useTranslation('mobile'); + const { count: alertCount } = useAlertsBadge(); + const [pickerOpen, setPickerOpen] = React.useState(false); + + const locale = i18n.language?.startsWith('en') ? 'en-GB' : 'de-DE'; + const eventTitle = title ?? (activeEvent ? resolveEventDisplayName(activeEvent) : t('header.appName', 'Event Admin')); + const subtitleText = + subtitle ?? + (activeEvent?.event_date + ? formatEventDate(activeEvent.event_date, locale) ?? '' + : hasEvents + ? t('header.selectEvent', 'Select an event to continue') + : t('header.empty', 'Create your first event to get started')); + + const showEventSwitcher = hasMultipleEvents; + const showQr = Boolean(activeEvent?.slug); + + return ( + + + + + {onBack ? ( + + + + + {t('actions.back', 'Back')} + + + + ) : null} + setPickerOpen(true)} + style={{ alignItems: 'flex-start' }} + > + + {eventTitle} + + {subtitleText ? ( + + {subtitleText} + + ) : null} + + {showEventSwitcher ? : null} + + + navigate(adminPath('/mobile/alerts'))}> + + + {alertCount > 0 ? ( + + + {alertCount > 9 ? '9+' : alertCount} + + + ) : null} + + + {showQr ? ( + navigate(adminPath(`/mobile/events/${activeEvent?.slug}/qr`))}> + + + + {t('header.quickQr', 'Quick QR')} + + + + ) : null} + {headerActions ?? null} + + + + + + {children} + + + + + setPickerOpen(false)} + title={t('header.eventSwitcher', 'Choose an event')} + footer={null} + bottomOffsetPx={110} + > + + {events.length === 0 ? ( + + + {t('header.noEventsTitle', 'Create your first event')} + + + {t('header.noEventsBody', 'Start an event to access tasks, uploads, QR posters and more.')} + + navigate(adminPath('/mobile/events/new'))}> + + + {t('header.createEvent', 'Create event')} + + + + + ) : ( + events.map((event) => ( + { + selectEvent(event.slug ?? null); + setPickerOpen(false); + }} + > + + + + {resolveEventDisplayName(event)} + + + {formatEventDate(event.event_date, locale) ?? t('header.noDate', 'Date tbd')} + + + + {event.slug === activeEvent?.slug + ? t('header.active', 'Active') + : (event.status ?? '—')} + + + + )) + )} + {activeEvent ? ( + { + selectEvent(null); + setPickerOpen(false); + }} + > + + {t('header.clearSelection', 'Clear selection')} + + + ) : null} + + + + ); +} + +export function renderEventLocation(event?: TenantEvent | null): string { + if (!event) return 'Location'; + const settings = (event.settings ?? {}) as Record; + const candidate = + (settings.location as string | undefined) ?? + (settings.address as string | undefined) ?? + (settings.city as string | undefined); + if (candidate && candidate.trim()) { + return candidate; + } + return 'Location'; +} diff --git a/resources/js/admin/mobile/hooks/useMobileNav.ts b/resources/js/admin/mobile/hooks/useMobileNav.ts index 5fb2c83..6970b9d 100644 --- a/resources/js/admin/mobile/hooks/useMobileNav.ts +++ b/resources/js/admin/mobile/hooks/useMobileNav.ts @@ -11,20 +11,24 @@ export function useMobileNav(currentSlug?: string | null) { const go = React.useCallback( (key: NavKey) => { - if (key === 'events') { - navigate(adminPath('/mobile/events')); - return; - } if (key === 'tasks') { if (slug) { navigate(adminPath(`/mobile/events/${slug}/tasks`)); } else { - navigate(adminPath('/mobile/events')); + navigate(adminPath('/mobile/tasks')); } return; } - if (key === 'alerts') { - navigate(adminPath('/mobile/alerts')); + if (key === 'uploads') { + if (slug) { + navigate(adminPath(`/mobile/events/${slug}/photos`)); + } else { + navigate(adminPath('/mobile/uploads')); + } + return; + } + if (key === 'home') { + navigate(adminPath('/mobile/dashboard')); return; } if (key === 'profile') { diff --git a/resources/js/admin/router.tsx b/resources/js/admin/router.tsx index c778799..c0b8a25 100644 --- a/resources/js/admin/router.tsx +++ b/resources/js/admin/router.tsx @@ -35,6 +35,8 @@ const MobileAlertsPage = React.lazy(() => import('./mobile/AlertsPage')); const MobileProfilePage = React.lazy(() => import('./mobile/ProfilePage')); const MobileLoginPage = React.lazy(() => import('./mobile/LoginPage')); const MobileDashboardPage = React.lazy(() => import('./mobile/DashboardPage')); +const MobileTasksTabPage = React.lazy(() => import('./mobile/TasksTabPage')); +const MobileUploadsTabPage = React.lazy(() => import('./mobile/UploadsTabPage')); const EngagementPage = React.lazy(() => import('./pages/EngagementPage')); const BillingPage = React.lazy(() => import('./pages/BillingPage')); const TasksPage = React.lazy(() => import('./pages/TasksPage')); @@ -137,6 +139,8 @@ export const router = createBrowserRouter([ { path: 'mobile/alerts', element: }, { path: 'mobile/profile', element: }, { path: 'mobile/dashboard', element: }, + { path: 'mobile/tasks', element: }, + { path: 'mobile/uploads', element: }, { path: 'engagement', element: }, { path: 'tasks', element: }, { path: 'task-collections', element: },