Refine admin PWA layout and tamagui usage
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-15 22:24:10 +01:00
parent 11018f273d
commit 292c8f0b26
37 changed files with 51503 additions and 21989 deletions

View File

@@ -85,7 +85,7 @@ function AdminApp() {
</div>
)}
>
<div className="bg-[#FFF8F5] font-[Manrope] text-[14px] font-normal leading-[1.6] text-[#1F2937] dark:bg-[#15121A] dark:text-slate-100">
<div className="bg-[#FFF1E8] font-[Manrope] text-[14px] font-normal leading-[1.6] text-[#0B132B] dark:bg-[#0B132B] dark:text-slate-100">
<RouterProvider router={router} />
</div>
</Suspense>

View File

@@ -122,6 +122,10 @@ export default function MobileBrandingPage() {
setLoading(true);
try {
const data = await getEvent(slug);
if (!data) {
setLoading(false);
return;
}
setEvent(data);
setForm(extractBrandingForm(data.settings ?? {}, BRANDING_FORM_DEFAULTS));
setWatermarkForm(extractWatermark(data));
@@ -153,7 +157,7 @@ export default function MobileBrandingPage() {
let active = true;
getTenantSettings()
.then((payload) => {
if (!active) return;
if (!active || !payload) return;
setTenantBranding(extractBrandingForm(payload.settings ?? {}, BRANDING_FORM_DEFAULTS));
})
.catch(() => undefined)
@@ -170,7 +174,7 @@ export default function MobileBrandingPage() {
const previewForm = form.useDefaultBranding && tenantBranding ? tenantBranding : form;
const previewTitle = event ? renderName(event.name) : t('events.placeholders.untitled', 'Unbenanntes Event');
const previewHeadingFont = previewForm.headingFont || 'Fraunces';
const previewHeadingFont = previewForm.headingFont || 'Archivo Black';
const previewBodyFont = previewForm.bodyFont || 'Manrope';
const previewSurfaceText = getContrastingTextColor(previewForm.surface, '#ffffff', '#0f172a');
const previewScale = FONT_SIZE_SCALE[previewForm.fontSize] ?? 1;

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>
);
}

View File

@@ -2,6 +2,7 @@ import React from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { RefreshCcw, Plus, Pencil, Trash2, ChevronDown, ChevronRight } from 'lucide-react';
import { Card } from '@tamagui/card';
import { YStack, XStack } from '@tamagui/stacks';
import { YGroup } from '@tamagui/group';
import { SizableText as Text } from '@tamagui/text';
@@ -9,8 +10,9 @@ import { ListItem } from '@tamagui/list-item';
import { Pressable } from '@tamagui/react-native-web-lite';
import { Button } from '@tamagui/button';
import { AlertDialog } from '@tamagui/alert-dialog';
import { ScrollView } from '@tamagui/scroll-view';
import { MobileShell, HeaderActionButton } from './components/MobileShell';
import { MobileCard, CTAButton, SkeletonCard, PillBadge, FloatingActionButton } from './components/Primitives';
import { MobileCard, CTAButton, SkeletonCard, FloatingActionButton } from './components/Primitives';
import { MobileField, MobileInput, MobileSelect, MobileTextArea } from './components/FormControls';
import {
getEvent,
@@ -43,82 +45,183 @@ import { RadioGroup } from '@tamagui/radio-group';
import { useBackNavigation } from './hooks/useBackNavigation';
import { buildTaskSummary } from './lib/taskSummary';
import { buildTaskSectionCounts, type TaskSectionKey } from './lib/taskSectionCounts';
import { useAdminTheme } from './theme';
import { withAlpha } from './components/colors';
import { ADMIN_ACTION_COLORS, useAdminTheme } from './theme';
function TaskSummaryCard({
summary,
text,
muted,
border,
surfaceMuted,
}: {
summary: ReturnType<typeof buildTaskSummary>;
text: string;
muted: string;
border: string;
surfaceMuted: string;
}) {
function TaskSummaryCard({ summary }: { summary: ReturnType<typeof buildTaskSummary> }) {
const { t } = useTranslation('management');
const { textStrong, muted, border, surface, surfaceMuted, shadow } = useAdminTheme();
const total = summary.assigned + summary.library + summary.collections + summary.emotions;
const segments = [
{
key: 'assigned',
label: t('events.tasks.summary.assigned', 'Assigned'),
value: summary.assigned,
color: ADMIN_ACTION_COLORS.tasks,
},
{
key: 'library',
label: t('events.tasks.summary.library', 'Library'),
value: summary.library,
color: ADMIN_ACTION_COLORS.qr,
},
{
key: 'collections',
label: t('events.tasks.summary.collections', 'Collections'),
value: summary.collections,
color: ADMIN_ACTION_COLORS.settings,
},
{
key: 'emotions',
label: t('events.tasks.summary.emotions', 'Emotions'),
value: summary.emotions,
color: ADMIN_ACTION_COLORS.branding,
},
];
return (
<MobileCard space="$2" borderColor={border}>
<XStack alignItems="center" justifyContent="space-between" space="$2">
<SummaryItem
label={t('events.tasks.summary.assigned', 'Assigned')}
value={summary.assigned}
text={text}
muted={muted}
surfaceMuted={surfaceMuted}
/>
<SummaryItem
label={t('events.tasks.summary.library', 'Library')}
value={summary.library}
text={text}
muted={muted}
surfaceMuted={surfaceMuted}
/>
</XStack>
<XStack alignItems="center" justifyContent="space-between" space="$2">
<SummaryItem
label={t('events.tasks.summary.collections', 'Collections')}
value={summary.collections}
text={text}
muted={muted}
surfaceMuted={surfaceMuted}
/>
<SummaryItem
label={t('events.tasks.summary.emotions', 'Emotions')}
value={summary.emotions}
text={text}
muted={muted}
surfaceMuted={surfaceMuted}
/>
</XStack>
</MobileCard>
<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.tasks.summary.title', 'Task overview')}
</Text>
</XStack>
</XStack>
<XStack alignItems="baseline" space="$2">
<Text fontSize="$xl" fontWeight="900" color={textStrong}>
{total}
</Text>
<Text fontSize="$xs" color={muted}>
{t('events.tasks.summary.total', 'Tasks total')}
</Text>
</XStack>
<XStack
height={10}
borderRadius={999}
overflow="hidden"
borderWidth={1}
borderColor={border}
backgroundColor={surfaceMuted}
>
{total > 0 ? (
segments.map((segment) =>
segment.value > 0 ? (
<XStack
key={segment.key}
flex={segment.value}
backgroundColor={withAlpha(segment.color, 0.55)}
/>
) : null,
)
) : (
<XStack flex={1} backgroundColor={withAlpha(border, 0.4)} />
)}
</XStack>
<XStack flexWrap="wrap" space="$2">
{segments.map((segment) => (
<SummaryLegendItem key={segment.key} label={segment.label} value={segment.value} color={segment.color} />
))}
</XStack>
</YStack>
</Card>
);
}
function SummaryItem({
function SummaryLegendItem({
label,
value,
text,
muted,
surfaceMuted,
color,
}: {
label: string;
value: number;
text: string;
muted: string;
surfaceMuted: string;
color: string;
}) {
const { textStrong } = useAdminTheme();
return (
<YStack flex={1} padding="$2" borderRadius={12} backgroundColor={surfaceMuted} space="$1">
<Text fontSize={11} color={muted}>
<XStack
alignItems="center"
space="$1.5"
paddingHorizontal="$2.5"
paddingVertical="$1.5"
borderRadius={999}
borderWidth={1}
borderColor={withAlpha(color, 0.35)}
backgroundColor={withAlpha(color, 0.12)}
>
<XStack width={8} height={8} borderRadius={999} backgroundColor={color} />
<Text fontSize={11} fontWeight="700" color={textStrong}>
{label}
</Text>
<Text fontSize={16} fontWeight="800" color={text}>
<Text fontSize={11} fontWeight="700" color={textStrong}>
{value}
</Text>
</YStack>
</XStack>
);
}
function QuickNavChip({
label,
count,
onPress,
}: {
label: string;
count: number;
onPress: () => void;
}) {
const { textStrong, border, surface, surfaceMuted } = useAdminTheme();
return (
<Pressable onPress={onPress}>
<XStack
alignItems="center"
space="$1.5"
paddingHorizontal="$3"
paddingVertical="$2"
borderRadius={999}
borderWidth={1}
borderColor={border}
backgroundColor={surface}
style={{ minHeight: 36 }}
>
<Text fontSize="$xs" fontWeight="700" color={textStrong}>
{label}
</Text>
<XStack
paddingHorizontal="$2"
paddingVertical="$0.5"
borderRadius={999}
borderWidth={1}
borderColor={border}
backgroundColor={surfaceMuted}
>
<Text fontSize={10} fontWeight="800" color={textStrong}>
{count}
</Text>
</XStack>
</XStack>
</Pressable>
);
}
@@ -491,45 +594,82 @@ export default function MobileEventTasksPage() {
) : null}
{!loading ? (
<TaskSummaryCard
summary={summary}
text={text}
muted={muted}
border={border}
surfaceMuted={surfaceMuted}
/>
<TaskSummaryCard summary={summary} />
) : null}
{!loading ? (
<YStack space="$2">
<Text fontSize={12} fontWeight="700" color={muted}>
{t('events.tasks.quickNav', 'Quick jump')}
</Text>
<XStack space="$2" flexWrap="wrap">
{sectionCounts.map((section) => (
<Button
key={section.key}
unstyled
onPress={() => handleQuickNav(section.key)}
borderRadius={14}
<Card
borderRadius={22}
borderWidth={2}
borderColor={border}
backgroundColor={surface}
padding="$3"
>
<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={surface}
paddingVertical="$2"
paddingHorizontal="$3"
pressStyle={{ backgroundColor: surfaceMuted }}
style={{ flexGrow: 1 }}
backgroundColor={surfaceMuted}
>
<XStack alignItems="center" justifyContent="center" space="$1.5">
<Text fontSize="$xs" fontWeight="700" color={text}>
{t(`events.tasks.sections.${section.key}`, section.key)}
<Text fontSize="$xs" fontWeight="800" color={text}>
{t('events.tasks.quickNav', 'Quick jump')}
</Text>
</XStack>
</XStack>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<XStack space="$2" paddingVertical="$1">
{sectionCounts.map((section) => (
<QuickNavChip
key={section.key}
label={t(`events.tasks.sections.${section.key}`, section.key)}
count={section.count}
onPress={() => handleQuickNav(section.key)}
/>
))}
</XStack>
</ScrollView>
<XStack alignItems="center" space="$2">
<XStack flex={1}>
<MobileInput
type="search"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder={t('events.tasks.search', 'Search tasks')}
compact
/>
</XStack>
<Pressable onPress={() => setShowEmotionFilterSheet(true)}>
<XStack
alignItems="center"
space="$1.5"
paddingVertical="$2"
paddingHorizontal="$3"
borderRadius={14}
borderWidth={1}
borderColor={border}
backgroundColor={surface}
>
<Text fontSize={11} fontWeight="700" color={text}>
{t('events.tasks.emotionFilterShort', 'Emotion')}
</Text>
<PillBadge tone="muted">{section.count}</PillBadge>
<Text fontSize={11} color={muted}>
{emotionFilter
? emotions.find((e) => String(e.id) === emotionFilter)?.name ?? t('events.tasks.customEmotion', 'Custom emotion')
: t('events.tasks.allEmotions', 'All')}
</Text>
<ChevronDown size={14} color={muted} />
</XStack>
</Button>
))}
</XStack>
</YStack>
</Pressable>
</XStack>
</YStack>
</Card>
) : null}
{loading ? (
@@ -631,33 +771,6 @@ export default function MobileEventTasksPage() {
) : (
<YStack space="$2">
<div ref={assignedRef} />
<YStack space="$2">
<MobileInput
type="search"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder={t('events.tasks.search', 'Search tasks')}
compact
/>
<Pressable onPress={() => setShowEmotionFilterSheet(true)}>
<MobileCard borderColor={border} backgroundColor={surface} space="$2">
<XStack alignItems="center" justifyContent="space-between">
<YStack>
<Text fontSize={12} fontWeight="700" color={text}>
{t('events.tasks.emotionFilter', 'Emotion filter')}
</Text>
<Text fontSize={11} color={muted}>
{emotionFilter
? emotions.find((e) => String(e.id) === emotionFilter)?.name ?? t('events.tasks.customEmotion', 'Custom emotion')
: t('events.tasks.allEmotions', 'All')}
</Text>
</YStack>
<ChevronDown size={16} color={muted} />
</XStack>
</MobileCard>
</Pressable>
</YStack>
<Text fontSize="$sm" color={muted}>
{t('events.tasks.count', '{{count}} Tasks', { count: filteredTasks.length })}
</Text>

View File

@@ -59,7 +59,7 @@ const BACKGROUND_PRESETS = [
},
];
const DEFAULT_BODY_FONT = 'Manrope';
const DEFAULT_DISPLAY_FONT = 'Fraunces';
const DEFAULT_DISPLAY_FONT = 'Archivo Black';
export default function MobileQrLayoutCustomizePage() {
const { slug: slugParam, tokenId: tokenParam } = useParams<{ slug?: string; tokenId?: string }>();
@@ -1206,7 +1206,7 @@ function LayoutControls({
const [openColorSlot, setOpenColorSlot] = React.useState<string | null>(null);
const { border, surface, surfaceMuted, text, textStrong, muted, overlay, shadow, danger } = useAdminTheme();
const fontOptions = React.useMemo(() => {
const preset = ['Fraunces', 'Manrope', 'Inter', 'Roboto', 'Lora'];
const preset = ['Archivo Black', 'Manrope', 'Inter', 'Roboto', 'Lora'];
const tenant = tenantFonts.map((font) => font.family);
return Array.from(new Set([...tenant, ...preset]));
}, [tenantFonts]);

View File

@@ -65,6 +65,11 @@ vi.mock('../theme', () => ({
backdrop: '#0f172a',
dangerBg: '#fee2e2',
dangerText: '#b91c1c',
glassSurface: 'rgba(255,255,255,0.8)',
glassSurfaceStrong: 'rgba(255,255,255,0.9)',
glassBorder: 'rgba(226,232,240,0.7)',
glassShadow: 'rgba(15,23,42,0.14)',
appBackground: 'linear-gradient(180deg, #f7fafc, #eef3f7)',
}),
}));

View File

@@ -0,0 +1,235 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
const fixtures = vi.hoisted(() => ({
event: {
id: 1,
name: 'Demo Wedding',
slug: 'demo-event',
event_date: '2026-02-19',
status: 'published' as const,
settings: { location: 'Berlin' },
tasks_count: 4,
photo_count: 12,
active_invites_count: 3,
total_invites_count: 5,
},
activePackage: {
id: 1,
package_id: 1,
package_name: 'Standard',
package_type: 'standard',
included_package_slug: null,
active: true,
used_events: 2,
remaining_events: 3,
price: null,
currency: null,
purchased_at: null,
expires_at: null,
package_limits: null,
branding_allowed: true,
watermark_allowed: true,
features: [],
},
}));
const navigateMock = vi.fn();
vi.mock('react-router-dom', () => ({
useNavigate: () => navigateMock,
useLocation: () => ({ search: '', pathname: '/event-admin/mobile/dashboard' }),
useParams: () => ({ slug: fixtures.event.slug }),
}));
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, fallback?: string | Record<string, unknown>, options?: Record<string, unknown>) => {
let text = key;
let resolvedOptions = options;
if (typeof fallback === 'string') {
text = fallback;
} else if (fallback && typeof fallback === 'object') {
resolvedOptions = fallback;
if (typeof fallback.defaultValue === 'string') {
text = fallback.defaultValue;
}
}
if (!resolvedOptions || typeof text !== 'string') {
return text;
}
return text.replace(/\{\{(\w+)\}\}/g, (_, token) => String(resolvedOptions?.[token] ?? ''));
},
i18n: {
language: 'de',
},
}),
initReactI18next: {
type: '3rdParty',
init: () => undefined,
},
}));
vi.mock('@tanstack/react-query', () => ({
useQuery: ({ queryKey }: { queryKey: unknown }) => {
const keyParts = Array.isArray(queryKey) ? queryKey : [queryKey];
const key = keyParts.map(String).join(':');
if (key.includes('packages-overview')) {
return { data: { packages: [], activePackage: fixtures.activePackage }, isLoading: false, isError: false };
}
if (key.includes('onboarding') && key.includes('status')) {
return { data: { steps: { summary_seen_package_id: fixtures.activePackage.id } }, isLoading: false };
}
if (key.includes('dashboard') && key.includes('events')) {
return { data: [fixtures.event], isLoading: false };
}
if (key.includes('dashboard') && key.includes('stats')) {
return { data: null, isLoading: false };
}
return { data: null, isLoading: false, isError: false };
},
}));
vi.mock('../../context/EventContext', () => ({
useEventContext: () => ({
events: [fixtures.event],
activeEvent: fixtures.event,
hasEvents: true,
hasMultipleEvents: false,
isLoading: false,
selectEvent: vi.fn(),
}),
}));
vi.mock('../../auth/context', () => ({
useAuth: () => ({ status: 'unauthenticated' }),
}));
vi.mock('../hooks/useInstallPrompt', () => ({
useInstallPrompt: () => ({ isInstalled: true, canInstall: false, isIos: false, promptInstall: vi.fn() }),
}));
vi.mock('../hooks/useAdminPushSubscription', () => ({
useAdminPushSubscription: () => ({ supported: true, subscribed: true, permission: 'granted', loading: false, enable: vi.fn() }),
}));
vi.mock('../hooks/useDevicePermissions', () => ({
useDevicePermissions: () => ({ storage: 'unavailable', loading: false, requestPersistentStorage: vi.fn() }),
}));
vi.mock('../lib/mobileTour', () => ({
getTourSeen: () => true,
resolveTourStepKeys: () => [],
setTourSeen: vi.fn(),
}));
vi.mock('../components/MobileShell', () => ({
MobileShell: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('../components/Sheet', () => ({
MobileSheet: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('../components/Primitives', () => ({
MobileCard: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
CTAButton: ({ label }: { label: string }) => <button type="button">{label}</button>,
KpiTile: ({ label, value }: { label: string; value: string | number }) => (
<div>
<span>{label}</span>
<span>{value}</span>
</div>
),
ActionTile: ({ label }: { label: string }) => <div>{label}</div>,
PillBadge: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SkeletonCard: () => <div>Loading...</div>,
}));
vi.mock('@tamagui/card', () => ({
Card: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@tamagui/progress', () => ({
Progress: Object.assign(({ children }: { children: React.ReactNode }) => <div>{children}</div>, {
Indicator: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>,
}),
}));
vi.mock('@tamagui/group', () => ({
XGroup: Object.assign(({ children }: { children: React.ReactNode }) => <div>{children}</div>, {
Item: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}),
YGroup: Object.assign(({ children }: { children: React.ReactNode }) => <div>{children}</div>, {
Item: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}),
}));
vi.mock('@tamagui/stacks', () => ({
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
XStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@tamagui/text', () => ({
SizableText: ({ 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}>
{children}
</button>
),
}));
vi.mock('../theme', () => ({
ADMIN_ACTION_COLORS: {
settings: '#10b981',
tasks: '#f59e0b',
qr: '#6366f1',
images: '#ec4899',
liveShow: '#f97316',
liveShowSettings: '#38bdf8',
guests: '#22c55e',
guestMessages: '#f97316',
branding: '#0ea5e9',
photobooth: '#f43f5e',
recap: '#94a3b8',
analytics: '#22c55e',
},
ADMIN_MOTION: {
tileStaggerMs: 0,
},
useAdminTheme: () => ({
textStrong: '#0f172a',
muted: '#64748b',
border: '#e2e8f0',
surface: '#ffffff',
accentSoft: '#eef2ff',
primary: '#ff5a5f',
surfaceMuted: '#f8fafc',
shadow: 'rgba(15,23,42,0.12)',
}),
}));
vi.mock('../../lib/events', () => ({
resolveEventDisplayName: () => fixtures.event.name,
resolveEngagementMode: () => 'full',
isBrandingAllowed: () => true,
formatEventDate: () => '19. Feb. 2026',
}));
vi.mock('../eventDate', () => ({
isPastEvent: () => false,
}));
import MobileDashboardPage from '../DashboardPage';
describe('MobileDashboardPage', () => {
it('shows package usage progress when a limit is available', () => {
render(<MobileDashboardPage />);
expect(screen.getByText('2 of 5 events used')).toBeInTheDocument();
expect(screen.getByText('3 remaining')).toBeInTheDocument();
});
});

View File

@@ -79,6 +79,11 @@ vi.mock('../theme', () => ({
border: '#e5e7eb',
surface: '#ffffff',
primary: '#ff5a5f',
glassSurface: 'rgba(255,255,255,0.8)',
glassSurfaceStrong: 'rgba(255,255,255,0.9)',
glassBorder: 'rgba(229,231,235,0.7)',
glassShadow: 'rgba(15,23,42,0.14)',
appBackground: 'linear-gradient(180deg, #f7fafc, #eef3f7)',
}),
}));

View File

@@ -0,0 +1,217 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
const fixtures = vi.hoisted(() => ({
event: {
id: 1,
name: 'Demo Event',
slug: 'demo-event',
event_date: '2026-02-19',
event_type_id: null,
event_type: null,
status: 'published',
settings: {},
},
assignedTasks: [
{ id: 1, title: 'Task A', description: 'Desc', emotion: null, event_type_id: null },
{ id: 2, title: 'Task B', description: '', emotion: null, event_type_id: null },
],
libraryTasks: [
{ id: 3, title: 'Task C', description: '', emotion: null, event_type_id: null },
],
collections: [{ id: 1, name: 'Starter Pack', description: '' }],
emotions: [{ id: 1, name: 'Joy', color: '#ff6b6b' }],
}));
const navigateMock = vi.fn();
const backMock = vi.fn();
vi.mock('react-router-dom', () => ({
useNavigate: () => navigateMock,
useParams: () => ({ slug: fixtures.event.slug }),
}));
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, fallback?: string | Record<string, unknown>) => {
if (typeof fallback === 'string') {
return fallback;
}
if (fallback && typeof fallback === 'object' && typeof fallback.defaultValue === 'string') {
return fallback.defaultValue;
}
return key;
},
}),
}));
vi.mock('../hooks/useBackNavigation', () => ({
useBackNavigation: () => backMock,
}));
vi.mock('../../context/EventContext', () => ({
useEventContext: () => ({
activeEvent: fixtures.event,
selectEvent: vi.fn(),
}),
}));
vi.mock('../../api', () => ({
getEvent: vi.fn().mockResolvedValue(fixtures.event),
getEvents: vi.fn().mockResolvedValue([fixtures.event]),
getEventTasks: vi.fn().mockResolvedValue({ data: fixtures.assignedTasks }),
getTasks: vi.fn().mockResolvedValue({ data: fixtures.libraryTasks }),
getTaskCollections: vi.fn().mockResolvedValue({ data: fixtures.collections }),
getEmotions: vi.fn().mockResolvedValue(fixtures.emotions),
assignTasksToEvent: vi.fn(),
updateTask: vi.fn(),
importTaskCollection: vi.fn(),
createTask: vi.fn(),
detachTasksFromEvent: vi.fn(),
createEmotion: vi.fn(),
updateEmotion: vi.fn(),
deleteEmotion: vi.fn(),
}));
vi.mock('react-hot-toast', () => ({
default: {
error: vi.fn(),
success: vi.fn(),
},
}));
vi.mock('@tamagui/card', () => ({
Card: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@tamagui/scroll-view', () => ({
ScrollView: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@tamagui/group', () => ({
YGroup: Object.assign(({ children }: { children: React.ReactNode }) => <div>{children}</div>, {
Item: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}),
}));
vi.mock('@tamagui/stacks', () => ({
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
XStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@tamagui/text', () => ({
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
}));
vi.mock('@tamagui/list-item', () => ({
ListItem: ({ title, subTitle, iconAfter }: { title?: React.ReactNode; subTitle?: React.ReactNode; iconAfter?: React.ReactNode }) => (
<div>
{title}
{subTitle}
{iconAfter}
</div>
),
}));
vi.mock('@tamagui/react-native-web-lite', () => ({
Pressable: ({ children, onPress }: { children: React.ReactNode; onPress?: () => void }) => (
<button type="button" onClick={onPress}>
{children}
</button>
),
}));
vi.mock('@tamagui/button', () => ({
Button: ({ children, onPress }: { children: React.ReactNode; onPress?: () => void }) => (
<button type="button" onClick={onPress}>
{children}
</button>
),
}));
vi.mock('@tamagui/radio-group', () => ({
RadioGroup: Object.assign(({ children }: { children: React.ReactNode }) => <div>{children}</div>, {
Item: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
Indicator: () => <div />,
}),
}));
vi.mock('@tamagui/alert-dialog', () => ({
AlertDialog: Object.assign(({ children }: { children: React.ReactNode }) => <div>{children}</div>, {
Portal: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
Overlay: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>,
Content: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
Title: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
Description: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
Cancel: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
Action: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}),
}));
vi.mock('../components/MobileShell', () => ({
MobileShell: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
HeaderActionButton: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('../components/Primitives', () => ({
MobileCard: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
CTAButton: ({ label, onPress }: { label: string; onPress?: () => void }) => (
<button type="button" onClick={onPress}>
{label}
</button>
),
SkeletonCard: () => <div>Loading...</div>,
FloatingActionButton: ({ label }: { label: string }) => <div>{label}</div>,
}));
vi.mock('../components/FormControls', () => ({
MobileField: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
MobileInput: (props: React.InputHTMLAttributes<HTMLInputElement>) => <input {...props} />,
MobileSelect: ({ children }: { children: React.ReactNode }) => <select>{children}</select>,
MobileTextArea: (props: React.TextareaHTMLAttributes<HTMLTextAreaElement>) => <textarea {...props} />,
}));
vi.mock('../components/Sheet', () => ({
MobileSheet: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('../components/Tag', () => ({
Tag: ({ label }: { label: string }) => <span>{label}</span>,
}));
vi.mock('../theme', () => ({
ADMIN_ACTION_COLORS: {
settings: '#0ea5e9',
tasks: '#f59e0b',
qr: '#6366f1',
branding: '#22c55e',
},
useAdminTheme: () => ({
textStrong: '#111827',
muted: '#6b7280',
subtle: '#94a3b8',
border: '#e5e7eb',
primary: '#ff5a5f',
danger: '#dc2626',
surface: '#ffffff',
surfaceMuted: '#f9fafb',
dangerBg: '#fee2e2',
dangerText: '#b91c1c',
overlay: 'rgba(15,23,42,0.5)',
shadow: 'rgba(15,23,42,0.12)',
}),
}));
import MobileEventTasksPage from '../EventTasksPage';
describe('MobileEventTasksPage', () => {
it('renders the task overview summary and quick jump chips', async () => {
render(<MobileEventTasksPage />);
expect(await screen.findByText('Task overview')).toBeInTheDocument();
expect(screen.getByText('Tasks total')).toBeInTheDocument();
expect(screen.getByText('Quick jump')).toBeInTheDocument();
expect(screen.getByText('Assigned')).toBeInTheDocument();
});
});

View File

@@ -69,6 +69,11 @@ vi.mock('../theme', () => ({
surface: '#ffffff',
primary: '#ff5a5f',
accentSoft: '#fde7ea',
glassSurface: 'rgba(255,255,255,0.8)',
glassSurfaceStrong: 'rgba(255,255,255,0.9)',
glassBorder: 'rgba(229,231,235,0.7)',
glassShadow: 'rgba(15,23,42,0.14)',
appBackground: 'linear-gradient(180deg, #f7fafc, #eef3f7)',
}),
}));

View File

@@ -110,6 +110,11 @@ vi.mock('../theme', () => ({
primary: '#ff5a5f',
danger: '#b91c1c',
accentSoft: '#ffe5ec',
glassSurface: 'rgba(255,255,255,0.8)',
glassSurfaceStrong: 'rgba(255,255,255,0.9)',
glassBorder: 'rgba(229,231,235,0.7)',
glassShadow: 'rgba(15,23,42,0.14)',
appBackground: 'linear-gradient(180deg, #f7fafc, #eef3f7)',
}),
}));

View File

@@ -16,9 +16,11 @@ export type NavKey = 'home' | 'tasks' | 'uploads' | 'profile';
export function BottomNav({ active, onNavigate }: { active: NavKey; onNavigate: (key: NavKey) => void }) {
const { t } = useTranslation('mobile');
const location = useLocation();
const { surface, border, primary, accentSoft, muted, subtle, shadow } = useAdminTheme();
const surfaceColor = surface;
const navSurface = withAlpha(surfaceColor, 0.92);
const { surface, border, primary, accent, muted, subtle, shadow, glassSurfaceStrong, glassBorder, glassShadow } = useAdminTheme();
const surfaceColor = glassSurfaceStrong ?? surface;
const navSurface = glassSurfaceStrong ?? withAlpha(surfaceColor, 0.92);
const navBorder = glassBorder ?? border;
const navShadow = glassShadow ?? shadow;
const [pressedKey, setPressedKey] = React.useState<NavKey | null>(null);
const isDeepHome = active === 'home' && location.pathname !== adminPath('/mobile/dashboard');
@@ -38,14 +40,14 @@ export function BottomNav({ active, onNavigate }: { active: NavKey; onNavigate:
right={0}
backgroundColor={navSurface}
borderTopWidth={1}
borderColor={border}
borderColor={navBorder}
paddingVertical="$2"
paddingHorizontal="$4"
zIndex={50}
shadowColor={shadow}
shadowOpacity={0.08}
shadowRadius={12}
shadowOffset={{ width: 0, height: -4 }}
shadowColor={navShadow}
shadowOpacity={0.12}
shadowRadius={16}
shadowOffset={{ width: 0, height: -6 }}
// allow for safe-area inset on modern phones
style={{
paddingBottom: 'max(env(safe-area-inset-bottom, 0px), 8px)',
@@ -58,6 +60,8 @@ export function BottomNav({ active, onNavigate }: { active: NavKey; onNavigate:
const activeState = item.key === active;
const isPressed = pressedKey === item.key;
const IconCmp = item.icon;
const activeBg = primary;
const activeShadow = withAlpha(primary, 0.4);
return (
<Pressable
key={item.key}
@@ -78,9 +82,10 @@ export function BottomNav({ active, onNavigate }: { active: NavKey; onNavigate:
paddingHorizontal="$3"
paddingVertical="$2"
borderRadius={12}
backgroundColor={activeState ? accentSoft : 'transparent'}
backgroundColor={activeState ? activeBg : 'transparent'}
gap="$1"
style={{
boxShadow: activeState ? `0 10px 22px ${activeShadow}` : undefined,
transform: isPressed ? 'scale(0.96)' : 'scale(1)',
opacity: isPressed ? 0.9 : 1,
transition: 'transform 140ms ease, background-color 140ms ease, opacity 140ms ease',
@@ -93,17 +98,17 @@ export function BottomNav({ active, onNavigate }: { active: NavKey; onNavigate:
width={28}
height={3}
borderRadius={999}
backgroundColor={primary}
backgroundColor={accent}
/>
) : null}
<YStack width={ICON_SIZE} height={ICON_SIZE} alignItems="center" justifyContent="center" shrink={0}>
<IconCmp size={ICON_SIZE} color={activeState ? primary : subtle} />
<IconCmp size={ICON_SIZE} color={activeState ? 'white' : subtle} />
</YStack>
<Text
fontSize="$xs"
fontWeight="700"
fontFamily="$body"
color={activeState ? primary : muted}
color={activeState ? 'white' : muted}
textAlign="center"
flexShrink={1}
>

View File

@@ -37,13 +37,32 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
const { t } = useTranslation('mobile');
const { count: notificationCount } = useNotificationsBadge();
const online = useOnlineStatus();
const { background, surface, border, text, muted, warningBg, warningText, primary, danger, shadow } = useAdminTheme();
const {
background,
surface,
border,
text,
muted,
warningBg,
warningText,
primary,
danger,
shadow,
glassSurfaceStrong,
glassBorder,
glassShadow,
appBackground,
} = useAdminTheme();
const backgroundColor = background;
const surfaceColor = surface;
const borderColor = border;
const textColor = text;
const mutedText = muted;
const headerSurface = withAlpha(surfaceColor, 0.94);
const headerSurface = glassSurfaceStrong ?? withAlpha(surfaceColor, 0.94);
const headerBorder = glassBorder ?? borderColor;
const actionSurface = glassSurfaceStrong ?? surfaceColor;
const actionBorder = glassBorder ?? borderColor;
const actionShadow = glassShadow ?? shadow;
const [fallbackEvents, setFallbackEvents] = React.useState<TenantEvent[]>([]);
const [loadingEvents, setLoadingEvents] = React.useState(false);
const [attemptedFetch, setAttemptedFetch] = React.useState(false);
@@ -152,10 +171,17 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
width={34}
height={34}
borderRadius={12}
backgroundColor={surfaceColor}
backgroundColor={actionSurface}
borderWidth={1}
borderColor={actionBorder}
alignItems="center"
justifyContent="center"
position="relative"
style={{
boxShadow: `0 10px 18px ${actionShadow}`,
backdropFilter: 'blur(12px)',
WebkitBackdropFilter: 'blur(12px)',
}}
>
<Bell size={16} color={textColor} />
{notificationCount > 0 ? (
@@ -190,6 +216,7 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
backgroundColor={primary}
alignItems="center"
justifyContent="center"
style={{ boxShadow: `0 10px 18px ${withAlpha(primary, 0.32)}` }}
>
<QrCode size={16} color="white" />
</XStack>
@@ -200,18 +227,23 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
);
return (
<YStack backgroundColor={backgroundColor} minHeight="100vh" alignItems="center">
<YStack
backgroundColor={backgroundColor}
minHeight="100vh"
alignItems="center"
style={{ background: appBackground }}
>
<YStack
backgroundColor={headerSurface}
borderBottomWidth={1}
borderColor={borderColor}
borderColor={headerBorder}
paddingHorizontal="$4"
paddingTop="$4"
paddingBottom="$3"
shadowColor={shadow}
shadowOpacity={0.06}
shadowRadius={10}
shadowOffset={{ width: 0, height: 4 }}
shadowColor={actionShadow}
shadowOpacity={0.08}
shadowRadius={14}
shadowOffset={{ width: 0, height: 8 }}
width="100%"
maxWidth={800}
position="sticky"

View File

@@ -2,27 +2,32 @@ import React from 'react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite';
import { useAdminTheme } from '../theme';
import { ADMIN_GRADIENTS, useAdminTheme } from '../theme';
import { withAlpha } from './colors';
export function MobileCard({
children,
className,
style,
...rest
}: React.ComponentProps<typeof YStack>) {
const { surface, border, shadow } = useAdminTheme();
const { surface, border, shadow, glassSurface, glassBorder, glassShadow } = useAdminTheme();
return (
<YStack
className={['admin-fade-up', className].filter(Boolean).join(' ')}
backgroundColor={surface}
borderRadius={18}
borderWidth={1}
borderColor={border}
shadowColor={shadow}
shadowOpacity={0.08}
shadowRadius={14}
backgroundColor={glassSurface ?? surface}
borderRadius={20}
borderWidth={2}
borderColor={glassBorder ?? border}
shadowColor={glassShadow ?? shadow}
shadowOpacity={0.16}
shadowRadius={16}
shadowOffset={{ width: 0, height: 10 }}
padding="$3.5"
space="$2"
style={{
...style,
}}
{...rest}
>
{children}
@@ -99,13 +104,18 @@ export function CTAButton({
iconLeft?: React.ReactNode;
iconRight?: React.ReactNode;
}) {
const { primary, surface, border, text, danger } = useAdminTheme();
const { primary, surface, border, text, danger, glassSurfaceStrong } = useAdminTheme();
const isPrimary = tone === 'primary';
const isDanger = tone === 'danger';
const isDisabled = disabled || loading;
const backgroundColor = isDanger ? danger : isPrimary ? primary : surface;
const backgroundColor = isDanger ? danger : isPrimary ? primary : glassSurfaceStrong ?? surface;
const borderColor = isPrimary || isDanger ? 'transparent' : border;
const labelColor = isPrimary || isDanger ? 'white' : text;
const primaryStyle = isPrimary
? {
boxShadow: `0 18px 28px ${withAlpha(primary, 0.4)}`,
}
: undefined;
return (
<Pressable
onPress={isDisabled ? undefined : onPress}
@@ -119,13 +129,14 @@ export function CTAButton({
>
<XStack
height={52}
borderRadius={16}
borderRadius={18}
alignItems="center"
justifyContent="center"
backgroundColor={backgroundColor}
borderWidth={isPrimary || isDanger ? 0 : 1}
borderWidth={isPrimary || isDanger ? 0 : 2}
borderColor={borderColor}
space="$2"
style={primaryStyle}
>
{iconLeft}
<Text fontSize="$sm" fontWeight="800" color={labelColor}>
@@ -183,6 +194,7 @@ export function ActionTile({
color,
onPress,
disabled = false,
variant = 'grid',
delayMs = 0,
}: {
icon: React.ComponentType<{ size?: number; color?: string }>;
@@ -190,49 +202,56 @@ export function ActionTile({
color: string;
onPress?: () => void;
disabled?: boolean;
variant?: 'grid' | 'cluster';
delayMs?: number;
}) {
const { textStrong } = useAdminTheme();
const backgroundColor = `${color}18`;
const borderColor = `${color}40`;
const shadowColor = `${color}2b`;
const iconShadow = `${color}55`;
const { textStrong, glassSurface } = useAdminTheme();
const isCluster = variant === 'cluster';
const backgroundColor = withAlpha(color, 0.12);
const borderColor = withAlpha(color, 0.4);
const shadowColor = withAlpha(color, 0.35);
const iconShadow = withAlpha(color, 0.5);
const tileStyle = {
...(delayMs ? { animationDelay: `${delayMs}ms` } : {}),
backgroundImage: `linear-gradient(135deg, ${backgroundColor}, ${color}0f)`,
boxShadow: `0 10px 24px ${shadowColor}`,
backgroundImage: `linear-gradient(135deg, ${backgroundColor}, ${withAlpha(color, 0.08)})`,
boxShadow: isCluster ? `0 12px 18px ${shadowColor}` : `0 20px 30px ${shadowColor}`,
};
return (
<Pressable
onPress={disabled ? undefined : onPress}
style={{ width: '48%', marginBottom: 12, opacity: disabled ? 0.5 : 1 }}
style={{
width: isCluster ? '100%' : '48%',
flex: isCluster ? 1 : undefined,
marginBottom: isCluster ? 0 : 12,
opacity: disabled ? 0.5 : 1,
}}
disabled={disabled}
>
<YStack
className="admin-fade-up"
style={tileStyle}
borderRadius={16}
borderRadius={isCluster ? 14 : 16}
padding="$3"
space="$2.5"
backgroundColor={backgroundColor}
borderWidth={1}
backgroundColor={glassSurface ?? backgroundColor}
borderWidth={2}
borderColor={borderColor}
minHeight={110}
minHeight={120}
alignItems="center"
justifyContent="center"
>
<XStack
width={36}
height={36}
borderRadius={12}
width={44}
height={44}
borderRadius={14}
backgroundColor={color}
alignItems="center"
justifyContent="center"
style={{ boxShadow: `0 6px 14px ${iconShadow}` }}
>
<IconCmp size={16} color="white" />
<IconCmp size={18} color="white" />
</XStack>
<Text fontSize="$sm" fontWeight="700" color={textStrong} textAlign="center">
<Text fontSize="$sm" fontWeight="800" color={textStrong} textAlign="center">
{label}
</Text>
</YStack>

View File

@@ -17,11 +17,12 @@ type MobileScaffoldProps = {
export function MobileScaffold({ title, onBack, rightSlot, children, footer }: MobileScaffoldProps) {
const { t } = useTranslation('mobile');
const { background, surface, border, text, primary } = useAdminTheme();
const headerSurface = withAlpha(surface, 0.94);
const { background, surface, border, text, primary, glassSurfaceStrong, glassBorder, appBackground } = useAdminTheme();
const headerSurface = glassSurfaceStrong ?? withAlpha(surface, 0.94);
const headerBorder = glassBorder ?? border;
return (
<YStack backgroundColor={background} minHeight="100vh">
<YStack backgroundColor={background} minHeight="100vh" style={{ background: appBackground }}>
<XStack
alignItems="center"
justifyContent="space-between"
@@ -30,7 +31,7 @@ export function MobileScaffold({ title, onBack, rightSlot, children, footer }: M
paddingBottom="$3"
backgroundColor={headerSurface}
borderBottomWidth={1}
borderColor={border}
borderColor={headerBorder}
position="sticky"
top={0}
zIndex={60}

View File

@@ -92,6 +92,11 @@ vi.mock('../../theme', () => ({
primary: '#FF5A5F',
danger: '#b91c1c',
shadow: 'rgba(0,0,0,0.12)',
glassSurface: 'rgba(255,255,255,0.8)',
glassSurfaceStrong: 'rgba(255,255,255,0.9)',
glassBorder: 'rgba(229,231,235,0.7)',
glassShadow: 'rgba(15,23,42,0.14)',
appBackground: 'linear-gradient(180deg, #f7fafc, #eef3f7)',
}),
}));

View File

@@ -182,7 +182,7 @@ const DEFAULT_PRESET: LayoutPreset = [
height: 220,
fontSize: 110,
align: 'center',
fontFamily: 'Fraunces',
fontFamily: 'Archivo Black',
lineHeight: 1.3,
},
{ id: 'subtitle', type: 'subtitle', x: (c) => (c.canvasWidth - 800) / 2, y: 580, width: 800, height: 120, fontSize: 42, align: 'center', fontFamily: 'Manrope', lineHeight: 1.4 },
@@ -202,7 +202,7 @@ const evergreenVowsPreset: LayoutPreset = [
height: 200,
fontSize: 95,
align: 'left',
fontFamily: 'Fraunces',
fontFamily: 'Archivo Black',
lineHeight: 1.3,
},
{
@@ -251,7 +251,7 @@ const midnightGalaPreset: LayoutPreset = [
height: 220,
fontSize: 105,
align: 'center',
fontFamily: 'Fraunces',
fontFamily: 'Archivo Black',
lineHeight: 1.3,
},
{ id: 'subtitle', type: 'subtitle', x: (c) => (c.canvasWidth - 900) / 2, y: 480, width: 900, height: 120, fontSize: 40, align: 'center', fontFamily: 'Manrope', lineHeight: 1.4 },
@@ -269,7 +269,7 @@ const midnightGalaPreset: LayoutPreset = [
const gardenBrunchPreset: LayoutPreset = [
// Verspielt, asymmetrisch, aber ausbalanciert
{ id: 'headline', type: 'headline', x: 120, y: 240, width: 900, height: 200, fontSize: 90, align: 'left', fontFamily: 'Fraunces', lineHeight: 1.3 },
{ id: 'headline', type: 'headline', x: 120, y: 240, width: 900, height: 200, fontSize: 90, align: 'left', fontFamily: 'Archivo Black', lineHeight: 1.3 },
{ id: 'subtitle', type: 'subtitle', x: 120, y: 450, width: 700, height: 120, fontSize: 40, align: 'left', fontFamily: 'Manrope', lineHeight: 1.4 },
{
id: 'qr',
@@ -305,7 +305,7 @@ const sparklerSoireePreset: LayoutPreset = [
height: 220,
fontSize: 100,
align: 'center',
fontFamily: 'Fraunces',
fontFamily: 'Archivo Black',
lineHeight: 1.3,
},
{ id: 'subtitle', type: 'subtitle', x: (c) => (c.canvasWidth - 800) / 2, y: 480, width: 800, height: 120, fontSize: 40, align: 'center', fontFamily: 'Manrope' },
@@ -333,7 +333,7 @@ const confettiBashPreset: LayoutPreset = [
height: 220,
fontSize: 110,
align: 'center',
fontFamily: 'Fraunces',
fontFamily: 'Archivo Black',
lineHeight: 1.3,
},
{
@@ -394,7 +394,7 @@ const balancedModernPreset: LayoutPreset = [
height: 380,
fontSize: 100,
align: 'left',
fontFamily: 'Fraunces',
fontFamily: 'Archivo Black',
lineHeight: 1.3,
},
{

View File

@@ -1,59 +1,120 @@
import { useTheme } from '@tamagui/core';
export const ADMIN_COLORS = {
primary: '#FF5A5F',
primaryStrong: '#C2413B',
accent: '#FFF8F5',
accentSoft: '#FFF1EB',
accentWarm: '#FFE4DA',
warning: '#F5C542',
success: '#06D6A0',
danger: '#B91C1C',
text: '#1F2937',
textMuted: '#6B7280',
textSubtle: '#94A3B8',
border: '#F2E4DA',
primary: '#FF5C5C',
primaryStrong: '#E63B57',
accent: '#3D5AFE',
accentSoft: '#E8ECFF',
accentWarm: '#FFE3D6',
warning: '#FBBF24',
success: '#22C55E',
danger: '#EF4444',
text: '#0B132B',
textMuted: '#54606E',
textSubtle: '#8C99A8',
border: '#F3D6C9',
surface: '#FFFFFF',
surfaceMuted: '#FFFDFB',
backdrop: '#0F172A',
surfaceMuted: '#FFF6F0',
backdrop: '#0B132B',
};
export const ADMIN_ACTION_COLORS = {
settings: '#14B8A6',
tasks: '#F59E0B',
qr: '#3B82F6',
images: '#8B5CF6',
liveShow: '#EC4899',
liveShowSettings: '#0EA5E9',
guests: '#10B981',
guestMessages: '#F97316',
branding: '#6366F1',
photobooth: '#E11D48',
recap: '#64748B',
settings: '#00C2A8',
tasks: '#FFC857',
qr: '#3D5AFE',
images: '#FF7AB6',
liveShow: '#FF6D00',
liveShowSettings: '#00B0FF',
guests: '#22C55E',
guestMessages: '#FF8A00',
branding: '#00B4D8',
photobooth: '#FF3D71',
recap: '#94A3B8',
packages: ADMIN_COLORS.primary,
analytics: '#22C55E',
invites: ADMIN_COLORS.primaryStrong,
invites: '#3D5AFE',
};
export const ADMIN_GRADIENTS = {
primaryCta: `linear-gradient(135deg, ${ADMIN_COLORS.primary}, #FF8A8E, ${ADMIN_COLORS.accent})`,
softCard: `linear-gradient(135deg, ${ADMIN_COLORS.accentSoft}, ${ADMIN_COLORS.accentWarm})`,
loginBackground: 'linear-gradient(135deg, #0b1020, #0f172a, #0b1020)',
primaryCta: `linear-gradient(135deg, ${ADMIN_COLORS.primary}, #FF9A5C, ${ADMIN_COLORS.accent})`,
softCard: 'linear-gradient(145deg, rgba(255,255,255,0.98), rgba(255,245,238,0.92))',
loginBackground: 'linear-gradient(135deg, #0b132b, #162040, #0b132b)',
appBackground:
'radial-gradient(circle at 15% 0%, rgba(255, 92, 92, 0.22), transparent 50%), radial-gradient(circle at 85% 10%, rgba(61, 90, 254, 0.2), transparent 55%), linear-gradient(180deg, #fff4ee 0%, #ffefe4 100%)',
appBackgroundDark:
'radial-gradient(circle at 15% 0%, rgba(255, 92, 92, 0.18), transparent 55%), radial-gradient(circle at 85% 10%, rgba(61, 90, 254, 0.18), transparent 55%), linear-gradient(180deg, #0b132b 0%, #0e1a33 100%)',
};
export const ADMIN_MOTION = {
tileStaggerMs: 40,
};
type Rgb = { r: number; g: number; b: number };
function parseRgb(color: string): Rgb | null {
const trimmed = color.trim();
if (trimmed.startsWith('#')) {
const hex = trimmed.slice(1);
const normalized = hex.length === 3 ? hex.split('').map((ch) => ch + ch).join('') : hex;
if (normalized.length === 6) {
return {
r: Number.parseInt(normalized.slice(0, 2), 16),
g: Number.parseInt(normalized.slice(2, 4), 16),
b: Number.parseInt(normalized.slice(4, 6), 16),
};
}
}
const rgb = trimmed.match(/^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/i);
if (rgb) {
return { r: Number(rgb[1]), g: Number(rgb[2]), b: Number(rgb[3]) };
}
const rgba = trimmed.match(/^rgba\((\d+),\s*(\d+),\s*(\d+),\s*([0-9.]+)\)$/i);
if (rgba) {
return { r: Number(rgba[1]), g: Number(rgba[2]), b: Number(rgba[3]) };
}
return null;
}
function withAlpha(color: string, alpha: number): string {
const rgb = parseRgb(color);
if (! rgb) {
return color;
}
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${alpha})`;
}
function isDarkColor(color: string): boolean {
const rgb = parseRgb(color);
if (! rgb) {
return false;
}
const luminance = (0.2126 * rgb.r + 0.7152 * rgb.g + 0.0722 * rgb.b) / 255;
return luminance < 0.5;
}
export function useAdminTheme() {
const theme = useTheme();
const background = String(theme.background?.val ?? '#FFF8F5');
const surface = String(theme.surface?.val ?? ADMIN_COLORS.surface);
const border = String(theme.borderColor?.val ?? ADMIN_COLORS.border);
const isDark = isDarkColor(background);
const glassSurface = withAlpha(surface, isDark ? 0.96 : 0.98);
const glassSurfaceStrong = withAlpha(surface, isDark ? 1 : 1);
const glassBorder = withAlpha(border, isDark ? 0.85 : 0.95);
const glassShadow = isDark ? 'rgba(0, 0, 0, 0.55)' : 'rgba(11, 19, 43, 0.18)';
const appBackground = isDark ? ADMIN_GRADIENTS.appBackgroundDark : ADMIN_GRADIENTS.appBackground;
return {
theme,
background: String(theme.background?.val ?? '#FFF8F5'),
surface: String(theme.surface?.val ?? ADMIN_COLORS.surface),
background,
surface,
surfaceMuted: String(theme.gray2?.val ?? ADMIN_COLORS.surfaceMuted),
border: String(theme.borderColor?.val ?? ADMIN_COLORS.border),
border,
text: String(theme.color?.val ?? ADMIN_COLORS.text),
textStrong: String(theme.color12?.val ?? theme.color?.val ?? ADMIN_COLORS.text),
muted: String(theme.gray?.val ?? ADMIN_COLORS.textMuted),
@@ -73,7 +134,12 @@ export function useAdminTheme() {
infoText: String(theme.blue10?.val ?? ADMIN_COLORS.primaryStrong),
danger: String(theme.red10?.val ?? ADMIN_COLORS.danger),
backdrop: String(theme.gray12?.val ?? ADMIN_COLORS.backdrop),
overlay: String(theme.gray12?.val ?? 'rgba(15, 23, 42, 0.6)'),
shadow: String(theme.shadowColor?.val ?? 'rgba(31, 41, 55, 0.12)'),
overlay: withAlpha(String(theme.gray12?.val ?? ADMIN_COLORS.backdrop), 0.6),
shadow: String(theme.shadowColor?.val ?? 'rgba(15, 23, 42, 0.12)'),
glassSurface,
glassSurfaceStrong,
glassBorder,
glassShadow,
appBackground,
};
}