Refine dashboard overview layout
This commit is contained in:
@@ -35,19 +35,25 @@ function translateLimits(t: any) {
|
|||||||
|
|
||||||
// --- TAMAGUI-ALIGNED PRIMITIVES ---
|
// --- TAMAGUI-ALIGNED PRIMITIVES ---
|
||||||
|
|
||||||
function DashboardCard({ children, style, ...rest }: React.ComponentProps<typeof Card>) {
|
function DashboardCard({
|
||||||
|
children,
|
||||||
|
style,
|
||||||
|
variant = 'default',
|
||||||
|
...rest
|
||||||
|
}: React.ComponentProps<typeof Card> & { variant?: 'default' | 'embedded' }) {
|
||||||
const theme = useAdminTheme();
|
const theme = useAdminTheme();
|
||||||
|
const isEmbedded = variant === 'embedded';
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
backgroundColor={theme.surface}
|
backgroundColor={theme.surface}
|
||||||
borderRadius={20}
|
borderRadius={isEmbedded ? 16 : 20}
|
||||||
borderWidth={1}
|
borderWidth={1}
|
||||||
borderColor={theme.border}
|
borderColor={theme.border}
|
||||||
padding="$3.5"
|
padding="$3.5"
|
||||||
shadowColor={theme.shadow}
|
shadowColor={theme.shadow}
|
||||||
shadowOpacity={0.16}
|
shadowOpacity={isEmbedded ? 0 : 0.16}
|
||||||
shadowRadius={16}
|
shadowRadius={isEmbedded ? 0 : 16}
|
||||||
shadowOffset={{ width: 0, height: 10 }}
|
shadowOffset={isEmbedded ? { width: 0, height: 0 } : { width: 0, height: 10 }}
|
||||||
style={style}
|
style={style}
|
||||||
{...rest}
|
{...rest}
|
||||||
>
|
>
|
||||||
@@ -60,26 +66,33 @@ function SectionHeader({
|
|||||||
title,
|
title,
|
||||||
subtitle,
|
subtitle,
|
||||||
action,
|
action,
|
||||||
|
showSeparator = true,
|
||||||
|
compact = false,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
action?: React.ReactNode;
|
action?: React.ReactNode;
|
||||||
|
showSeparator?: boolean;
|
||||||
|
compact?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const theme = useAdminTheme();
|
const theme = useAdminTheme();
|
||||||
|
const titleSize = compact ? '$md' : '$lg';
|
||||||
|
const subtitleSize = compact ? '$xs' : '$sm';
|
||||||
|
const spacing = compact ? '$1' : '$1.5';
|
||||||
return (
|
return (
|
||||||
<YStack space="$1.5">
|
<YStack space={spacing}>
|
||||||
<XStack alignItems="center" justifyContent="space-between">
|
<XStack alignItems="center" justifyContent="space-between">
|
||||||
<Text fontSize="$lg" fontWeight="800" color={theme.textStrong}>
|
<Text fontSize={titleSize} fontWeight="800" color={theme.textStrong}>
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
{action ?? null}
|
{action ?? null}
|
||||||
</XStack>
|
</XStack>
|
||||||
{subtitle ? (
|
{subtitle ? (
|
||||||
<Text fontSize="$sm" color={theme.muted}>
|
<Text fontSize={subtitleSize} color={theme.muted}>
|
||||||
{subtitle}
|
{subtitle}
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
<Separator backgroundColor={theme.border} opacity={0.6} />
|
{showSeparator ? <Separator backgroundColor={theme.border} opacity={0.6} /> : null}
|
||||||
</YStack>
|
</YStack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -104,6 +117,7 @@ export default function MobileDashboardPage() {
|
|||||||
const { t, i18n } = useTranslation(['management', 'dashboard', 'mobile']);
|
const { t, i18n } = useTranslation(['management', 'dashboard', 'mobile']);
|
||||||
const { events, activeEvent, hasEvents, isLoading, selectEvent } = useEventContext();
|
const { events, activeEvent, hasEvents, isLoading, selectEvent } = useEventContext();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
const theme = useAdminTheme();
|
||||||
const isMember = user?.role === 'member';
|
const isMember = user?.role === 'member';
|
||||||
|
|
||||||
// --- LOGIC ---
|
// --- LOGIC ---
|
||||||
@@ -184,30 +198,40 @@ export default function MobileDashboardPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<MobileShell activeTab="home" title={t('mobileDashboard.title', 'Dashboard')}>
|
<MobileShell activeTab="home" title={t('mobileDashboard.title', 'Dashboard')}>
|
||||||
<SectionHeader
|
<DashboardCard padding="$0">
|
||||||
title={t('dashboard:overview.title', 'At a glance')}
|
<YStack padding="$3.5" space="$2">
|
||||||
subtitle={t('dashboard:overview.description', 'Key customer metrics at a glance.')}
|
<SectionHeader
|
||||||
/>
|
title={t('dashboard:overview.title', 'At a glance')}
|
||||||
|
subtitle={t('dashboard:overview.description', 'Key customer metrics at a glance.')}
|
||||||
{/* 1. LIFECYCLE HERO */}
|
showSeparator={false}
|
||||||
<LifecycleHero
|
compact
|
||||||
event={activeEvent}
|
/>
|
||||||
stats={stats}
|
</YStack>
|
||||||
locale={locale}
|
<Separator backgroundColor={theme.border} opacity={0.6} />
|
||||||
navigate={navigate}
|
<YStack padding="$3.5" space="$2.5">
|
||||||
readiness={readiness}
|
{/* 1. LIFECYCLE HERO */}
|
||||||
/>
|
<LifecycleHero
|
||||||
|
event={activeEvent}
|
||||||
|
stats={stats}
|
||||||
|
locale={locale}
|
||||||
|
navigate={navigate}
|
||||||
|
readiness={readiness}
|
||||||
|
variant="embedded"
|
||||||
|
/>
|
||||||
|
|
||||||
{/* 1b. SETUP CHECKLIST */}
|
{/* 1b. SETUP CHECKLIST */}
|
||||||
{phase === 'setup' && (
|
{phase === 'setup' && (
|
||||||
<SetupChecklist
|
<SetupChecklist
|
||||||
steps={readiness.steps}
|
steps={readiness.steps}
|
||||||
title={t('management:photobooth.checklist.title', 'Checklist')}
|
title={t('management:photobooth.checklist.title', 'Checklist')}
|
||||||
/>
|
variant="embedded"
|
||||||
)}
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 2. PULSE STRIP */}
|
{/* 2. PULSE STRIP */}
|
||||||
<PulseStrip event={activeEvent} stats={stats} />
|
<PulseStrip event={activeEvent} stats={stats} />
|
||||||
|
</YStack>
|
||||||
|
</DashboardCard>
|
||||||
|
|
||||||
{/* 3. ALERTS */}
|
{/* 3. ALERTS */}
|
||||||
<AlertsSection event={activeEvent} stats={stats} t={t} />
|
<AlertsSection event={activeEvent} stats={stats} t={t} />
|
||||||
@@ -250,9 +274,12 @@ function getEventPhase(event: TenantEvent): EventPhase {
|
|||||||
return 'setup';
|
return 'setup';
|
||||||
}
|
}
|
||||||
|
|
||||||
function LifecycleHero({ event, stats, locale, navigate, readiness }: any) {
|
function LifecycleHero({ event, stats, locale, navigate, readiness, variant = 'default' }: any) {
|
||||||
const theme = useAdminTheme();
|
const theme = useAdminTheme();
|
||||||
const { t } = useTranslation(['management', 'dashboard']);
|
const { t } = useTranslation(['management', 'dashboard']);
|
||||||
|
const isEmbedded = variant === 'embedded';
|
||||||
|
const cardVariant = isEmbedded ? 'embedded' : 'default';
|
||||||
|
const cardPadding = isEmbedded ? '$3' : '$3.5';
|
||||||
|
|
||||||
if (!event) return null;
|
if (!event) return null;
|
||||||
const phase = getEventPhase(event);
|
const phase = getEventPhase(event);
|
||||||
@@ -275,11 +302,13 @@ function LifecycleHero({ event, stats, locale, navigate, readiness }: any) {
|
|||||||
<YStack space="$2">
|
<YStack space="$2">
|
||||||
<Header />
|
<Header />
|
||||||
<DashboardCard
|
<DashboardCard
|
||||||
|
variant={cardVariant}
|
||||||
|
padding={cardPadding}
|
||||||
backgroundColor={theme.primary}
|
backgroundColor={theme.primary}
|
||||||
borderColor="transparent"
|
borderColor="transparent"
|
||||||
style={{ backgroundImage: 'linear-gradient(135deg, #4F46E5 0%, #4338CA 100%)' }}
|
style={{ backgroundImage: 'linear-gradient(135deg, #4F46E5 0%, #4338CA 100%)' }}
|
||||||
>
|
>
|
||||||
<YStack space="$3">
|
<YStack space={isEmbedded ? '$2.5' : '$3'}>
|
||||||
<XStack alignItems="center" justifyContent="space-between">
|
<XStack alignItems="center" justifyContent="space-between">
|
||||||
<YStack space="$1">
|
<YStack space="$1">
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" space="$2">
|
||||||
@@ -321,8 +350,8 @@ function LifecycleHero({ event, stats, locale, navigate, readiness }: any) {
|
|||||||
return (
|
return (
|
||||||
<YStack space="$2">
|
<YStack space="$2">
|
||||||
<Header />
|
<Header />
|
||||||
<DashboardCard>
|
<DashboardCard variant={cardVariant} padding={cardPadding}>
|
||||||
<YStack space="$3">
|
<YStack space={isEmbedded ? '$2.5' : '$3'}>
|
||||||
<XStack alignItems="center" space="$2.5">
|
<XStack alignItems="center" space="$2.5">
|
||||||
<YStack width={40} height={40} borderRadius={20} backgroundColor={theme.success} alignItems="center" justifyContent="center">
|
<YStack width={40} height={40} borderRadius={20} backgroundColor={theme.success} alignItems="center" justifyContent="center">
|
||||||
<CheckCircle2 size={20} color="white" />
|
<CheckCircle2 size={20} color="white" />
|
||||||
@@ -379,8 +408,8 @@ function LifecycleHero({ event, stats, locale, navigate, readiness }: any) {
|
|||||||
return (
|
return (
|
||||||
<YStack space="$2">
|
<YStack space="$2">
|
||||||
<Header />
|
<Header />
|
||||||
<DashboardCard>
|
<DashboardCard variant={cardVariant} padding={cardPadding}>
|
||||||
<YStack space="$3">
|
<YStack space={isEmbedded ? '$2.5' : '$3'}>
|
||||||
<XStack alignItems="center" justifyContent="space-between">
|
<XStack alignItems="center" justifyContent="space-between">
|
||||||
<YStack>
|
<YStack>
|
||||||
<Text fontSize="$xs" color={theme.muted} fontWeight="700" textTransform="uppercase">
|
<Text fontSize="$xs" color={theme.muted} fontWeight="700" textTransform="uppercase">
|
||||||
@@ -437,30 +466,27 @@ function PulseStrip({ event, stats }: any) {
|
|||||||
const pendingCount = stats?.pending_photos ?? event?.pending_photo_count ?? 0;
|
const pendingCount = stats?.pending_photos ?? event?.pending_photo_count ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<YStack space="$2">
|
<KpiStrip items={[
|
||||||
<SectionHeader title={t('management:eventMenu.summary', 'Overview')} />
|
{
|
||||||
<KpiStrip items={[
|
icon: ImageIcon,
|
||||||
{
|
value: uploadCount,
|
||||||
icon: ImageIcon,
|
label: t('management:events.list.stats.photos', 'Photos'),
|
||||||
value: uploadCount,
|
color: theme.primary,
|
||||||
label: t('management:events.list.stats.photos', 'Photos'),
|
},
|
||||||
color: theme.primary
|
{
|
||||||
},
|
icon: Users,
|
||||||
{
|
value: guestCount,
|
||||||
icon: Users,
|
label: t('management:events.list.stats.guests', 'Guests'),
|
||||||
value: guestCount,
|
color: theme.textStrong,
|
||||||
label: t('management:events.list.stats.guests', 'Guests'),
|
},
|
||||||
color: theme.textStrong
|
{
|
||||||
},
|
icon: ShieldCheck,
|
||||||
{
|
value: pendingCount,
|
||||||
icon: ShieldCheck,
|
label: t('management:photos.filters.pending', 'Pending'),
|
||||||
value: pendingCount,
|
note: pendingCount > 0 ? t('management:common.actionNeeded', 'Review') : undefined,
|
||||||
label: t('management:photos.filters.pending', 'Pending'),
|
color: pendingCount > 0 ? '#8B5CF6' : theme.textMuted,
|
||||||
note: pendingCount > 0 ? t('management:common.actionNeeded', 'Review') : undefined,
|
},
|
||||||
color: pendingCount > 0 ? '#8B5CF6' : theme.textMuted
|
]} />
|
||||||
}
|
|
||||||
]} />
|
|
||||||
</YStack>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -259,6 +259,8 @@ vi.mock('../theme', () => ({
|
|||||||
useAdminTheme: () => ({
|
useAdminTheme: () => ({
|
||||||
textStrong: '#0f172a',
|
textStrong: '#0f172a',
|
||||||
text: '#0f172a',
|
text: '#0f172a',
|
||||||
|
textMuted: '#94a3b8',
|
||||||
|
accent: '#7c3aed',
|
||||||
muted: '#64748b',
|
muted: '#64748b',
|
||||||
border: '#e2e8f0',
|
border: '#e2e8f0',
|
||||||
surface: '#ffffff',
|
surface: '#ffffff',
|
||||||
|
|||||||
@@ -1,33 +1,50 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Card } from '@tamagui/card';
|
||||||
|
import { YGroup } from '@tamagui/group';
|
||||||
|
import { ListItem } from '@tamagui/list-item';
|
||||||
import { YStack, XStack } from '@tamagui/stacks';
|
import { YStack, XStack } from '@tamagui/stacks';
|
||||||
import { SizableText as Text } from '@tamagui/text';
|
import { SizableText as Text } from '@tamagui/text';
|
||||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||||
import { CheckCircle2, Circle, ChevronDown, ChevronUp } from 'lucide-react';
|
import { CheckCircle2, Circle, ChevronDown, ChevronUp } from 'lucide-react';
|
||||||
|
import { Separator } from 'tamagui';
|
||||||
import { useAdminTheme } from '../theme';
|
import { useAdminTheme } from '../theme';
|
||||||
import { ReadinessStep } from '../hooks/useEventReadiness';
|
import { ReadinessStep } from '../hooks/useEventReadiness';
|
||||||
import { adminPath } from '../../constants';
|
import { adminPath } from '../../constants';
|
||||||
|
import { PillBadge } from './Primitives';
|
||||||
|
|
||||||
export function SetupChecklist({ steps, title }: { steps: ReadinessStep[]; title: string }) {
|
export function SetupChecklist({
|
||||||
|
steps,
|
||||||
|
title,
|
||||||
|
variant = 'card',
|
||||||
|
}: {
|
||||||
|
steps: ReadinessStep[];
|
||||||
|
title: string;
|
||||||
|
variant?: 'card' | 'embedded';
|
||||||
|
}) {
|
||||||
const theme = useAdminTheme();
|
const theme = useAdminTheme();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const isAllComplete = steps.every(s => s.isComplete);
|
const isAllComplete = steps.every(s => s.isComplete);
|
||||||
const [collapsed, setCollapsed] = React.useState(isAllComplete);
|
const [collapsed, setCollapsed] = React.useState(isAllComplete);
|
||||||
|
const isEmbedded = variant === 'embedded';
|
||||||
|
|
||||||
const completedCount = steps.filter(s => s.isComplete).length;
|
const completedCount = steps.filter(s => s.isComplete).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<YStack
|
<Card
|
||||||
backgroundColor={theme.surface}
|
backgroundColor={theme.surface}
|
||||||
borderRadius={16}
|
borderRadius={isEmbedded ? 16 : 20}
|
||||||
borderWidth={1}
|
borderWidth={1}
|
||||||
borderColor={theme.border}
|
borderColor={theme.border}
|
||||||
padding="$0"
|
padding="$0"
|
||||||
overflow="hidden"
|
overflow="hidden"
|
||||||
style={{ boxShadow: `0 2px 8px ${theme.shadow}` }}
|
shadowColor={theme.shadow}
|
||||||
|
shadowOpacity={isEmbedded ? 0 : 0.16}
|
||||||
|
shadowRadius={isEmbedded ? 0 : 16}
|
||||||
|
shadowOffset={isEmbedded ? { width: 0, height: 0 } : { width: 0, height: 10 }}
|
||||||
>
|
>
|
||||||
<Pressable onPress={() => setCollapsed(!collapsed)}>
|
<Pressable onPress={() => setCollapsed(!collapsed)}>
|
||||||
<XStack padding="$3.5" paddingVertical="$3" alignItems="center" justifyContent="space-between">
|
<XStack padding="$3" paddingVertical="$2.5" alignItems="center" justifyContent="space-between">
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" space="$2">
|
||||||
<Text fontSize="$xs" fontWeight="700" color={theme.muted} textTransform="uppercase" letterSpacing={1}>
|
<Text fontSize="$xs" fontWeight="700" color={theme.muted} textTransform="uppercase" letterSpacing={1}>
|
||||||
{title}
|
{title}
|
||||||
@@ -48,60 +65,54 @@ export function SetupChecklist({ steps, title }: { steps: ReadinessStep[]; title
|
|||||||
|
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<YStack>
|
<YStack>
|
||||||
{steps.map((step, index) => {
|
<Separator backgroundColor={theme.border} opacity={0.6} />
|
||||||
const isNext = !step.isComplete && steps.slice(0, index).every(s => s.isComplete);
|
<YGroup {...({ borderRadius: "$4", borderWidth: 1, borderColor: theme.border, overflow: "hidden" } as any)}>
|
||||||
|
{steps.map((step, index) => {
|
||||||
return (
|
const isNext = !step.isComplete && steps.slice(0, index).every(s => s.isComplete);
|
||||||
<Pressable
|
|
||||||
key={step.id}
|
|
||||||
onPress={() => navigate(adminPath(step.targetPath))}
|
|
||||||
style={{
|
|
||||||
backgroundColor: isNext ? theme.surface : 'transparent',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<XStack
|
|
||||||
paddingHorizontal="$3.5"
|
|
||||||
paddingVertical="$3"
|
|
||||||
alignItems="center"
|
|
||||||
space="$3"
|
|
||||||
borderTopWidth={1}
|
|
||||||
borderColor={theme.border}
|
|
||||||
>
|
|
||||||
{step.isComplete ? (
|
|
||||||
<CheckCircle2 size={20} color={theme.successText} />
|
|
||||||
) : isNext ? (
|
|
||||||
<Circle size={20} color={theme.primary} strokeWidth={2.5} />
|
|
||||||
) : (
|
|
||||||
<Circle size={20} color={theme.border} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<YStack flex={1} space="$0.5">
|
return (
|
||||||
<Text
|
<YGroup.Item key={step.id}>
|
||||||
fontSize="$sm"
|
<ListItem
|
||||||
fontWeight={isNext ? "700" : "500"}
|
hoverTheme
|
||||||
|
pressTheme
|
||||||
|
paddingVertical="$2"
|
||||||
|
paddingHorizontal="$3"
|
||||||
|
backgroundColor={isNext ? theme.surfaceMuted : 'transparent'}
|
||||||
|
onPress={() => navigate(adminPath(step.targetPath))}
|
||||||
|
title={
|
||||||
|
<XStack alignItems="center" space="$2.5">
|
||||||
|
{step.isComplete ? (
|
||||||
|
<CheckCircle2 size={18} color={theme.successText} />
|
||||||
|
) : isNext ? (
|
||||||
|
<Circle size={18} color={theme.primary} strokeWidth={2.5} />
|
||||||
|
) : (
|
||||||
|
<Circle size={18} color={theme.border} />
|
||||||
|
)}
|
||||||
|
<Text
|
||||||
|
fontSize="$sm"
|
||||||
|
fontWeight={isNext ? '700' : '500'}
|
||||||
color={step.isComplete ? theme.muted : theme.textStrong}
|
color={step.isComplete ? theme.muted : theme.textStrong}
|
||||||
textDecorationLine={step.isComplete ? 'line-through' : 'none'}
|
textDecorationLine={step.isComplete ? 'line-through' : 'none'}
|
||||||
>
|
>
|
||||||
{step.label}
|
{step.label}
|
||||||
</Text>
|
</Text>
|
||||||
{step.description && !step.isComplete && (
|
</XStack>
|
||||||
<Text fontSize="$xs" color={theme.muted}>
|
}
|
||||||
{step.description}
|
subTitle={
|
||||||
</Text>
|
step.description && !step.isComplete ? (
|
||||||
)}
|
<Text fontSize="$xs" color={theme.muted}>
|
||||||
</YStack>
|
{step.description}
|
||||||
|
</Text>
|
||||||
{isNext && (
|
) : undefined
|
||||||
<YStack backgroundColor={theme.primary} borderRadius={999} paddingHorizontal="$2.5" paddingVertical="$1.5">
|
}
|
||||||
<Text fontSize="$xs" color="white" fontWeight="700">Start</Text>
|
iconAfter={isNext ? <PillBadge tone="success">Start</PillBadge> : undefined}
|
||||||
</YStack>
|
/>
|
||||||
)}
|
</YGroup.Item>
|
||||||
</XStack>
|
);
|
||||||
</Pressable>
|
})}
|
||||||
);
|
</YGroup>
|
||||||
})}
|
|
||||||
</YStack>
|
</YStack>
|
||||||
)}
|
)}
|
||||||
</YStack>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user