diff --git a/resources/js/admin/i18n/locales/de/dashboard.json b/resources/js/admin/i18n/locales/de/dashboard.json index 191c834..b3dff7e 100644 --- a/resources/js/admin/i18n/locales/de/dashboard.json +++ b/resources/js/admin/i18n/locales/de/dashboard.json @@ -51,6 +51,9 @@ "title": "Bereit für den Eventstart", "description": "Erledige diese Schritte, damit Gäste ohne Reibung loslegen können.", "nextStepTitle": "Nächster Schritt", + "quickSettings": "Event-Einstellungen", + "publishToggle": "Live", + "autoApproveToggle": "Auto", "pending": "Noch offen", "complete": "Erledigt", "items": { diff --git a/resources/js/admin/i18n/locales/en/dashboard.json b/resources/js/admin/i18n/locales/en/dashboard.json index 92d0c93..64ef0dd 100644 --- a/resources/js/admin/i18n/locales/en/dashboard.json +++ b/resources/js/admin/i18n/locales/en/dashboard.json @@ -51,6 +51,9 @@ "title": "Ready for event day", "description": "Complete these steps so guests can join without friction.", "nextStepTitle": "Next step", + "quickSettings": "Event settings", + "publishToggle": "Live", + "autoApproveToggle": "Auto", "pending": "Pending", "complete": "Done", "items": { diff --git a/resources/js/admin/mobile/DashboardPage.tsx b/resources/js/admin/mobile/DashboardPage.tsx index 963d188..a0295f4 100644 --- a/resources/js/admin/mobile/DashboardPage.tsx +++ b/resources/js/admin/mobile/DashboardPage.tsx @@ -11,13 +11,15 @@ import { YStack, XStack } from '@tamagui/stacks'; import { SizableText as Text } from '@tamagui/text'; import { Pressable } from '@tamagui/react-native-web-lite'; import { Image } from '@tamagui/image'; +import { Switch } from '@tamagui/switch'; import { Separator } from 'tamagui'; import { isSameDay, isPast, parseISO, differenceInDays, startOfDay } from 'date-fns'; +import toast from 'react-hot-toast'; import { MobileShell } from './components/MobileShell'; import { ADMIN_EVENTS_PATH, adminPath } from '../constants'; import { useEventContext } from '../context/EventContext'; -import { getEventStats, EventStats, TenantEvent, getEventPhotos, TenantPhoto } from '../api'; +import { getEventStats, EventStats, TenantEvent, getEventPhotos, TenantPhoto, updateEvent } from '../api'; import { formatEventDate } from '../lib/events'; import { useAuth } from '../auth/context'; import { useAdminTheme } from './theme'; @@ -26,6 +28,7 @@ import { withAlpha } from './components/colors'; import { useEventReadiness } from './hooks/useEventReadiness'; import { SetupChecklist } from './components/SetupChecklist'; import { KpiStrip, PillBadge } from './components/Primitives'; +import { getApiErrorMessage } from '../lib/apiError'; // --- HELPERS --- @@ -115,7 +118,7 @@ export default function MobileDashboardPage() { const navigate = useNavigate(); const { slug: slugParam } = useParams<{ slug?: string }>(); const { t, i18n } = useTranslation(['management', 'dashboard', 'mobile']); - const { events, activeEvent, hasEvents, isLoading, selectEvent } = useEventContext(); + const { events, activeEvent, hasEvents, isLoading, selectEvent, refetch } = useEventContext(); const { user } = useAuth(); const theme = useAdminTheme(); const isMember = user?.role === 'member'; @@ -216,6 +219,8 @@ export default function MobileDashboardPage() { navigate={navigate} readiness={readiness} variant="embedded" + canManage={canManageEvents} + onUpdated={refetch} /> {/* 2. PULSE STRIP */} @@ -261,16 +266,31 @@ function getEventPhase(event: TenantEvent): EventPhase { return 'setup'; } -function LifecycleHero({ event, stats, locale, navigate, readiness, variant = 'default' }: any) { +function LifecycleHero({ + event, + stats, + locale, + navigate, + readiness, + variant = 'default', + canManage = false, + onUpdated, +}: any) { const theme = useAdminTheme(); const { t } = useTranslation(['management', 'dashboard']); const isEmbedded = variant === 'embedded'; const cardVariant = isEmbedded ? 'embedded' : 'default'; const cardPadding = isEmbedded ? '$3' : '$3.5'; + const [isPublishing, setIsPublishing] = React.useState(false); + const [isAutoApproving, setIsAutoApproving] = React.useState(false); + const [published, setPublished] = React.useState(false); + const [autoApproveUploads, setAutoApproveUploads] = React.useState(false); if (!event) return null; const phase = getEventPhase(event); const pendingPhotos = stats?.pending_photos ?? event.pending_photo_count ?? 0; + const isPostEvent = phase === 'post'; + const showQuickControls = canManage && !isPostEvent; // Header Row const Header = () => ( @@ -332,6 +352,62 @@ function LifecycleHero({ event, stats, locale, navigate, readiness, variant = 'd } const daysToGo = event.event_date ? differenceInDays(parseISO(event.event_date), new Date()) : 0; + const isSetupPhase = phase === 'setup'; + const nextStep = readiness.nextStep; + const showNextStep = isSetupPhase && Boolean(nextStep); + const showChecklist = isSetupPhase; + + React.useEffect(() => { + setPublished(event.status === 'published'); + const visibility = (event.settings?.guest_upload_visibility as string | undefined) ?? 'review'; + setAutoApproveUploads(visibility === 'immediate'); + }, [event.settings?.guest_upload_visibility, event.status]); + + const handlePublishChange = React.useCallback( + async (checked: boolean) => { + if (!event.slug) return; + const previous = published; + setPublished(checked); + setIsPublishing(true); + try { + await updateEvent(event.slug, { status: checked ? 'published' : 'draft' }); + onUpdated?.(); + } catch (err) { + setPublished(previous); + toast.error( + getApiErrorMessage(err, t('eventForm.errors.saveFailed', 'Event could not be saved.')) + ); + } finally { + setIsPublishing(false); + } + }, + [event.slug, onUpdated, published, t], + ); + + const handleAutoApproveChange = React.useCallback( + async (checked: boolean) => { + if (!event.slug) return; + const previous = autoApproveUploads; + setAutoApproveUploads(checked); + setIsAutoApproving(true); + try { + const nextSettings = { + ...(event.settings ?? {}), + guest_upload_visibility: checked ? 'immediate' : 'review', + }; + await updateEvent(event.slug, { settings: nextSettings }); + onUpdated?.(); + } catch (err) { + setAutoApproveUploads(previous); + toast.error( + getApiErrorMessage(err, t('eventForm.errors.saveFailed', 'Event could not be saved.')) + ); + } finally { + setIsAutoApproving(false); + } + }, + [autoApproveUploads, event.settings, event.slug, onUpdated, t], + ); if (phase === 'post') { return ( @@ -388,12 +464,61 @@ function LifecycleHero({ event, stats, locale, navigate, readiness, variant = 'd } // SETUP - const nextStep = readiness.nextStep; - const showNextStep = Boolean(nextStep); - return (
+ {showQuickControls ? ( + + navigate(adminPath(`/mobile/events/${event.slug}/edit`))}> + + + + {t('dashboard:readiness.quickSettings', 'Event settings')} + + + + + + + + {t('dashboard:readiness.publishToggle', 'Live')} + + handlePublishChange(Boolean(checked))} + size="$2" + disabled={isPublishing} + aria-label={t('eventForm.fields.publish.label', 'Publish immediately')} + > + + + + + + + {t('dashboard:readiness.autoApproveToggle', 'Auto')} + + handleAutoApproveChange(Boolean(checked))} + size="$2" + disabled={isAutoApproving} + aria-label={t('eventForm.fields.uploadVisibility.label', 'Uploads visible immediately')} + > + + + + + + ) : null} @@ -413,7 +538,7 @@ function LifecycleHero({ event, stats, locale, navigate, readiness, variant = 'd - {showNextStep ? ( + {showNextStep && nextStep ? ( {t('dashboard:readiness.nextStepTitle', 'Next step')} @@ -453,12 +578,16 @@ function LifecycleHero({ event, stats, locale, navigate, readiness, variant = 'd ) : null} - - + {showChecklist ? ( + <> + + + + ) : null} diff --git a/resources/js/admin/mobile/__tests__/DashboardPage.test.tsx b/resources/js/admin/mobile/__tests__/DashboardPage.test.tsx index 21dfdb8..821a181 100644 --- a/resources/js/admin/mobile/__tests__/DashboardPage.test.tsx +++ b/resources/js/admin/mobile/__tests__/DashboardPage.test.tsx @@ -230,6 +230,27 @@ vi.mock('@tamagui/text', () => ({ SizableText: ({ children }: { children: React.ReactNode }) => {children}, })); +vi.mock('@tamagui/switch', () => ({ + Switch: Object.assign( + ({ + children, + checked, + onCheckedChange, + }: { + children: React.ReactNode; + checked?: boolean; + onCheckedChange?: (checked: boolean) => void; + }) => ( + + ), + { + Thumb: ({ children }: { children?: React.ReactNode }) => {children}, + }, + ), +})); + vi.mock('@tamagui/react-native-web-lite', () => ({ Pressable: ({ children, onPress }: { children: React.ReactNode; onPress?: () => void }) => (