Add hero quick settings toggles
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

This commit is contained in:
Codex Agent
2026-01-22 17:30:51 +01:00
parent 2e089f7f77
commit 5aa79b587d
4 changed files with 169 additions and 13 deletions

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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 (
<YStack space="$2">
<Header />
{showQuickControls ? (
<XStack
alignItems="center"
justifyContent="space-between"
borderWidth={1}
borderColor={theme.border}
borderRadius={12}
paddingHorizontal="$2.5"
paddingVertical="$2"
>
<Pressable onPress={() => navigate(adminPath(`/mobile/events/${event.slug}/edit`))}>
<XStack alignItems="center" space="$1.5">
<Settings size={16} color={theme.primary} />
<Text fontSize="$sm" fontWeight="700" color={theme.textStrong}>
{t('dashboard:readiness.quickSettings', 'Event settings')}
</Text>
</XStack>
</Pressable>
<XStack alignItems="center" space="$3">
<YStack alignItems="center" space="$1">
<Text fontSize="$xs" color={theme.muted} textTransform="uppercase" letterSpacing={0.8}>
{t('dashboard:readiness.publishToggle', 'Live')}
</Text>
<Switch
checked={published}
onCheckedChange={(checked) => handlePublishChange(Boolean(checked))}
size="$2"
disabled={isPublishing}
aria-label={t('eventForm.fields.publish.label', 'Publish immediately')}
>
<Switch.Thumb />
</Switch>
</YStack>
<YStack alignItems="center" space="$1">
<Text fontSize="$xs" color={theme.muted} textTransform="uppercase" letterSpacing={0.8}>
{t('dashboard:readiness.autoApproveToggle', 'Auto')}
</Text>
<Switch
checked={autoApproveUploads}
onCheckedChange={(checked) => handleAutoApproveChange(Boolean(checked))}
size="$2"
disabled={isAutoApproving}
aria-label={t('eventForm.fields.uploadVisibility.label', 'Uploads visible immediately')}
>
<Switch.Thumb />
</Switch>
</YStack>
</XStack>
</XStack>
) : null}
<DashboardCard variant={cardVariant} padding={cardPadding}>
<YStack space={isEmbedded ? '$2.5' : '$3'}>
<XStack alignItems="center" justifyContent="space-between">
@@ -413,7 +538,7 @@ function LifecycleHero({ event, stats, locale, navigate, readiness, variant = 'd
</YStack>
</XStack>
{showNextStep ? (
{showNextStep && nextStep ? (
<YStack space="$2">
<Text fontSize="$xs" fontWeight="700" color={theme.muted} textTransform="uppercase" letterSpacing={1}>
{t('dashboard:readiness.nextStepTitle', 'Next step')}
@@ -453,12 +578,16 @@ function LifecycleHero({ event, stats, locale, navigate, readiness, variant = 'd
</YStack>
) : null}
<Separator backgroundColor={theme.border} opacity={0.6} />
<SetupChecklist
steps={readiness.steps}
title={t('dashboard:readiness.title', 'Bereit für den Eventstart')}
variant="inline"
/>
{showChecklist ? (
<>
<Separator backgroundColor={theme.border} opacity={0.6} />
<SetupChecklist
steps={readiness.steps}
title={t('dashboard:readiness.title', 'Bereit für den Eventstart')}
variant="inline"
/>
</>
) : null}
</YStack>
</DashboardCard>
</YStack>

View File

@@ -230,6 +230,27 @@ vi.mock('@tamagui/text', () => ({
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
}));
vi.mock('@tamagui/switch', () => ({
Switch: Object.assign(
({
children,
checked,
onCheckedChange,
}: {
children: React.ReactNode;
checked?: boolean;
onCheckedChange?: (checked: boolean) => void;
}) => (
<button type="button" onClick={() => onCheckedChange?.(!checked)}>
{children}
</button>
),
{
Thumb: ({ children }: { children?: React.ReactNode }) => <span>{children}</span>,
},
),
}));
vi.mock('@tamagui/react-native-web-lite', () => ({
Pressable: ({ children, onPress }: { children: React.ReactNode; onPress?: () => void }) => (
<button type="button" onClick={onPress}>