diff --git a/resources/js/admin/i18n/locales/de/management.json b/resources/js/admin/i18n/locales/de/management.json index 741628d..f3f5ee7 100644 --- a/resources/js/admin/i18n/locales/de/management.json +++ b/resources/js/admin/i18n/locales/de/management.json @@ -334,7 +334,7 @@ "qr": "QR-Code-Layouts", "images": "Bildverwaltung", "liveShow": "Live-Show-Warteschlange", - "liveShowSettings": "Live-Show Einstellungen", + "liveShowSettings": "Live-Show-Einstellungen", "guests": "Gästeverwaltung", "guestMessages": "Gästebenachrichtigungen", "branding": "Branding & Design", @@ -1873,7 +1873,7 @@ "qr": "QR-Code-Layouts", "images": "Bildverwaltung", "liveShow": "Live-Show-Warteschlange", - "liveShowSettings": "Live-Show Einstellungen", + "liveShowSettings": "Live-Show-Einstellungen", "guests": "Gästeverwaltung", "branding": "Branding & Design", "moderation": "Foto-Moderation", @@ -2265,10 +2265,24 @@ "shortcutSettings": "Event-Einstellungen", "shortcutBranding": "Branding & Moderation", "shortcutAnalytics": "Statistiken", + "quickActionsTitle": "Schnellzugriff", "kpiTitle": "Wichtigste Kennzahlen", "kpiTasks": "Offene Tasks", "kpiPhotos": "Fotos", "kpiGuests": "Gäste", + "kpiUnlimited": "{{label}} unbegrenzt", + "kpiRemaining": "{{remaining}} übrig", + "kpiTasksNote": "Aktiv", + "kpiTasksDisabled": "Deaktiviert", + "todayTitle": "Heute im Blick", + "todayUploads": "Uploads (24h)", + "todayPending": "Ausstehend", + "todayLikes": "Likes", + "todayTasks": "Tasks", + "recentActivityTitle": "Letzte Aktivitaeten", + "recentUploads": "Uploads in den letzten 24h", + "recentLikes": "Likes gesamt", + "recentPending": "Ausstehende Moderation", "alertsTitle": "Hinweise", "alertPending": "{{count}} neue Uploads warten auf Freigabe", "alertTasks": "{{count}} Tasks offen oder fällig" @@ -2377,7 +2391,7 @@ "emptyLive": "Keine Fotos für die Live-Show in der Warteschlange." }, "liveShowSettings": { - "title": "Live-Show Einstellungen", + "title": "Live-Show-Einstellungen", "subtitle": "Tempo, Layout und Effekte für die Leinwand feinjustieren.", "link": { "title": "Live-Show-Link", diff --git a/resources/js/admin/i18n/locales/en/management.json b/resources/js/admin/i18n/locales/en/management.json index 71fba36..33c0f5f 100644 --- a/resources/js/admin/i18n/locales/en/management.json +++ b/resources/js/admin/i18n/locales/en/management.json @@ -2269,10 +2269,24 @@ "shortcutSettings": "Event settings", "shortcutBranding": "Branding & moderation", "shortcutAnalytics": "Analytics", + "quickActionsTitle": "Quick actions", "kpiTitle": "Key performance indicators", "kpiTasks": "Open tasks", "kpiPhotos": "Photos", "kpiGuests": "Guests", + "kpiUnlimited": "{{label}} unlimited", + "kpiRemaining": "{{remaining}} remaining", + "kpiTasksNote": "Active", + "kpiTasksDisabled": "Disabled", + "todayTitle": "Today at a glance", + "todayUploads": "Uploads (24h)", + "todayPending": "Pending", + "todayLikes": "Likes", + "todayTasks": "Tasks", + "recentActivityTitle": "Recent activity", + "recentUploads": "Uploads in the last 24h", + "recentLikes": "Total likes", + "recentPending": "Pending moderation", "alertsTitle": "Alerts", "alertPending": "{{count}} new uploads awaiting moderation", "alertTasks": "{{count}} tasks due or open" diff --git a/resources/js/admin/mobile/DashboardPage.tsx b/resources/js/admin/mobile/DashboardPage.tsx index 9555c56..b6401d4 100644 --- a/resources/js/admin/mobile/DashboardPage.tsx +++ b/resources/js/admin/mobile/DashboardPage.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { useLocation, useNavigate, useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { useQuery } from '@tanstack/react-query'; -import { Bell, CalendarDays, Camera, CheckCircle2, ChevronDown, Download, Image as ImageIcon, Layout, ListTodo, MapPin, Megaphone, MessageCircle, Pencil, QrCode, Settings, ShieldCheck, Smartphone, Sparkles, TrendingUp, Tv, Users } from 'lucide-react'; +import { Activity, Bell, CalendarDays, Camera, CheckCircle2, ChevronDown, Download, Image as ImageIcon, Layout, ListTodo, MapPin, Megaphone, MessageCircle, Pencil, QrCode, Settings, ShieldCheck, Smartphone, Sparkles, TrendingUp, Tv, Users } from 'lucide-react'; import { Card } from '@tamagui/card'; import { Progress } from '@tamagui/progress'; import { XGroup, YGroup } from '@tamagui/group'; @@ -26,6 +26,8 @@ import { useAuth } from '../auth/context'; import toast from 'react-hot-toast'; import { ADMIN_ACTION_COLORS, ADMIN_MOTION, useAdminTheme } from './theme'; import { isPastEvent } from './eventDate'; +import { buildLimitWarnings } from '../lib/limitWarnings'; +import { withAlpha } from './components/colors'; type DeviceSetupProps = { installPrompt: ReturnType; @@ -552,29 +554,23 @@ export default function MobileDashboardPage() { ); } - return ( - - {showPackageSummaryBanner && activePackage ? ( - setSummaryOpen(true)} + return ( + + setEventSwitcherOpen(true)} + canEdit={canManageEvents} + onEdit={ + canManageEvents + ? () => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/edit`)) + : undefined + } /> - ) : null} - setEventSwitcherOpen(true)} - canEdit={canManageEvents} - onEdit={ - canManageEvents - ? () => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/edit`)) - : undefined - } - /> navigate(path)} /> + {showPackageSummaryBanner && activePackage ? ( + setSummaryOpen(true)} + /> + ) : null} + + navigate(adminPath('/mobile/settings'))} /> - {tourSheet} - {packageSummarySheet} - {taskDecisionSheet} - setEventSwitcherOpen(false)} - events={effectiveEvents} - locale={locale} - /> - - ); -} + {tourSheet} + {packageSummarySheet} + {taskDecisionSheet} + setEventSwitcherOpen(false)} + events={effectiveEvents} + locale={locale} + /> + + ); + } function PackageSummarySheet({ open, @@ -1369,6 +1373,36 @@ function resolveLocation(event: TenantEvent | null, t: (key: string, fallback: s return t('events.detail.locationPlaceholder', 'Location'); } +function resolveHeroGradient( + event: TenantEvent, + colors: { primary: string; accent: string } +): string { + const descriptor = `${event.event_type?.name ?? ''} ${event.event_type?.slug ?? ''} ${resolveEventDisplayName(event)}`.toLowerCase(); + const palette = [ + { + keys: ['wedding', 'hochzeit', 'wedding day', 'marriage'], + stops: ['#FCE4EC', '#F8D7DA', '#EDE7F6'], + }, + { + keys: ['birthday', 'geburtstag'], + stops: ['#FFE8D6', '#FFD6E8', '#E0F2FE'], + }, + { + keys: ['corporate', 'business', 'company', 'konferenz', 'conference'], + stops: ['#E0E7FF', '#DBEAFE', '#E2E8F0'], + }, + { + keys: ['festival', 'party', 'fest', 'celebration'], + stops: ['#FFE4B5', '#FFEDD5', '#EDE9FE'], + }, + ]; + + const match = palette.find((entry) => entry.keys.some((key) => descriptor.includes(key))); + const stops = match?.stops ?? [colors.primary, colors.accent]; + + return `linear-gradient(135deg, ${withAlpha(stops[0], 0.75)}, ${withAlpha(stops[1], 0.65)}, ${withAlpha(stops[2], 0.55)})`; +} + function EventHeaderCard({ event, locale, @@ -1385,7 +1419,7 @@ function EventHeaderCard({ onEdit?: () => void; }) { const { t } = useTranslation('management'); - const { textStrong, muted, border, surface, accentSoft, primary, shadow } = useAdminTheme(); + const { textStrong, muted, border, surface, accentSoft, primary, accent, shadow } = useAdminTheme(); if (!event) { return null; @@ -1393,6 +1427,16 @@ function EventHeaderCard({ const dateLabel = formatEventDate(event.event_date, locale) ?? t('events.detail.dateTbd', 'Date tbd'); const locationLabel = resolveLocation(event, t); + const eventTypeLabel = event.event_type?.name ?? t('events.detail.typeDefault', 'Event'); + const accentColor = accent || primary; + const heroGradient = resolveHeroGradient(event, { primary, accent: accentColor }); + const statusLabel = + event.status === 'published' + ? t('events.status.published', 'Live') + : event.status === 'archived' + ? t('events.status.archived', 'Archived') + : t('events.status.draft', 'Draft'); + const statusTone = event.status === 'published' ? 'success' : event.status === 'archived' ? 'muted' : 'warning'; return ( - + + + + + {canSwitch ? ( - + {resolveEventDisplayName(event)} ) : ( - + {resolveEventDisplayName(event)} )} - - {event.status === 'published' - ? t('events.status.published', 'Live') - : t('events.status.draft', 'Draft')} + + {statusLabel} - - - - - {dateLabel} - - - - {locationLabel} - + + {eventTypeLabel} + + + + + + {dateLabel} + + + + + + {locationLabel} + + @@ -1643,7 +1702,7 @@ function EventManagementGrid({ backgroundColor={surfaceMuted} > - {t('events.detail.managementTitle', 'Event management')} + {t('mobileDashboard.quickActionsTitle', 'Quick actions')} @@ -1693,17 +1752,31 @@ function KpiStrip({ const { textStrong, muted, border, surface, surfaceMuted, shadow } = useAdminTheme(); const text = textStrong; if (!event) return null; + const photoLimit = event.limits?.photos ?? null; + const guestLimit = event.limits?.guests ?? null; + const resolveLimitNote = (limit: typeof photoLimit, label: string) => { + if (!limit) return null; + if (limit.state === 'unlimited') { + return t('mobileDashboard.kpiUnlimited', '{{label}} unlimited', { label }); + } + if (typeof limit.remaining === 'number') { + return t('mobileDashboard.kpiRemaining', '{{remaining}} remaining', { remaining: limit.remaining }); + } + return null; + }; const kpis = [ { label: t('mobileDashboard.kpiPhotos', 'Photos'), value: stats?.uploads_total ?? event.photo_count ?? '—', icon: ImageIcon, + note: resolveLimitNote(photoLimit, t('mobileDashboard.kpiPhotos', 'Photos')), }, { label: t('mobileDashboard.kpiGuests', 'Guests'), value: event.active_invites_count ?? event.total_invites_count ?? '—', icon: Users, + note: resolveLimitNote(guestLimit, t('mobileDashboard.kpiGuests', 'Guests')), }, ]; @@ -1712,6 +1785,7 @@ function KpiStrip({ label: t('mobileDashboard.kpiTasks', 'Open tasks'), value: event.tasks_count ?? '—', icon: ListTodo, + note: tasksEnabled ? t('mobileDashboard.kpiTasksNote', 'Active') : t('mobileDashboard.kpiTasksDisabled', 'Disabled'), }); } @@ -1755,7 +1829,7 @@ function KpiStrip({ ) : ( {kpis.map((kpi) => ( - + ))} )} @@ -1764,19 +1838,48 @@ function KpiStrip({ ); } +function translateLimits(t: (key: string, defaultValue?: string, options?: Record) => string) { + const defaults: Record = { + photosBlocked: 'Upload limit reached. Buy more photos to continue.', + photosWarning: '{{remaining}} of {{limit}} photos remaining.', + guestsBlocked: 'Guest limit reached.', + guestsWarning: '{{remaining}} of {{limit}} guests remaining.', + galleryExpired: 'Gallery expired. Extend to keep it online.', + galleryWarningHour: 'Gallery expires in {{hours}} hour.', + galleryWarningHours: 'Gallery expires in {{hours}} hours.', + galleryWarningDay: 'Gallery expires in {{days}} day.', + galleryWarningDays: 'Gallery expires in {{days}} days.', + }; + return (key: string, options?: Record) => t(`limits.${key}`, defaults[key] ?? key, options); +} + function AlertsAndHints({ event, stats, tasksEnabled }: { event: TenantEvent | null; stats: EventStats | null | undefined; tasksEnabled: boolean }) { const { t } = useTranslation('management'); - const { textStrong, warningBg, warningBorder, warningText, border, surface, surfaceMuted, shadow } = useAdminTheme(); + const { textStrong, warningBg, warningBorder, warningText, dangerBg, dangerText, border, surface, surfaceMuted, shadow } = useAdminTheme(); const text = textStrong; if (!event) return null; - const alerts: string[] = []; - if (stats?.pending_photos) { - alerts.push(t('mobileDashboard.alertPending', '{{count}} new uploads awaiting moderation', { count: stats.pending_photos })); + const alerts: Array<{ message: string; tone: 'warning' | 'danger' }> = []; + const pendingCount = stats?.pending_photos ?? event.pending_photo_count ?? 0; + if (pendingCount > 0) { + alerts.push({ + message: t('mobileDashboard.alertPending', '{{count}} new uploads awaiting moderation', { count: pendingCount }), + tone: 'warning', + }); } if (tasksEnabled && event.tasks_count) { - alerts.push(t('mobileDashboard.alertTasks', '{{count}} tasks due or open', { count: event.tasks_count })); + alerts.push({ + message: t('mobileDashboard.alertTasks', '{{count}} tasks due or open', { count: event.tasks_count }), + tone: 'warning', + }); } + const limitWarnings = buildLimitWarnings(event.limits ?? null, translateLimits(t as any)); + limitWarnings.forEach((warning) => { + alerts.push({ + message: warning.message, + tone: warning.tone === 'danger' ? 'danger' : 'warning', + }); + }); if (alerts.length === 0) { return null; @@ -1813,15 +1916,197 @@ function AlertsAndHints({ event, stats, tasksEnabled }: { event: TenantEvent | n {alerts.map((alert) => ( - - {alert} + + {alert.message} + + + ))} + + + + ); +} + +function TodayStrip({ + event, + stats, + tasksEnabled, +}: { + event: TenantEvent | null; + stats: EventStats | null | undefined; + tasksEnabled: boolean; +}) { + const { t } = useTranslation('management'); + const { textStrong, muted, border, surface, surfaceMuted, shadow, accentSoft, primary } = useAdminTheme(); + if (!event) return null; + + const items = [ + { + key: 'uploads', + label: t('mobileDashboard.todayUploads', 'Uploads (24h)'), + value: stats?.uploads_24h ?? 0, + icon: ImageIcon, + }, + { + key: 'pending', + label: t('mobileDashboard.todayPending', 'Pending'), + value: stats?.pending_photos ?? event.pending_photo_count ?? 0, + icon: Bell, + }, + { + key: 'likes', + label: t('mobileDashboard.todayLikes', 'Likes'), + value: stats?.likes_total ?? event.like_count ?? 0, + icon: Sparkles, + }, + ]; + + if (tasksEnabled) { + items.unshift({ + key: 'tasks', + label: t('mobileDashboard.todayTasks', 'Tasks'), + value: event.tasks_count ?? 0, + icon: ListTodo, + }); + } + + return ( + + + + + + {t('mobileDashboard.todayTitle', 'Today at a glance')} + + + + + + {items.slice(0, 3).map((item) => ( + + + + + + + {item.label} + + + {item.value} + + + + ))} + + + + ); +} + +function RecentActivity({ event, stats }: { event: TenantEvent | null; stats: EventStats | null | undefined }) { + const { t } = useTranslation('management'); + const { textStrong, muted, border, surface, surfaceMuted, shadow } = useAdminTheme(); + if (!event) return null; + + const activityItems = [ + { + key: 'uploads', + label: t('mobileDashboard.recentUploads', 'Uploads in the last 24h'), + value: stats?.uploads_24h ?? 0, + }, + { + key: 'likes', + label: t('mobileDashboard.recentLikes', 'Total likes'), + value: stats?.likes_total ?? event.like_count ?? 0, + }, + { + key: 'pending', + label: t('mobileDashboard.recentPending', 'Pending moderation'), + value: stats?.pending_photos ?? event.pending_photo_count ?? 0, + }, + ]; + + return ( + + + + + + {t('mobileDashboard.recentActivityTitle', 'Recent activity')} + + + + + {activityItems.map((item) => ( + + + {item.label} + + + {item.value} ))} diff --git a/resources/js/admin/mobile/components/MobileShell.tsx b/resources/js/admin/mobile/components/MobileShell.tsx index 6902e3c..cde76f0 100644 --- a/resources/js/admin/mobile/components/MobileShell.tsx +++ b/resources/js/admin/mobile/components/MobileShell.tsx @@ -1,6 +1,6 @@ import React, { Suspense } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; -import { ChevronLeft, Bell, QrCode } from 'lucide-react'; +import { ChevronLeft, Bell, QrCode, ChevronsUpDown } from 'lucide-react'; import { YStack, XStack } from '@tamagui/stacks'; import { SizableText as Text } from '@tamagui/text'; import { Pressable } from '@tamagui/react-native-web-lite'; @@ -8,7 +8,7 @@ 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 { ADMIN_EVENTS_PATH, adminPath } from '../../constants'; import { MobileCard, CTAButton } from './Primitives'; import { useNotificationsBadge } from '../hooks/useNotificationsBadge'; import { useOnlineStatus } from '../hooks/useOnlineStatus'; @@ -31,7 +31,7 @@ type MobileShellProps = { }; export function MobileShell({ title, subtitle, children, activeTab, onBack, headerActions }: MobileShellProps) { - const { events, activeEvent, selectEvent } = useEventContext(); + const { events, activeEvent, hasMultipleEvents, selectEvent } = useEventContext(); const { user } = useAuth(); const { go } = useMobileNav(activeEvent?.slug, activeTab); const navigate = useNavigate(); @@ -137,7 +137,13 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head }, []); const pageTitle = title ?? t('header.appName', 'Event Admin'); - const eventContext = !isCompactHeader && effectiveActive ? resolveEventDisplayName(effectiveActive) : null; + const eventContext = !isCompactHeader + ? effectiveActive + ? resolveEventDisplayName(effectiveActive) + : hasMultipleEvents + ? t('header.selectEvent', 'Select an event') + : null + : null; const subtitleText = subtitle ?? eventContext ?? ''; const isMember = user?.role === 'member'; const memberPermissions = Array.isArray(effectiveActive?.member_permissions) ? effectiveActive?.member_permissions ?? [] : []; @@ -164,22 +170,55 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head ) : ( ); - const headerTitle = ( - - - - {pageTitle} + const headerTitleRight = ( + + + {pageTitle} + + {subtitleText ? ( + + {subtitleText} - {subtitleText ? ( - - {subtitleText} - - ) : null} - - + ) : null} + ); + const headerTitleCenter = ( + + + {pageTitle} + + {subtitleText ? ( + + {subtitleText} + + ) : null} + + ); + const isEventsIndex = location.pathname === ADMIN_EVENTS_PATH; + const canSwitchEvents = hasMultipleEvents && !isEventsIndex; const headerActionsRow = ( + {canSwitchEvents ? ( + navigate(ADMIN_EVENTS_PATH)} ariaLabel={t('header.switchEvent', 'Switch event')}> + + + + + ) : null} navigate(adminPath('/mobile/notifications'))} ariaLabel={t('mobile.notifications', 'Notifications')} @@ -273,22 +312,28 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head }} > {isCompactHeader ? ( - - - {headerBackButton} - - {headerTitle} - + + {headerBackButton} + + {headerTitleCenter} - + {headerActionsRow} - + ) : ( {headerBackButton} - {headerTitle} + + {headerTitleRight} + {headerActionsRow} diff --git a/resources/js/admin/mobile/components/Primitives.tsx b/resources/js/admin/mobile/components/Primitives.tsx index c02426b..bb612dc 100644 --- a/resources/js/admin/mobile/components/Primitives.tsx +++ b/resources/js/admin/mobile/components/Primitives.tsx @@ -152,10 +152,12 @@ export function KpiTile({ icon: IconCmp, label, value, + note, }: { icon: React.ComponentType<{ size?: number; color?: string }>; label: string; value: string | number; + note?: string; }) { const { accentSoft, primary, text } = useAdminTheme(); return ( @@ -178,6 +180,11 @@ export function KpiTile({ {value} + {note ? ( + + {note} + + ) : null} ); } diff --git a/resources/js/admin/mobile/components/__tests__/MobileShell.test.tsx b/resources/js/admin/mobile/components/__tests__/MobileShell.test.tsx index 1254729..37c658a 100644 --- a/resources/js/admin/mobile/components/__tests__/MobileShell.test.tsx +++ b/resources/js/admin/mobile/components/__tests__/MobileShell.test.tsx @@ -42,14 +42,16 @@ vi.mock('../BottomNav', () => ({ NavKey: {}, })); +const eventContext = { + events: [], + activeEvent: { slug: 'event-1', name: 'Test Event', event_date: '2024-01-01', status: 'active', settings: {} }, + hasMultipleEvents: false, + hasEvents: true, + selectEvent: vi.fn(), +}; + vi.mock('../../../context/EventContext', () => ({ - useEventContext: () => ({ - events: [], - activeEvent: { slug: 'event-1', name: 'Test Event', event_date: '2024-01-01', status: 'active', settings: {} }, - hasMultipleEvents: false, - hasEvents: true, - selectEvent: vi.fn(), - }), + useEventContext: () => eventContext, })); vi.mock('../../../auth/context', () => ({ @@ -105,6 +107,7 @@ vi.mock('../../theme', () => ({ })); import { MobileShell } from '../MobileShell'; +import { ADMIN_EVENTS_PATH } from '../../../constants'; describe('MobileShell', () => { beforeEach(() => { @@ -113,6 +116,9 @@ describe('MobileShell', () => { addEventListener: vi.fn(), removeEventListener: vi.fn(), }); + eventContext.events = []; + eventContext.hasMultipleEvents = false; + eventContext.activeEvent = { slug: 'event-1', name: 'Test Event', event_date: '2024-01-01', status: 'active', settings: {} }; }); it('renders quick QR as icon-only button', async () => { @@ -149,4 +155,44 @@ describe('MobileShell', () => { expect(screen.queryByText('Test Event')).not.toBeInTheDocument(); }); + + it('shows the event switcher when multiple events are available', async () => { + eventContext.hasMultipleEvents = true; + eventContext.events = [ + { slug: 'event-1', name: 'Test Event', event_date: '2024-01-01', status: 'active', settings: {} }, + { slug: 'event-2', name: 'Second Event', event_date: '2024-02-01', status: 'active', settings: {} }, + ]; + + await act(async () => { + render( + + +
Body
+
+
+ ); + }); + + expect(screen.getByLabelText('Switch event')).toBeInTheDocument(); + }); + + it('hides the event switcher on the events list page', async () => { + eventContext.hasMultipleEvents = true; + eventContext.events = [ + { slug: 'event-1', name: 'Test Event', event_date: '2024-01-01', status: 'active', settings: {} }, + { slug: 'event-2', name: 'Second Event', event_date: '2024-02-01', status: 'active', settings: {} }, + ]; + + await act(async () => { + render( + + +
Body
+
+
+ ); + }); + + expect(screen.queryByLabelText('Switch event')).not.toBeInTheDocument(); + }); });