Refine admin PWA layout and tamagui usage

This commit is contained in:
Codex Agent
2026-01-15 22:24:10 +01:00
parent 8941860140
commit c533d43c0f
37 changed files with 51503 additions and 21989 deletions

View File

@@ -3,6 +3,9 @@ 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 { Card } from '@tamagui/card';
import { Progress } from '@tamagui/progress';
import { XGroup, YGroup } from '@tamagui/group';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite';
@@ -11,7 +14,7 @@ import { MobileCard, CTAButton, KpiTile, ActionTile, PillBadge, SkeletonCard } f
import { MobileSheet } from './components/Sheet';
import { adminPath, ADMIN_WELCOME_BASE_PATH } from '../constants';
import { useEventContext } from '../context/EventContext';
import { fetchOnboardingStatus, getEventStats, EventStats, TenantEvent, getEvents, getTenantPackagesOverview } from '../api';
import { fetchOnboardingStatus, getEventStats, EventStats, TenantEvent, getEvents, getTenantPackagesOverview, TenantPackageSummary } from '../api';
import { formatEventDate, isBrandingAllowed, resolveEngagementMode, resolveEventDisplayName } from '../lib/events';
import { useAdminPushSubscription } from './hooks/useAdminPushSubscription';
import { useDevicePermissions } from './hooks/useDevicePermissions';
@@ -405,9 +408,9 @@ export default function MobileDashboardPage() {
if (!effectiveHasEvents) {
return (
<MobileShell activeTab="home" title={t('mobileDashboard.title', 'Dashboard')}>
{showPackageSummaryBanner ? (
{showPackageSummaryBanner && activePackage ? (
<PackageSummaryBanner
packageName={activePackage?.package_name}
activePackage={activePackage}
onOpen={() => setSummaryOpen(true)}
/>
) : null}
@@ -430,9 +433,9 @@ export default function MobileDashboardPage() {
title={t('mobileDashboard.title', 'Dashboard')}
subtitle={t('mobileDashboard.selectEvent', 'Select an event to continue')}
>
{showPackageSummaryBanner ? (
{showPackageSummaryBanner && activePackage ? (
<PackageSummaryBanner
packageName={activePackage?.package_name}
activePackage={activePackage}
onOpen={() => setSummaryOpen(true)}
/>
) : null}
@@ -448,9 +451,9 @@ export default function MobileDashboardPage() {
activeTab="home"
title={t('mobileDashboard.title', 'Dashboard')}
>
{showPackageSummaryBanner ? (
{showPackageSummaryBanner && activePackage ? (
<PackageSummaryBanner
packageName={activePackage?.package_name}
activePackage={activePackage}
onOpen={() => setSummaryOpen(true)}
/>
) : null}
@@ -620,18 +623,46 @@ function SummaryRow({ label, value }: { label: string; value: string }) {
}
function PackageSummaryBanner({
packageName,
activePackage,
onOpen,
}: {
packageName?: string | null;
activePackage: TenantPackageSummary;
onOpen: () => void;
}) {
const { t } = useTranslation('management');
const { textStrong, muted, border, surface, accentSoft, primary } = useAdminTheme();
const { textStrong, muted, border, surface, accentSoft, primary, surfaceMuted, shadow } = useAdminTheme();
const text = textStrong;
const packageName = activePackage.package_name ?? t('mobileDashboard.packageSummary.fallbackTitle', 'Package summary');
const remainingEvents = typeof activePackage.remaining_events === 'number' ? activePackage.remaining_events : null;
const hasLimit = remainingEvents !== null;
const totalEvents = hasLimit ? activePackage.used_events + remainingEvents : null;
const showProgress = hasLimit && (totalEvents ?? 0) > 0;
const usageLabel = hasLimit
? t('mobileDashboard.packageSummary.bannerUsage', '{{used}} of {{total}} events used', {
used: activePackage.used_events,
total: totalEvents ?? 0,
})
: t('mobileDashboard.packageSummary.bannerUnlimited', 'Unlimited events');
const remainingLabel = hasLimit
? t('mobileDashboard.packageSummary.bannerRemaining', '{{count}} remaining', { count: remainingEvents })
: t('mobileDashboard.packageSummary.bannerRemainingUnlimited', 'No limit');
const progressMax = totalEvents ?? 0;
const progressValue = Math.min(activePackage.used_events, progressMax);
return (
<MobileCard space="$2" borderColor={border} backgroundColor={surface}>
<Card
className="admin-fade-up"
space="$2"
borderRadius={20}
borderWidth={2}
borderColor={border}
backgroundColor={surface}
padding="$3"
shadowColor={shadow}
shadowOpacity={0.16}
shadowRadius={16}
shadowOffset={{ width: 0, height: 10 }}
>
<XStack alignItems="center" justifyContent="space-between" gap="$2">
<XStack alignItems="center" space="$2" flex={1}>
<XStack
@@ -650,7 +681,7 @@ function PackageSummaryBanner({
</Text>
<Text fontSize="$xs" color={muted}>
{t('mobileDashboard.packageSummary.bannerSubtitle', {
name: packageName ?? t('mobileDashboard.packageSummary.fallbackTitle', 'Package summary'),
name: packageName,
defaultValue: '{{name}} is active. Review limits & features.',
})}
</Text>
@@ -663,7 +694,29 @@ function PackageSummaryBanner({
onPress={onOpen}
/>
</XStack>
</MobileCard>
<YStack space="$1.5">
<XStack alignItems="center" justifyContent="space-between" gap="$2">
<Text fontSize="$xs" color={muted}>
{usageLabel}
</Text>
<Text fontSize="$xs" color={muted}>
{remainingLabel}
</Text>
</XStack>
{showProgress ? (
<Progress
value={progressValue}
max={progressMax}
size="$2"
backgroundColor={surfaceMuted}
borderWidth={1}
borderColor={border}
>
<Progress.Indicator backgroundColor={primary} />
</Progress>
) : null}
</YStack>
</Card>
);
}
@@ -1112,7 +1165,7 @@ function EventHeaderCard({
onEdit: () => void;
}) {
const { t } = useTranslation('management');
const { textStrong, muted, border, surface, accentSoft, primary } = useAdminTheme();
const { textStrong, muted, border, surface, accentSoft, primary, shadow } = useAdminTheme();
if (!event) {
return null;
@@ -1122,39 +1175,52 @@ function EventHeaderCard({
const locationLabel = resolveLocation(event, t);
return (
<MobileCard space="$3" borderColor={border} backgroundColor={surface} position="relative">
<XStack alignItems="center" justifyContent="space-between" space="$2">
{canSwitch ? (
<Pressable onPress={onSwitch} aria-label={t('mobileDashboard.pickEvent', 'Select an event')}>
<XStack alignItems="center" space="$2">
<Text fontSize="$lg" fontWeight="800" color={textStrong}>
{resolveEventDisplayName(event)}
</Text>
<ChevronDown size={16} color={muted} />
</XStack>
</Pressable>
) : (
<Text fontSize="$lg" fontWeight="800" color={textStrong}>
{resolveEventDisplayName(event)}
</Text>
)}
<PillBadge tone={event.status === 'published' ? 'success' : 'warning'}>
{event.status === 'published'
? t('events.status.published', 'Live')
: t('events.status.draft', 'Draft')}
</PillBadge>
</XStack>
<Card
position="relative"
borderRadius={20}
borderWidth={2}
borderColor={border}
backgroundColor={surface}
padding="$3"
shadowColor={shadow}
shadowOpacity={0.16}
shadowRadius={16}
shadowOffset={{ width: 0, height: 10 }}
>
<YStack space="$2.5">
<XStack alignItems="center" justifyContent="space-between" space="$2">
{canSwitch ? (
<Pressable onPress={onSwitch} aria-label={t('mobileDashboard.pickEvent', 'Select an event')}>
<XStack alignItems="center" space="$2">
<Text fontSize="$lg" fontWeight="800" color={textStrong}>
{resolveEventDisplayName(event)}
</Text>
<ChevronDown size={16} color={muted} />
</XStack>
</Pressable>
) : (
<Text fontSize="$lg" fontWeight="800" color={textStrong}>
{resolveEventDisplayName(event)}
</Text>
)}
<PillBadge tone={event.status === 'published' ? 'success' : 'warning'}>
{event.status === 'published'
? t('events.status.published', 'Live')
: t('events.status.draft', 'Draft')}
</PillBadge>
</XStack>
<XStack alignItems="center" space="$2">
<CalendarDays size={16} color={muted} />
<Text fontSize="$sm" color={muted}>
{dateLabel}
</Text>
<MapPin size={16} color={muted} />
<Text fontSize="$sm" color={muted}>
{locationLabel}
</Text>
</XStack>
<XStack alignItems="center" space="$2">
<CalendarDays size={16} color={muted} />
<Text fontSize="$sm" color={muted}>
{dateLabel}
</Text>
<MapPin size={16} color={muted} />
<Text fontSize="$sm" color={muted}>
{locationLabel}
</Text>
</XStack>
</YStack>
<Pressable
aria-label={t('mobileEvents.edit', 'Edit event')}
@@ -1174,7 +1240,7 @@ function EventHeaderCard({
>
<Pencil size={18} color={primary} />
</Pressable>
</MobileCard>
</Card>
);
}
@@ -1188,7 +1254,7 @@ function EventManagementGrid({
onNavigate: (path: string) => void;
}) {
const { t } = useTranslation('management');
const { textStrong } = useAdminTheme();
const { textStrong, border, surface, surfaceMuted, shadow } = useAdminTheme();
const slug = event?.slug ?? null;
const brandingAllowed = isBrandingAllowed(event ?? null);
@@ -1287,25 +1353,67 @@ function EventManagementGrid({
});
}
const rows: typeof tiles[] = [];
tiles.forEach((tile, index) => {
const rowIndex = Math.floor(index / 2);
if (!rows[rowIndex]) {
rows[rowIndex] = [];
}
rows[rowIndex].push(tile);
});
return (
<YStack space="$2">
<Text fontSize="$sm" fontWeight="800" color={textStrong}>
{t('events.detail.managementTitle', 'Event management')}
</Text>
<XStack flexWrap="wrap" space="$2">
{tiles.map((tile, index) => (
<ActionTile
key={tile.label}
icon={tile.icon}
label={tile.label}
color={tile.color}
onPress={tile.onPress}
disabled={tile.disabled}
delayMs={index * ADMIN_MOTION.tileStaggerMs}
/>
))}
</XStack>
</YStack>
<Card
borderRadius={22}
borderWidth={2}
borderColor={border}
backgroundColor={surface}
padding="$3"
shadowColor={shadow}
shadowOpacity={0.14}
shadowRadius={14}
shadowOffset={{ width: 0, height: 8 }}
>
<YStack space="$2.5">
<XStack alignItems="center" justifyContent="space-between">
<XStack
alignItems="center"
paddingHorizontal="$3"
paddingVertical="$1.5"
borderRadius={999}
borderWidth={1}
borderColor={border}
backgroundColor={surfaceMuted}
>
<Text fontSize="$xs" fontWeight="800" color={textStrong}>
{t('events.detail.managementTitle', 'Event management')}
</Text>
</XStack>
</XStack>
<YGroup space="$2">
{rows.map((row, rowIndex) => (
<YGroup.Item key={`row-${rowIndex}`}>
<XGroup space="$2">
{row.map((tile, index) => (
<XGroup.Item key={tile.label}>
<ActionTile
icon={tile.icon}
label={tile.label}
color={tile.color}
onPress={tile.onPress}
disabled={tile.disabled}
variant="cluster"
delayMs={(rowIndex * 2 + index) * ADMIN_MOTION.tileStaggerMs}
/>
</XGroup.Item>
))}
{row.length === 1 ? <XStack flex={1} /> : null}
</XGroup>
</YGroup.Item>
))}
</YGroup>
</YStack>
</Card>
);
}
@@ -1323,7 +1431,7 @@ function KpiStrip({
tasksEnabled: boolean;
}) {
const { t } = useTranslation('management');
const { textStrong, muted } = useAdminTheme();
const { textStrong, muted, border, surface, surfaceMuted, shadow } = useAdminTheme();
const text = textStrong;
if (!event) return null;
@@ -1349,33 +1457,57 @@ function KpiStrip({
}
return (
<YStack space="$2">
<Text fontSize="$sm" fontWeight="800" color={text}>
{t('mobileDashboard.kpiTitle', 'Key performance indicators')}
</Text>
{loading ? (
<XStack space="$2" flexWrap="wrap">
{Array.from({ length: 3 }).map((_, idx) => (
<MobileCard key={`kpi-${idx}`} height={90} width="32%" />
))}
<Card
borderRadius={22}
borderWidth={2}
borderColor={border}
backgroundColor={surface}
padding="$3"
shadowColor={shadow}
shadowOpacity={0.14}
shadowRadius={14}
shadowOffset={{ width: 0, height: 8 }}
>
<YStack space="$2.5">
<XStack alignItems="center" justifyContent="space-between" gap="$2">
<XStack
alignItems="center"
paddingHorizontal="$3"
paddingVertical="$1.5"
borderRadius={999}
borderWidth={1}
borderColor={border}
backgroundColor={surfaceMuted}
>
<Text fontSize="$xs" fontWeight="800" color={text}>
{t('mobileDashboard.kpiTitle', 'Key performance indicators')}
</Text>
</XStack>
<Text fontSize="$xs" color={muted}>
{formatEventDate(event.event_date, locale) ?? ''}
</Text>
</XStack>
) : (
<XStack space="$2" flexWrap="wrap">
{kpis.map((kpi) => (
<KpiTile key={kpi.label} icon={kpi.icon} label={kpi.label} value={kpi.value ?? '—'} />
))}
</XStack>
)}
<Text fontSize="$xs" color={muted}>
{formatEventDate(event.event_date, locale) ?? ''}
</Text>
</YStack>
{loading ? (
<XStack space="$2" flexWrap="wrap">
{Array.from({ length: 3 }).map((_, idx) => (
<MobileCard key={`kpi-${idx}`} height={90} width="32%" />
))}
</XStack>
) : (
<XStack space="$2" flexWrap="wrap">
{kpis.map((kpi) => (
<KpiTile key={kpi.label} icon={kpi.icon} label={kpi.label} value={kpi.value ?? '—'} />
))}
</XStack>
)}
</YStack>
</Card>
);
}
function AlertsAndHints({ event, stats, tasksEnabled }: { event: TenantEvent | null; stats: EventStats | null | undefined; tasksEnabled: boolean }) {
const { t } = useTranslation('management');
const { textStrong, warningBg, warningBorder, warningText } = useAdminTheme();
const { textStrong, warningBg, warningBorder, warningText, border, surface, surfaceMuted, shadow } = useAdminTheme();
const text = textStrong;
if (!event) return null;
@@ -1392,17 +1524,50 @@ function AlertsAndHints({ event, stats, tasksEnabled }: { event: TenantEvent | n
}
return (
<YStack space="$1.5">
<Text fontSize="$sm" fontWeight="800" color={text}>
{t('mobileDashboard.alertsTitle', 'Alerts')}
</Text>
{alerts.map((alert) => (
<MobileCard key={alert} backgroundColor={warningBg} borderColor={warningBorder} space="$2">
<Text fontSize="$sm" color={warningText}>
{alert}
</Text>
</MobileCard>
))}
</YStack>
<Card
borderRadius={22}
borderWidth={2}
borderColor={border}
backgroundColor={surface}
padding="$3"
shadowColor={shadow}
shadowOpacity={0.14}
shadowRadius={14}
shadowOffset={{ width: 0, height: 8 }}
>
<YStack space="$2.5">
<XStack alignItems="center" justifyContent="space-between">
<XStack
alignItems="center"
paddingHorizontal="$3"
paddingVertical="$1.5"
borderRadius={999}
borderWidth={1}
borderColor={border}
backgroundColor={surfaceMuted}
>
<Text fontSize="$xs" fontWeight="800" color={text}>
{t('mobileDashboard.alertsTitle', 'Alerts')}
</Text>
</XStack>
</XStack>
<YStack space="$2">
{alerts.map((alert) => (
<XStack
key={alert}
padding="$2.5"
borderRadius={16}
borderWidth={1}
borderColor={warningBorder}
backgroundColor={warningBg}
>
<Text fontSize="$sm" color={warningText}>
{alert}
</Text>
</XStack>
))}
</YStack>
</YStack>
</Card>
);
}