weiterer fortschritt mit tamagui und dem neuen mobile event admin

This commit is contained in:
Codex Agent
2025-12-10 20:01:47 +01:00
parent 73e550ee87
commit 7b01a77083
26 changed files with 761 additions and 139 deletions

View File

@@ -27,10 +27,12 @@ import { useEventContext } from '../context/EventContext';
import { EventSwitcher, EventMenuBar } from './EventNav';
import {
ADMIN_EVENT_CREATE_PATH,
ADMIN_EVENT_MEMBERS_PATH,
ADMIN_EVENT_INVITES_PATH,
ADMIN_EVENT_PHOTOS_PATH,
ADMIN_EVENT_TASKS_PATH,
ADMIN_EVENT_PHOTOBOOTH_PATH,
ADMIN_EVENT_VIEW_PATH,
} from '../constants';
import { formatEventDate, resolveEngagementMode, resolveEventDisplayName } from '../lib/events';

View File

@@ -22,6 +22,8 @@ const EventContext = React.createContext<EventContextValue | undefined>(undefine
export function EventProvider({ children }: { children: React.ReactNode }) {
const { status } = useAuth();
const [manualEvents, setManualEvents] = React.useState<TenantEvent[]>([]);
const [manualAttempted, setManualAttempted] = React.useState(false);
const [storedSlug, setStoredSlug] = React.useState<string | null>(() => {
if (typeof window === 'undefined') {
return null;
@@ -51,8 +53,28 @@ export function EventProvider({ children }: { children: React.ReactNode }) {
initialData: [],
});
const events = React.useMemo(() => (authReady ? fetchedEvents : []), [authReady, fetchedEvents]);
const isLoading = authReady ? queryLoading : status === 'loading';
const events = React.useMemo(
() => (authReady ? (manualEvents.length ? manualEvents : fetchedEvents) : []),
[authReady, fetchedEvents, manualEvents]
);
const isLoading = authReady ? queryLoading || (!manualAttempted && manualEvents.length === 0 && fetchedEvents.length === 0) : status === 'loading';
React.useEffect(() => {
if (!authReady || manualAttempted || queryLoading) {
return;
}
if (fetchedEvents.length > 0 && !isError) {
return;
}
setManualAttempted(true);
getEvents({ force: true })
.then((list) => {
setManualEvents(list ?? []);
})
.catch(() => {
setManualEvents([]);
});
}, [authReady, fetchedEvents.length, isError, manualAttempted, queryLoading]);
React.useEffect(() => {
if (!events.length || typeof window === 'undefined') {

View File

@@ -5,14 +5,12 @@ import { Bell, RefreshCcw } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite';
import { MobileScaffold } from './components/Scaffold';
import { MobileShell } from './components/MobileShell';
import { MobileCard, PillBadge } from './components/Primitives';
import { BottomNav } from './components/BottomNav';
import { GuestNotificationSummary, listGuestNotifications } from '../api';
import { isAuthError } from '../auth/tokens';
import { getApiErrorMessage } from '../lib/apiError';
import toast from 'react-hot-toast';
import { useMobileNav } from './hooks/useMobileNav';
import { MobileSheet } from './components/Sheet';
import { getEvents, TenantEvent } from '../api';
@@ -47,7 +45,6 @@ export default function MobileAlertsPage() {
const [alerts, setAlerts] = React.useState<AlertItem[]>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const { go } = useMobileNav(slug ?? null);
const [events, setEvents] = React.useState<TenantEvent[]>([]);
const [showEventPicker, setShowEventPicker] = React.useState(false);
@@ -84,17 +81,15 @@ export default function MobileAlertsPage() {
}, []);
return (
<MobileScaffold
<MobileShell
activeTab="home"
title={t('alerts.title', 'Alerts')}
onBack={() => navigate(-1)}
rightSlot={
headerActions={
<Pressable onPress={() => reload()}>
<RefreshCcw size={18} color="#0f172a" />
</Pressable>
}
footer={
<BottomNav active="home" onNavigate={go} />
}
>
{error ? (
<MobileCard>
@@ -192,6 +187,6 @@ export default function MobileAlertsPage() {
)}
</YStack>
</MobileSheet>
</MobileScaffold>
</MobileShell>
);
}

View File

@@ -0,0 +1,276 @@
import React from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { CreditCard, Package, Receipt, RefreshCcw, Sparkles } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite';
import { MobileShell } from './components/MobileShell';
import { MobileCard, CTAButton, PillBadge } from './components/Primitives';
import {
getTenantPackagesOverview,
getTenantPaddleTransactions,
TenantPackageSummary,
PaddleTransactionSummary,
} from '../api';
import { TenantAddonHistoryEntry, getTenantAddonHistory } from '../api';
import { getApiErrorMessage } from '../lib/apiError';
import { adminPath } from '../constants';
export default function MobileBillingPage() {
const { t } = useTranslation('management');
const navigate = useNavigate();
const location = useLocation();
const [packages, setPackages] = React.useState<TenantPackageSummary[]>([]);
const [activePackage, setActivePackage] = React.useState<TenantPackageSummary | null>(null);
const [transactions, setTransactions] = React.useState<PaddleTransactionSummary[]>([]);
const [addons, setAddons] = React.useState<TenantAddonHistoryEntry[]>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const packagesRef = React.useRef<HTMLDivElement | null>(null);
const invoicesRef = React.useRef<HTMLDivElement | null>(null);
const load = React.useCallback(async () => {
setLoading(true);
try {
const [pkg, trx, addonHistory] = await Promise.all([
getTenantPackagesOverview({ force: true }),
getTenantPaddleTransactions().catch(() => ({ data: [] as PaddleTransactionSummary[] })),
getTenantAddonHistory().catch(() => ({ data: [] as TenantAddonHistoryEntry[] })),
]);
setPackages(pkg.packages ?? []);
setActivePackage(pkg.activePackage ?? null);
setTransactions(trx.data ?? []);
setAddons(addonHistory.data ?? []);
setError(null);
} catch (err) {
setError(getApiErrorMessage(err, t('billing.errors.load', 'Konnte Abrechnung nicht laden.')));
} finally {
setLoading(false);
}
}, [t]);
React.useEffect(() => {
void load();
}, [load]);
React.useEffect(() => {
if (!location.hash) return;
const hash = location.hash.replace('#', '');
const target = hash === 'invoices' ? invoicesRef.current : packagesRef.current;
if (target) {
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}, [location.hash, loading]);
return (
<MobileShell
activeTab="profile"
title={t('billing.title', 'Billing & Packages')}
onBack={() => navigate(-1)}
headerActions={
<Pressable onPress={() => load()}>
<RefreshCcw size={18} color="#0f172a" />
</Pressable>
}
>
{error ? (
<MobileCard>
<Text fontWeight="700" color="#b91c1c">
{error}
</Text>
</MobileCard>
) : null}
<MobileCard space="$2" ref={packagesRef as any}>
<XStack alignItems="center" space="$2">
<Package size={18} color="#0f172a" />
<Text fontSize="$md" fontWeight="800" color="#0f172a">
{t('billing.sections.packages.title', 'Packages')}
</Text>
</XStack>
{loading ? (
<Text fontSize="$sm" color="#6b7280">
{t('common.loading', 'Lädt...')}
</Text>
) : (
<YStack space="$2">
{activePackage ? (
<PackageCard pkg={activePackage} label={t('billing.sections.packages.card.statusActive', 'Aktiv')} />
) : null}
{packages
.filter((pkg) => !activePackage || pkg.id !== activePackage.id)
.map((pkg) => (
<PackageCard key={pkg.id} pkg={pkg} />
))}
</YStack>
)}
</MobileCard>
<MobileCard space="$2" ref={invoicesRef as any}>
<XStack alignItems="center" space="$2">
<Receipt size={18} color="#0f172a" />
<Text fontSize="$md" fontWeight="800" color="#0f172a">
{t('billing.sections.invoices.title', 'Invoices & Payments')}
</Text>
</XStack>
{loading ? (
<Text fontSize="$sm" color="#6b7280">
{t('common.loading', 'Lädt...')}
</Text>
) : transactions.length === 0 ? (
<Text fontSize="$sm" color="#4b5563">
{t('billing.sections.invoices.empty', 'Keine Zahlungen gefunden.')}
</Text>
) : (
<YStack space="$1.5">
{transactions.slice(0, 8).map((trx) => (
<XStack key={trx.id} alignItems="center" justifyContent="space-between" borderBottomWidth={1} borderColor="#e5e7eb" paddingVertical="$1.5">
<YStack>
<Text fontSize="$sm" color="#0f172a" fontWeight="700">
{trx.status ?? '—'}
</Text>
<Text fontSize="$xs" color="#6b7280">
{formatDate(trx.created_at)}
</Text>
{trx.origin ? (
<Text fontSize="$xs" color="#9ca3af">
{trx.origin}
</Text>
) : null}
</YStack>
<YStack alignItems="flex-end">
<Text fontSize="$sm" color="#0f172a" fontWeight="700">
{formatAmount(trx.amount, trx.currency)}
</Text>
{trx.tax ? (
<Text fontSize="$xs" color="#6b7280">
{t('billing.sections.transactions.labels.tax', { value: formatAmount(trx.tax, trx.currency) })}
</Text>
) : null}
{trx.receipt_url ? (
<a href={trx.receipt_url} target="_blank" rel="noreferrer" style={{ fontSize: 12, color: '#2563eb' }}>
{t('billing.sections.transactions.labels.receipt', 'Beleg')}
</a>
) : null}
</YStack>
</XStack>
))}
</YStack>
)}
{null}
</MobileCard>
<MobileCard space="$2">
<XStack alignItems="center" space="$2">
<Sparkles size={18} color="#0f172a" />
<Text fontSize="$md" fontWeight="800" color="#0f172a">
{t('billing.sections.addOns.title', 'Add-ons')}
</Text>
</XStack>
{loading ? (
<Text fontSize="$sm" color="#6b7280">
{t('common.loading', 'Lädt...')}
</Text>
) : addons.length === 0 ? (
<Text fontSize="$sm" color="#4b5563">
{t('billing.sections.addOns.empty', 'Keine Add-ons gebucht.')}
</Text>
) : (
<YStack space="$1.5">
{addons.slice(0, 8).map((addon) => (
<AddonRow key={addon.id} addon={addon} />
))}
</YStack>
)}
{null}
</MobileCard>
</MobileShell>
);
}
function PackageCard({ pkg, label }: { pkg: TenantPackageSummary; label?: string }) {
const remaining = pkg.remaining_events ?? (pkg.package_limits?.max_events_per_year as number | undefined) ?? 0;
const expires = pkg.expires_at ? formatDate(pkg.expires_at) : null;
return (
<MobileCard borderColor="#e5e7eb" space="$2">
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$md" fontWeight="800" color="#0f172a">
{pkg.package_name ?? 'Package'}
</Text>
{label ? <PillBadge tone="success">{label}</PillBadge> : null}
</XStack>
{expires ? (
<Text fontSize="$xs" color="#6b7280">
{expires}
</Text>
) : null}
<XStack space="$2" marginTop="$2">
<PillBadge tone="muted">
{remaining} Events
</PillBadge>
{pkg.price !== null && pkg.price !== undefined ? (
<PillBadge tone="muted">{formatAmount(pkg.price, pkg.currency ?? 'EUR')}</PillBadge>
) : null}
</XStack>
</MobileCard>
);
}
function formatAmount(value: number | null | undefined, currency: string | null | undefined): string {
if (value === null || value === undefined) {
return '—';
}
const cur = currency ?? 'EUR';
try {
return new Intl.NumberFormat(undefined, { style: 'currency', currency: cur }).format(value);
} catch {
return `${value} ${cur}`;
}
}
function AddonRow({ addon }: { addon: TenantAddonHistoryEntry }) {
const labels: Record<TenantAddonHistoryEntry['status'], { tone: 'success' | 'warning' | 'muted'; text: string }> = {
completed: { tone: 'success', text: 'Completed' },
pending: { tone: 'warning', text: 'Pending' },
failed: { tone: 'muted', text: 'Failed' },
};
const status = labels[addon.status];
const eventName =
(addon.event?.name && typeof addon.event.name === 'string' && addon.event.name) ||
(addon.event?.name && typeof addon.event.name === 'object' ? addon.event.name?.en ?? addon.event.name?.de ?? Object.values(addon.event.name)[0] : null) ||
null;
return (
<MobileCard borderColor="#e5e7eb" padding="$3" space="$1.5">
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$sm" fontWeight="700" color="#0f172a">
{addon.label ?? addon.addon_key}
</Text>
<PillBadge tone={status.tone}>{status.text}</PillBadge>
</XStack>
<Text fontSize="$xs" color="#6b7280">
{formatDate(addon.purchased_at)}
</Text>
{eventName ? (
<Text fontSize="$xs" color="#9ca3af">
{eventName}
</Text>
) : null}
<XStack space="$2" marginTop="$1">
{addon.extra_photos ? <PillBadge tone="muted">+{addon.extra_photos} photos</PillBadge> : null}
{addon.extra_guests ? <PillBadge tone="muted">+{addon.extra_guests} guests</PillBadge> : null}
{addon.extra_gallery_days ? <PillBadge tone="muted">+{addon.extra_gallery_days} days</PillBadge> : null}
</XStack>
<Text fontSize="$sm" color="#0f172a" marginTop="$1">
{formatAmount(addon.amount, addon.currency)}
</Text>
</MobileCard>
);
}
function formatDate(value: string | null | undefined): string {
if (!value) return '—';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '—';
return date.toLocaleDateString(undefined, { day: '2-digit', month: 'short', year: 'numeric' });
}

View File

@@ -5,13 +5,11 @@ import { Image as ImageIcon, RefreshCcw, Save } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite';
import { MobileScaffold } from './components/Scaffold';
import { MobileShell } from './components/MobileShell';
import { MobileCard, CTAButton } from './components/Primitives';
import { BottomNav } from './components/BottomNav';
import { TenantEvent, getEvent, updateEvent, getTenantFonts, TenantFont } from '../api';
import { isAuthError } from '../auth/tokens';
import { getApiErrorMessage } from '../lib/apiError';
import { useMobileNav } from './hooks/useMobileNav';
import { MobileSheet } from './components/Sheet';
type BrandingForm = {
@@ -37,7 +35,6 @@ export default function MobileBrandingPage() {
const [loading, setLoading] = React.useState(true);
const [saving, setSaving] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const { go } = useMobileNav(slug);
const [showFontsSheet, setShowFontsSheet] = React.useState(false);
const [fonts, setFonts] = React.useState<TenantFont[]>([]);
const [fontsLoading, setFontsLoading] = React.useState(false);
@@ -108,17 +105,15 @@ export default function MobileBrandingPage() {
}
return (
<MobileScaffold
<MobileShell
activeTab="home"
title={t('events.branding.title', 'Branding & Customization')}
onBack={() => navigate(-1)}
rightSlot={
headerActions={
<Pressable disabled={saving} onPress={() => handleSave()}>
<Save size={18} color="#007AFF" />
</Pressable>
}
footer={
<BottomNav active="home" onNavigate={go} />
}
>
{error ? (
<MobileCard>
@@ -280,7 +275,7 @@ export default function MobileBrandingPage() {
)}
</YStack>
</MobileSheet>
</MobileScaffold>
</MobileShell>
);
}

View File

@@ -10,13 +10,16 @@ import { MobileShell, renderEventLocation } from './components/MobileShell';
import { MobileCard, CTAButton, KpiTile, ActionTile, PillBadge } from './components/Primitives';
import { adminPath } from '../constants';
import { useEventContext } from '../context/EventContext';
import { getEventStats, EventStats, TenantEvent } from '../api';
import { getEventStats, EventStats, TenantEvent, getEvents } from '../api';
import { formatEventDate, resolveEventDisplayName } from '../lib/events';
export default function MobileDashboardPage() {
const navigate = useNavigate();
const { t, i18n } = useTranslation('management');
const { events, activeEvent, hasEvents, hasMultipleEvents, isLoading } = useEventContext();
const { events, activeEvent, hasEvents, hasMultipleEvents, isLoading, selectEvent } = useEventContext();
const [fallbackEvents, setFallbackEvents] = React.useState<TenantEvent[]>([]);
const [fallbackLoading, setFallbackLoading] = React.useState(false);
const [fallbackAttempted, setFallbackAttempted] = React.useState(false);
const { data: stats, isLoading: statsLoading } = useQuery<EventStats | null>({
queryKey: ['mobile', 'dashboard', 'stats', activeEvent?.slug],
@@ -28,8 +31,36 @@ export default function MobileDashboardPage() {
});
const locale = i18n.language?.startsWith('en') ? 'en-GB' : 'de-DE';
const { data: dashboardEvents } = useQuery<TenantEvent[]>({
queryKey: ['mobile', 'dashboard', 'events'],
queryFn: () => getEvents({ force: true }),
staleTime: 60_000,
});
const effectiveEvents = events.length ? events : dashboardEvents?.length ? dashboardEvents : fallbackEvents;
const effectiveHasEvents = hasEvents || Boolean(dashboardEvents?.length) || fallbackEvents.length > 0;
const effectiveMultiple =
hasMultipleEvents || (dashboardEvents?.length ?? 0) > 1 || fallbackEvents.length > 1;
if (isLoading) {
React.useEffect(() => {
if (events.length || isLoading || fallbackLoading || fallbackAttempted) {
return;
}
setFallbackAttempted(true);
setFallbackLoading(true);
getEvents({ force: true })
.then((list: TenantEvent[]) => {
setFallbackEvents(list ?? []);
if (list?.length === 1 && !activeEvent) {
selectEvent(list[0]?.slug ?? null);
}
})
.catch(() => {
setFallbackEvents([]);
})
.finally(() => setFallbackLoading(false));
}, [events.length, isLoading, activeEvent, selectEvent, fallbackLoading, fallbackAttempted]);
if (isLoading || fallbackLoading) {
return (
<MobileShell activeTab="home" title={t('events.list.dashboardTitle', 'Dashboard')}>
<YStack space="$2">
@@ -41,7 +72,7 @@ export default function MobileDashboardPage() {
);
}
if (!hasEvents) {
if (!effectiveHasEvents) {
return (
<MobileShell activeTab="home" title={t('events.list.dashboardTitle', 'Dashboard')}>
<OnboardingEmptyState />
@@ -49,10 +80,14 @@ export default function MobileDashboardPage() {
);
}
if (hasMultipleEvents && !activeEvent) {
if (effectiveMultiple && !activeEvent) {
return (
<MobileShell activeTab="home" title={t('events.list.dashboardTitle', 'Dashboard')}>
<EventPickerList events={events} locale={locale} />
<MobileShell
activeTab="home"
title={t('events.list.dashboardTitle', 'Dashboard')}
subtitle={t('header.selectEvent', 'Select an event to continue')}
>
<EventPickerList events={effectiveEvents} locale={locale} />
</MobileShell>
);
}
@@ -123,16 +158,39 @@ function OnboardingEmptyState() {
function EventPickerList({ events, locale }: { events: TenantEvent[]; locale: string }) {
const { t } = useTranslation('management');
const { selectEvent } = useEventContext();
const navigate = useNavigate();
const [localEvents, setLocalEvents] = React.useState<TenantEvent[]>(events);
const [loading, setLoading] = React.useState(false);
React.useEffect(() => {
setLocalEvents(events);
}, [events]);
React.useEffect(() => {
if (events.length > 0 || loading) {
return;
}
setLoading(true);
getEvents({ force: true })
.then((list) => setLocalEvents(list ?? []))
.catch(() => setLocalEvents([]))
.finally(() => setLoading(false));
}, [events.length, loading]);
return (
<YStack space="$2">
<Text fontSize="$sm" color="#111827" fontWeight="700">
{t('events.detail.pickEvent', 'Select an event')}
</Text>
{events.map((event) => (
{localEvents.map((event) => (
<Pressable
key={event.slug}
onPress={() => selectEvent(event.slug ?? null)}
onPress={() => {
selectEvent(event.slug ?? null);
if (event.slug) {
navigate(adminPath(`/mobile/events/${event.slug}`));
}
}}
>
<MobileCard borderColor="#e5e7eb" space="$2">
<XStack alignItems="center" justifyContent="space-between">

View File

@@ -5,16 +5,15 @@ import { CalendarDays, MapPin, Settings, Users, Camera, Sparkles, QrCode, Image,
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite';
import { MobileScaffold } from './components/Scaffold';
import { MobileShell } from './components/MobileShell';
import { MobileCard, PillBadge, KpiTile, ActionTile } from './components/Primitives';
import { BottomNav } from './components/BottomNav';
import { TenantEvent, EventStats, EventToolkit, getEvent, getEventStats, getEventToolkit } from '../api';
import { TenantEvent, EventStats, EventToolkit, getEvent, getEventStats, getEventToolkit, getEvents } from '../api';
import { adminPath, ADMIN_EVENT_BRANDING_PATH, ADMIN_EVENT_INVITES_PATH, ADMIN_EVENT_MEMBERS_PATH, ADMIN_EVENT_PHOTOS_PATH, ADMIN_EVENT_TASKS_PATH } from '../constants';
import { isAuthError } from '../auth/tokens';
import { getApiErrorMessage } from '../lib/apiError';
import { useMobileNav } from './hooks/useMobileNav';
import { MobileSheet } from './components/Sheet';
import { useEventContext } from '../context/EventContext';
import { formatEventDate, resolveEventDisplayName } from '../lib/events';
export default function MobileEventDetailPage() {
const { slug: slugParam } = useParams<{ slug?: string }>();
@@ -27,10 +26,14 @@ export default function MobileEventDetailPage() {
const [toolkit, setToolkit] = React.useState<EventToolkit | null>(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const { go } = useMobileNav(slug);
const { events, activeEvent, selectEvent } = useEventContext();
const [showEventPicker, setShowEventPicker] = React.useState(false);
React.useEffect(() => {
if (!slug) return;
selectEvent(slug);
}, [slug, selectEvent]);
React.useEffect(() => {
if (!slug) return;
(async () => {
@@ -43,7 +46,18 @@ export default function MobileEventDetailPage() {
setError(null);
} catch (err) {
if (!isAuthError(err)) {
setError(getApiErrorMessage(err, t('events.errors.loadFailed', 'Event konnte nicht geladen werden.')));
try {
const list = await getEvents({ force: true });
const fallback = list.find((ev: TenantEvent) => ev.slug === slug) ?? null;
if (fallback) {
setEvent(fallback);
setError(null);
} else {
setError(getApiErrorMessage(err, t('events.errors.loadFailed', 'Event konnte nicht geladen werden.')));
}
} catch (fallbackErr) {
setError(getApiErrorMessage(fallbackErr, t('events.errors.loadFailed', 'Event konnte nicht geladen werden.')));
}
}
} finally {
setLoading(false);
@@ -53,8 +67,8 @@ export default function MobileEventDetailPage() {
const kpis = [
{
label: t('events.detail.kpi.tasks', 'Tasks Completed'),
value: toolkit?.tasks?.summary ? `${toolkit.tasks.summary.completed}/${toolkit.tasks.summary.total}` : '—',
label: t('events.detail.kpi.tasks', 'Active Tasks'),
value: event?.tasks_count ?? toolkit?.tasks?.summary?.total ?? '—',
icon: Sparkles,
},
{
@@ -70,19 +84,17 @@ export default function MobileEventDetailPage() {
];
return (
<MobileScaffold
title={t('events.detail.title', 'Event Details Dashboard')}
onBack={() => navigate(adminPath('/mobile/events'))}
rightSlot={
<MobileShell
activeTab="home"
title={resolveEventDisplayName(event ?? activeEvent ?? undefined)}
subtitle={
event?.event_date || activeEvent?.event_date
? formatDate(event?.event_date ?? activeEvent?.event_date)
: undefined
}
onBack={() => navigate(-1)}
headerActions={
<XStack space="$3" alignItems="center">
<Pressable onPress={() => setShowEventPicker(true)}>
<XStack alignItems="center" space="$1.5">
<Text fontSize="$sm" color="#007AFF" fontWeight="600">
{activeEvent?.name ?? t('events.detail.pickEvent', 'Event wählen')}
</Text>
<ChevronDown size={14} color="#007AFF" />
</XStack>
</Pressable>
<Pressable onPress={() => navigate(adminPath('/settings'))}>
<Settings size={18} color="#0f172a" />
</Pressable>
@@ -91,9 +103,6 @@ export default function MobileEventDetailPage() {
</Pressable>
</XStack>
}
footer={
<BottomNav active="home" onNavigate={go} />
}
>
{error ? (
<MobileCard>
@@ -225,7 +234,7 @@ export default function MobileEventDetailPage() {
/>
</XStack>
</YStack>
</MobileScaffold>
</MobileShell>
);
}

View File

@@ -4,14 +4,12 @@ import { useTranslation } from 'react-i18next';
import { CalendarDays, ChevronDown, MapPin } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { MobileScaffold } from './components/Scaffold';
import { MobileShell } from './components/MobileShell';
import { MobileCard, CTAButton } from './components/Primitives';
import { BottomNav } from './components/BottomNav';
import { createEvent, getEvent, updateEvent, TenantEvent } from '../api';
import { adminPath } from '../constants';
import { isAuthError } from '../auth/tokens';
import { getApiErrorMessage } from '../lib/apiError';
import { useMobileNav } from './hooks/useMobileNav';
type FormState = {
name: string;
@@ -42,7 +40,6 @@ export default function MobileEventFormPage() {
const [loading, setLoading] = React.useState(isEdit);
const [saving, setSaving] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const { go } = useMobileNav(slug);
React.useEffect(() => {
if (!slug) return;
@@ -102,12 +99,10 @@ export default function MobileEventFormPage() {
}
return (
<MobileScaffold
<MobileShell
activeTab="home"
title={isEdit ? t('events.form.editTitle', 'Edit Event') : t('events.form.createTitle', 'Create New Event')}
onBack={() => navigate(-1)}
footer={
<BottomNav active="home" onNavigate={go} />
}
>
{error ? (
<MobileCard>
@@ -222,7 +217,7 @@ export default function MobileEventFormPage() {
) : null}
<CTAButton label={saving ? t('events.form.saving', 'Saving...') : isEdit ? t('events.form.update', 'Update Event') : t('events.form.create', 'Create Event')} onPress={() => handleSubmit()} />
</YStack>
</MobileScaffold>
</MobileShell>
);
}

View File

@@ -4,14 +4,13 @@ import { useTranslation } from 'react-i18next';
import { UserPlus, Trash2, Copy, RefreshCcw } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { MobileScaffold } from './components/Scaffold';
import { Pressable } from '@tamagui/react-native-web-lite';
import { MobileShell } from './components/MobileShell';
import { MobileCard, CTAButton, PillBadge } from './components/Primitives';
import { BottomNav } from './components/BottomNav';
import { EventMember, getEventMembers, inviteEventMember, removeEventMember } from '../api';
import { isAuthError } from '../auth/tokens';
import { getApiErrorMessage } from '../lib/apiError';
import toast from 'react-hot-toast';
import { useMobileNav } from './hooks/useMobileNav';
import { MobileSheet } from './components/Sheet';
export default function MobileEventMembersPage() {
@@ -26,9 +25,6 @@ export default function MobileEventMembersPage() {
const [invite, setInvite] = React.useState({ name: '', email: '', role: 'member' as EventMember['role'] });
const [saving, setSaving] = React.useState(false);
const [inviteLink, setInviteLink] = React.useState<string | null>(null);
const { go } = useMobileNav(slug);
const [search, setSearch] = React.useState('');
const [confirmRemove, setConfirmRemove] = React.useState<EventMember | null>(null);
const [search, setSearch] = React.useState('');
const [confirmRemove, setConfirmRemove] = React.useState<EventMember | null>(null);
@@ -96,17 +92,15 @@ export default function MobileEventMembersPage() {
}
return (
<MobileScaffold
<MobileShell
activeTab="home"
title={t('events.members.title', 'Guest Management')}
onBack={() => navigate(-1)}
rightSlot={
headerActions={
<Pressable onPress={() => load()}>
<RefreshCcw size={18} color="#0f172a" />
</Pressable>
}
footer={
<BottomNav active="home" onNavigate={go} />
}
>
{error ? (
<MobileCard>
@@ -289,7 +283,7 @@ export default function MobileEventMembersPage() {
{confirmRemove?.name || confirmRemove?.email}
</Text>
</MobileSheet>
</MobileScaffold>
</MobileShell>
);
}

View File

@@ -55,7 +55,6 @@ export default function MobileEventPhotosPage() {
featured: filter === 'featured' || onlyFeatured,
status: filter === 'hidden' || onlyHidden ? 'hidden' : undefined,
search: search || undefined,
uploader: uploaderFilter || undefined,
});
setPhotos((prev) => (page === 1 ? result.photos : [...prev, ...result.photos]));
setTotalCount(result.meta?.total ?? result.photos.length);
@@ -339,3 +338,24 @@ export default function MobileEventPhotosPage() {
</MobileShell>
);
}
const inputStyle: React.CSSProperties = {
width: '100%',
height: 38,
borderRadius: 10,
border: '1px solid #e5e7eb',
padding: '0 12px',
fontSize: 13,
background: 'white',
};
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<YStack space="$1">
<Text fontSize="$sm" color="#111827">
{label}
</Text>
{children}
</YStack>
);
}

View File

@@ -136,9 +136,9 @@ export default function MobileEventTasksPage() {
}
async function importCollection(collectionId: number) {
if (!eventId) return;
if (!slug || !eventId) return;
try {
await importTaskCollection(collectionId, eventId);
await importTaskCollection(collectionId, slug);
const result = await getEventTasks(eventId, 1);
setTasks(result.data);
toast.success(t('events.tasks.imported', 'Aufgabenpaket importiert'));
@@ -459,7 +459,7 @@ export default function MobileEventTasksPage() {
<ListItem
title={
<Text fontSize={12.5} fontWeight="600" color="#111827">
{collection.title}
{collection.name}
</Text>
}
subTitle={

View File

@@ -5,14 +5,12 @@ import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite';
import { useTranslation } from 'react-i18next';
import { MobileScaffold } from './components/Scaffold';
import { MobileShell } from './components/MobileShell';
import { MobileCard, PillBadge, CTAButton } from './components/Primitives';
import { BottomNav } from './components/BottomNav';
import { getEvents, TenantEvent } from '../api';
import { adminPath } from '../constants';
import { isAuthError } from '../auth/tokens';
import { getApiErrorMessage } from '../lib/apiError';
import { useMobileNav } from './hooks/useMobileNav';
export default function MobileEventsPage() {
const { t } = useTranslation('management');
@@ -20,7 +18,6 @@ export default function MobileEventsPage() {
const [events, setEvents] = React.useState<TenantEvent[]>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const { go } = useMobileNav();
const [query, setQuery] = React.useState('');
React.useEffect(() => {
@@ -38,17 +35,15 @@ export default function MobileEventsPage() {
}, [t]);
return (
<MobileScaffold
<MobileShell
activeTab="home"
title={t('events.list.dashboardTitle', 'All Events Dashboard')}
onBack={() => navigate(-1)}
rightSlot={
headerActions={
<Pressable>
<Search size={18} color="#0f172a" />
</Pressable>
}
footer={
<BottomNav active="home" onNavigate={go} />
}
>
{error ? (
<MobileCard>
@@ -111,7 +106,7 @@ export default function MobileEventsPage() {
))}
</YStack>
)}
</MobileScaffold>
</MobileShell>
);
}

View File

@@ -4,12 +4,11 @@ import { useTranslation } from 'react-i18next';
import { LogOut, User, Settings, Shield, Globe, Moon } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { MobileScaffold } from './components/Scaffold';
import { Pressable } from '@tamagui/react-native-web-lite';
import { MobileShell } from './components/MobileShell';
import { MobileCard, CTAButton } from './components/Primitives';
import { BottomNav } from './components/BottomNav';
import { useAuth } from '../auth/context';
import { fetchTenantProfile } from '../api';
import { useMobileNav } from './hooks/useMobileNav';
import { adminPath } from '../constants';
import i18n from '../i18n';
@@ -17,7 +16,6 @@ export default function MobileProfilePage() {
const { user, logout } = useAuth();
const navigate = useNavigate();
const { t } = useTranslation('management');
const { go } = useMobileNav();
const [name, setName] = React.useState(user?.name ?? 'Guest');
const [email, setEmail] = React.useState(user?.email ?? '');
@@ -39,12 +37,10 @@ export default function MobileProfilePage() {
}, [email, name, role]);
return (
<MobileScaffold
<MobileShell
activeTab="profile"
title={t('profile.title', 'Profile')}
onBack={() => navigate(-1)}
footer={
<BottomNav active="profile" onNavigate={go} />
}
>
<MobileCard space="$3" alignItems="center">
<XStack
@@ -74,7 +70,7 @@ export default function MobileProfilePage() {
<Text fontSize="$md" fontWeight="800" color="#111827">
{t('profile.settings', 'Settings')}
</Text>
<Pressable onPress={() => navigate(adminPath('/settings'))}>
<Pressable onPress={() => navigate(adminPath('/mobile/settings'))}>
<XStack alignItems="center" justifyContent="space-between" paddingVertical="$2" borderBottomWidth={1} borderColor="#e5e7eb">
<Text fontSize="$sm" color="#111827">
{t('profile.account', 'Account & Security')}
@@ -82,6 +78,22 @@ export default function MobileProfilePage() {
<Settings size={18} color="#9ca3af" />
</XStack>
</Pressable>
<Pressable onPress={() => navigate(adminPath('/mobile/billing#packages'))}>
<XStack alignItems="center" justifyContent="space-between" paddingVertical="$2" borderBottomWidth={1} borderColor="#e5e7eb">
<Text fontSize="$sm" color="#111827">
{t('billing.sections.packages.title', 'Packages & Billing')}
</Text>
<Settings size={18} color="#9ca3af" />
</XStack>
</Pressable>
<Pressable onPress={() => navigate(adminPath('/mobile/billing#invoices'))}>
<XStack alignItems="center" justifyContent="space-between" paddingVertical="$2">
<Text fontSize="$sm" color="#111827">
{t('billing.sections.invoices.title', 'Invoices & Payments')}
</Text>
<Settings size={18} color="#9ca3af" />
</XStack>
</Pressable>
<XStack alignItems="center" justifyContent="space-between" paddingVertical="$2" borderBottomWidth={1} borderColor="#e5e7eb">
<XStack space="$2" alignItems="center">
<Globe size={16} color="#6b7280" />
@@ -127,6 +139,6 @@ export default function MobileProfilePage() {
navigate(adminPath('/logout'));
}}
/>
</MobileScaffold>
</MobileShell>
);
}

View File

@@ -5,14 +5,12 @@ import { Download, Share2, ChevronRight, RefreshCcw } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite';
import { MobileScaffold } from './components/Scaffold';
import { MobileShell } from './components/MobileShell';
import { MobileCard, CTAButton, PillBadge } from './components/Primitives';
import { BottomNav } from './components/BottomNav';
import { TenantEvent, getEvent, getEventQrInvites, createQrInvite } from '../api';
import { isAuthError } from '../auth/tokens';
import { getApiErrorMessage } from '../lib/apiError';
import toast from 'react-hot-toast';
import { useMobileNav } from './hooks/useMobileNav';
import { MobileSheet } from './components/Sheet';
const LAYOUTS = [
@@ -33,7 +31,6 @@ export default function MobileQrPrintPage() {
const [loading, setLoading] = React.useState(true);
const [paperSize, setPaperSize] = React.useState('A4 (210 x 297 mm)');
const [qrUrl, setQrUrl] = React.useState<string>('');
const { go } = useMobileNav(slug);
const [showPaperSheet, setShowPaperSheet] = React.useState(false);
const [showLayoutSheet, setShowLayoutSheet] = React.useState(false);
@@ -59,17 +56,15 @@ export default function MobileQrPrintPage() {
}, [slug, t]);
return (
<MobileScaffold
<MobileShell
activeTab="home"
title={t('events.qr.title', 'QR Code & Print Layouts')}
onBack={() => navigate(-1)}
rightSlot={
headerActions={
<Pressable onPress={() => window.location.reload()}>
<RefreshCcw size={18} color="#0f172a" />
</Pressable>
}
footer={
<BottomNav active="home" onNavigate={go} />
}
>
{error ? (
<MobileCard>
@@ -124,7 +119,8 @@ export default function MobileQrPrintPage() {
label={t('events.qr.share', 'Share')}
onPress={async () => {
try {
await navigator.clipboard.writeText(qrUrl || event?.public_url || '');
const shareUrl = String(qrUrl || (event as any)?.public_url || '');
await navigator.clipboard.writeText(shareUrl);
toast.success(t('events.qr.shareSuccess', 'Link kopiert'));
} catch {
toast.error(t('events.qr.shareFailed', 'Konnte Link nicht kopieren'));
@@ -200,7 +196,8 @@ export default function MobileQrPrintPage() {
onPress={async () => {
if (!slug) return;
try {
const invite = await createQrInvite(slug, { label: 'Mobile Link' });
if (!slug) return;
const invite = await createQrInvite(slug, { label: 'Mobile Link' });
setQrUrl(invite.url);
toast.success(t('events.qr.created', 'Neuer QR-Link erstellt'));
} catch (err) {
@@ -265,6 +262,6 @@ export default function MobileQrPrintPage() {
))}
</YStack>
</MobileSheet>
</MobileScaffold>
</MobileShell>
);
}

View File

@@ -0,0 +1,160 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Shield, Bell, LogOut, User } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite';
import { MobileShell } from './components/MobileShell';
import { MobileCard, CTAButton, PillBadge } from './components/Primitives';
import { useAuth } from '../auth/context';
import {
getNotificationPreferences,
updateNotificationPreferences,
NotificationPreferences,
} from '../api';
import { getApiErrorMessage } from '../lib/apiError';
import { adminPath } from '../constants';
type PreferenceKey = keyof NotificationPreferences;
const PREFERENCE_LABELS: Record<PreferenceKey, string> = {
task_updates: 'Task updates',
photo_limits: 'Photo limits',
photo_thresholds: 'Photo thresholds',
guest_limits: 'Guest limits',
guest_thresholds: 'Guest thresholds',
purchase_limits: 'Purchase limits',
billing: 'Billing & invoices',
alerts: 'Alerts',
};
export default function MobileSettingsPage() {
const { t } = useTranslation('management');
const navigate = useNavigate();
const { user, logout } = useAuth();
const [preferences, setPreferences] = React.useState<NotificationPreferences>({});
const [defaults, setDefaults] = React.useState<NotificationPreferences>({});
const [loading, setLoading] = React.useState(true);
const [saving, setSaving] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
React.useEffect(() => {
(async () => {
setLoading(true);
try {
const result = await getNotificationPreferences();
setPreferences(result.preferences);
setDefaults(result.defaults ?? {});
setError(null);
} catch (err) {
setError(getApiErrorMessage(err, t('settings.notifications.errorLoad', 'Benachrichtigungen konnten nicht geladen werden.')));
} finally {
setLoading(false);
}
})();
}, [t]);
const togglePref = (key: PreferenceKey) => {
setPreferences((prev) => ({
...prev,
[key]: !prev[key],
}));
};
const handleSave = async () => {
setSaving(true);
try {
await updateNotificationPreferences(preferences);
setError(null);
} catch (err) {
setError(getApiErrorMessage(err, t('settings.notifications.errorSave', 'Speichern fehlgeschlagen')));
} finally {
setSaving(false);
}
};
const handleReset = () => {
setPreferences(defaults);
};
return (
<MobileShell activeTab="profile" title={t('settings.title', 'Einstellungen')} onBack={() => navigate(-1)}>
{error ? (
<MobileCard>
<Text fontWeight="700" color="#b91c1c">
{error}
</Text>
</MobileCard>
) : null}
<MobileCard space="$3">
<XStack alignItems="center" space="$2">
<Shield size={18} color="#0f172a" />
<Text fontSize="$md" fontWeight="800" color="#0f172a">
{t('settings.session.title', 'Account')}
</Text>
</XStack>
<Text fontSize="$sm" color="#4b5563">
{user?.name ?? user?.email ?? t('settings.session.unknown', 'Benutzer')}
</Text>
{user?.tenant_id ? (
<PillBadge tone="muted">Tenant #{user.tenant_id}</PillBadge>
) : null}
<XStack space="$2">
<CTAButton label={t('settings.profile.actions.openProfile', 'Profil bearbeiten')} onPress={() => navigate(adminPath('/mobile/profile'))} />
<CTAButton label={t('settings.session.logout', 'Abmelden')} tone="ghost" onPress={() => logout({ redirect: adminPath('/logout') })} />
</XStack>
</MobileCard>
<MobileCard space="$3">
<XStack alignItems="center" space="$2">
<Bell size={18} color="#0f172a" />
<Text fontSize="$md" fontWeight="800" color="#0f172a">
{t('settings.notifications.title', 'Benachrichtigungen')}
</Text>
</XStack>
{loading ? (
<Text fontSize="$sm" color="#6b7280">
{t('settings.notifications.loading', 'Lade Einstellungen ...')}
</Text>
) : (
<YStack space="$2">
{Object.keys(PREFERENCE_LABELS).map((key) => {
const prefKey = key as PreferenceKey;
return (
<XStack key={prefKey} alignItems="center" justifyContent="space-between" borderBottomWidth={1} borderColor="#e5e7eb" paddingBottom="$2" paddingTop="$1.5">
<Text fontSize="$sm" color="#0f172a">
{PREFERENCE_LABELS[prefKey]}
</Text>
<input
type="checkbox"
checked={Boolean(preferences[prefKey])}
onChange={() => togglePref(prefKey)}
/>
</XStack>
);
})}
</YStack>
)}
<XStack space="$2">
<CTAButton label={saving ? t('common.processing', '...') : t('settings.notifications.actions.save', 'Speichern')} onPress={() => handleSave()} />
<CTAButton label={t('common.reset', 'Reset')} tone="ghost" onPress={() => handleReset()} />
</XStack>
</MobileCard>
<MobileCard space="$3">
<XStack alignItems="center" space="$2">
<User size={18} color="#0f172a" />
<Text fontSize="$md" fontWeight="800" color="#0f172a">
{t('settings.appearance.title', 'Darstellung')}
</Text>
</XStack>
<Text fontSize="$sm" color="#4b5563">
{t('settings.appearance.description', 'Schalte Dark-Mode oder passe Branding im Admin an.')}
</Text>
<CTAButton label={t('settings.appearance.title', 'Darstellung & Branding')} tone="ghost" onPress={() => navigate(adminPath('/settings'))} />
</MobileCard>
</MobileShell>
);
}

View File

@@ -43,7 +43,14 @@ export function BottomNav({ active, onNavigate }: { active: NavKey; onNavigate:
const IconCmp = item.icon;
return (
<Pressable key={item.key} onPress={() => onNavigate(item.key)}>
<YStack alignItems="center" space="$1" position="relative">
<YStack
alignItems="center"
space="$1"
position="relative"
padding="$2"
borderRadius={12}
backgroundColor={activeState ? '#e8f1ff' : 'transparent'}
>
<IconCmp size={20} color={activeState ? String(theme.primary?.val ?? '#007AFF') : '#94a3b8'} />
<Text fontSize="$xs" color={activeState ? '$primary' : '#6b7280'}>
{item.label}

View File

@@ -13,7 +13,7 @@ import { MobileSheet } from './Sheet';
import { MobileCard, PillBadge } from './Primitives';
import { useAlertsBadge } from '../hooks/useAlertsBadge';
import { formatEventDate, resolveEventDisplayName } from '../../lib/events';
import { TenantEvent } from '../../api';
import { TenantEvent, getEvents } from '../../api';
type MobileShellProps = {
title?: string;
@@ -31,19 +31,54 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
const { t, i18n } = useTranslation('mobile');
const { count: alertCount } = useAlertsBadge();
const [pickerOpen, setPickerOpen] = React.useState(false);
const [fallbackEvents, setFallbackEvents] = React.useState<TenantEvent[]>([]);
const [loadingEvents, setLoadingEvents] = React.useState(false);
const [attemptedFetch, setAttemptedFetch] = React.useState(false);
const locale = i18n.language?.startsWith('en') ? 'en-GB' : 'de-DE';
const eventTitle = title ?? (activeEvent ? resolveEventDisplayName(activeEvent) : t('header.appName', 'Event Admin'));
const effectiveEvents = events.length ? events : fallbackEvents;
const effectiveHasMultiple = hasMultipleEvents || effectiveEvents.length > 1;
const effectiveHasEvents = hasEvents || effectiveEvents.length > 0;
const effectiveActive = activeEvent ?? (effectiveEvents.length === 1 ? effectiveEvents[0] : null);
React.useEffect(() => {
if (events.length || loadingEvents || attemptedFetch) {
return;
}
setAttemptedFetch(true);
setLoadingEvents(true);
getEvents({ force: true })
.then((list) => {
setFallbackEvents(list ?? []);
if (!activeEvent && list?.length === 1) {
selectEvent(list[0]?.slug ?? null);
}
})
.catch(() => setFallbackEvents([]))
.finally(() => setLoadingEvents(false));
}, [events.length, loadingEvents, attemptedFetch, activeEvent, selectEvent]);
React.useEffect(() => {
if (!pickerOpen) return;
if (effectiveEvents.length) return;
setLoadingEvents(true);
getEvents({ force: true })
.then((list) => setFallbackEvents(list ?? []))
.catch(() => setFallbackEvents([]))
.finally(() => setLoadingEvents(false));
}, [pickerOpen, effectiveEvents.length]);
const eventTitle = title ?? (effectiveActive ? resolveEventDisplayName(effectiveActive) : t('header.appName', 'Event Admin'));
const subtitleText =
subtitle ??
(activeEvent?.event_date
? formatEventDate(activeEvent.event_date, locale) ?? ''
: hasEvents
(effectiveActive?.event_date
? formatEventDate(effectiveActive.event_date, locale) ?? ''
: effectiveHasEvents
? t('header.selectEvent', 'Select an event to continue')
: t('header.empty', 'Create your first event to get started'));
const showEventSwitcher = hasMultipleEvents;
const showQr = Boolean(activeEvent?.slug);
const showEventSwitcher = effectiveHasMultiple;
const showQr = Boolean(effectiveActive?.slug);
return (
<YStack backgroundColor="#f7f8fb" minHeight="100vh">
@@ -65,9 +100,6 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
<Pressable onPress={onBack}>
<XStack alignItems="center" space="$1.5">
<ChevronLeft size={18} color="#007AFF" />
<Text fontSize="$sm" color="#007AFF" fontWeight="600">
{t('actions.back', 'Back')}
</Text>
</XStack>
</Pressable>
) : null}
@@ -120,7 +152,7 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
</XStack>
</Pressable>
{showQr ? (
<Pressable onPress={() => navigate(adminPath(`/mobile/events/${activeEvent?.slug}/qr`))}>
<Pressable onPress={() => navigate(adminPath(`/mobile/events/${effectiveActive?.slug}/qr`))}>
<XStack
height={34}
paddingHorizontal="$3"
@@ -156,7 +188,7 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
bottomOffsetPx={110}
>
<YStack space="$2">
{events.length === 0 ? (
{effectiveEvents.length === 0 ? (
<MobileCard alignItems="flex-start" space="$2">
<Text fontSize="$sm" color="#111827" fontWeight="700">
{t('header.noEventsTitle', 'Create your first event')}
@@ -173,12 +205,16 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
</Pressable>
</MobileCard>
) : (
events.map((event) => (
effectiveEvents.map((event) => (
<Pressable
key={event.slug}
onPress={() => {
selectEvent(event.slug ?? null);
const targetSlug = event.slug ?? null;
selectEvent(targetSlug);
setPickerOpen(false);
if (targetSlug) {
navigate(adminPath(`/mobile/events/${targetSlug}`));
}
}}
>
<XStack alignItems="center" justifyContent="space-between" paddingVertical="$2">

View File

@@ -122,20 +122,22 @@ export function ActionTile({
onPress: () => void;
}) {
return (
<Pressable onPress={onPress} style={{ width: '48%' }}>
<Pressable onPress={onPress} style={{ width: '48%', marginBottom: 12 }}>
<YStack
borderRadius={16}
padding="$3"
space="$2"
space="$2.5"
backgroundColor={`${color}22`}
borderWidth={1}
borderColor={`${color}55`}
minHeight={110}
alignItems="center"
justifyContent="center"
>
<XStack width={38} height={38} borderRadius={12} backgroundColor={color} alignItems="center" justifyContent="center">
<IconCmp size={18} color="white" />
<XStack width={34} height={34} borderRadius={12} backgroundColor={color} alignItems="center" justifyContent="center">
<IconCmp size={16} color="white" />
</XStack>
<Text fontSize="$sm" fontWeight="700" color="#111827">
<Text fontSize="$sm" fontWeight="700" color="#111827" textAlign="center">
{label}
</Text>
</YStack>

View File

@@ -96,7 +96,6 @@ export default function SettingsPage() {
const result = await getNotificationPreferences();
setPreferences(result.preferences);
setDefaults(result.defaults);
setNotificationMeta(result.meta ?? null);
} catch (error) {
setNotificationError(getApiErrorMessage(error, t('settings.notifications.errorLoad', 'Benachrichtigungseinstellungen konnten nicht geladen werden.')));
} finally {

View File

@@ -41,7 +41,7 @@ vi.mock('../../onboarding', () => ({
vi.mock('../context/EventContext', () => ({
useEventContext: () => ({ events: [], activeEvent: null, selectEvent: vi.fn(), isLoading: false, isError: false, refetch: vi.fn() }),
}), { virtual: true });
}));
vi.mock('../../api', () => ({
getDashboardSummary: vi.fn().mockResolvedValue(null),

View File

@@ -228,6 +228,7 @@ export function InviteLayoutCustomizerPanel({
backgroundImages = preloadedBackgrounds,
}: InviteLayoutCustomizerPanelProps): React.JSX.Element {
const { t } = useTranslation('management');
const fabricCanvasRef = React.useRef<any>(null);
const { fonts: availableFonts, isLoading: fontsLoading } = useTenantFonts();
const inviteUrl = invite?.url ?? '';

View File

@@ -33,6 +33,8 @@ const MobileEventMembersPage = React.lazy(() => import('./mobile/EventMembersPag
const MobileEventTasksPage = React.lazy(() => import('./mobile/EventTasksPage'));
const MobileAlertsPage = React.lazy(() => import('./mobile/AlertsPage'));
const MobileProfilePage = React.lazy(() => import('./mobile/ProfilePage'));
const MobileBillingPage = React.lazy(() => import('./mobile/BillingPage'));
const MobileSettingsPage = React.lazy(() => import('./mobile/SettingsPage'));
const MobileLoginPage = React.lazy(() => import('./mobile/LoginPage'));
const MobileDashboardPage = React.lazy(() => import('./mobile/DashboardPage'));
const MobileTasksTabPage = React.lazy(() => import('./mobile/TasksTabPage'));
@@ -138,6 +140,8 @@ export const router = createBrowserRouter([
{ path: 'mobile/events/:slug/tasks', element: <MobileEventTasksPage /> },
{ path: 'mobile/alerts', element: <MobileAlertsPage /> },
{ path: 'mobile/profile', element: <RequireAdminAccess><MobileProfilePage /></RequireAdminAccess> },
{ path: 'mobile/billing', element: <RequireAdminAccess><MobileBillingPage /></RequireAdminAccess> },
{ path: 'mobile/settings', element: <RequireAdminAccess><MobileSettingsPage /></RequireAdminAccess> },
{ path: 'mobile/dashboard', element: <MobileDashboardPage /> },
{ path: 'mobile/tasks', element: <MobileTasksTabPage /> },
{ path: 'mobile/uploads', element: <MobileUploadsTabPage /> },

View File

@@ -26,7 +26,14 @@ const appRoot = async () => {
const { ToastProvider } = await import('./components/ToastHost');
const { LocaleProvider } = await import('./i18n/LocaleContext');
const { default: MatomoTracker } = await import('@/components/analytics/MatomoTracker');
const matomoConfig = (window as any).__MATOMO_GUEST__ as { enabled?: boolean; url?: string; siteId?: string };
const rawMatomo = (window as any).__MATOMO_GUEST__ as { enabled?: boolean; url?: string; siteId?: string } | undefined;
const matomoConfig = rawMatomo
? {
enabled: Boolean(rawMatomo.enabled),
url: rawMatomo.url ?? '',
siteId: rawMatomo.siteId ?? '',
}
: undefined;
// Register a minimal service worker for background sync (best-effort)
if ('serviceWorker' in navigator) {

View File

@@ -65,10 +65,13 @@ export async function createGiftVoucherCheckout(data: GiftVoucherCheckoutRequest
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
const message =
(payload?.errors && typeof payload.errors === 'object' && Object.values(payload.errors)[0]?.[0]) ||
payload?.message ||
'Unable to start checkout';
const errors = (payload?.errors ?? {}) as Record<string, unknown>;
const firstErrorEntry = Object.values(errors)[0];
const firstError =
Array.isArray(firstErrorEntry) && typeof firstErrorEntry[0] === 'string'
? firstErrorEntry[0]
: null;
const message = firstError || (typeof payload?.message === 'string' ? payload.message : null) || 'Unable to start checkout';
throw new Error(message);
}

30
resources/js/types/shims.d.ts vendored Normal file
View File

@@ -0,0 +1,30 @@
declare module '@tamagui/stacks' {
export const YStack: any;
export const XStack: any;
export const Stack: any;
}
declare module '@tamagui/text' {
export const SizableText: any;
}
declare module '@tamagui/button' {
const Button: any;
export default Button;
export const ButtonText: any;
export { Button };
}
declare module '@tamagui/list-item' {
export const ListItem: any;
}
declare module '@tamagui/react-native-web-lite' {
export const Pressable: any;
export * from 'react-native';
}
declare module '@/actions/*' {
const mod: any;
export = mod;
}

View File

@@ -127,5 +127,8 @@
"resources/js/guest/**/*.ts",
"resources/js/guest/**/*.tsx",
"resources/js/guest/**/*.d.ts"
],
"exclude": [
"resources/js/actions/**"
]
}