Refine admin PWA layout and tamagui usage
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user