From 7b01a770839a73313ca23a9c17e2d67cb43153e5 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Wed, 10 Dec 2025 20:01:47 +0100 Subject: [PATCH] weiterer fortschritt mit tamagui und dem neuen mobile event admin --- .../js/admin/components/CommandShelf.tsx | 2 + resources/js/admin/context/EventContext.tsx | 26 +- resources/js/admin/mobile/AlertsPage.tsx | 15 +- resources/js/admin/mobile/BillingPage.tsx | 276 ++++++++++++++++++ resources/js/admin/mobile/BrandingPage.tsx | 15 +- resources/js/admin/mobile/DashboardPage.tsx | 76 ++++- resources/js/admin/mobile/EventDetailPage.tsx | 57 ++-- resources/js/admin/mobile/EventFormPage.tsx | 13 +- .../js/admin/mobile/EventMembersPage.tsx | 18 +- resources/js/admin/mobile/EventPhotosPage.tsx | 22 +- resources/js/admin/mobile/EventTasksPage.tsx | 6 +- resources/js/admin/mobile/EventsPage.tsx | 15 +- resources/js/admin/mobile/ProfilePage.tsx | 32 +- resources/js/admin/mobile/QrPrintPage.tsx | 21 +- resources/js/admin/mobile/SettingsPage.tsx | 160 ++++++++++ .../js/admin/mobile/components/BottomNav.tsx | 9 +- .../admin/mobile/components/MobileShell.tsx | 64 +++- .../js/admin/mobile/components/Primitives.tsx | 12 +- resources/js/admin/pages/SettingsPage.tsx | 1 - .../__tests__/DashboardPage.guard.test.tsx | 2 +- .../InviteLayoutCustomizerPanel.tsx | 1 + resources/js/admin/router.tsx | 4 + resources/js/guest/main.tsx | 9 +- resources/js/lib/giftVouchers.ts | 11 +- resources/js/types/shims.d.ts | 30 ++ tsconfig.json | 3 + 26 files changed, 761 insertions(+), 139 deletions(-) create mode 100644 resources/js/admin/mobile/BillingPage.tsx create mode 100644 resources/js/admin/mobile/SettingsPage.tsx create mode 100644 resources/js/types/shims.d.ts diff --git a/resources/js/admin/components/CommandShelf.tsx b/resources/js/admin/components/CommandShelf.tsx index 3ea2ed7..544f171 100644 --- a/resources/js/admin/components/CommandShelf.tsx +++ b/resources/js/admin/components/CommandShelf.tsx @@ -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'; diff --git a/resources/js/admin/context/EventContext.tsx b/resources/js/admin/context/EventContext.tsx index dcbf418..0d4a094 100644 --- a/resources/js/admin/context/EventContext.tsx +++ b/resources/js/admin/context/EventContext.tsx @@ -22,6 +22,8 @@ const EventContext = React.createContext(undefine export function EventProvider({ children }: { children: React.ReactNode }) { const { status } = useAuth(); + const [manualEvents, setManualEvents] = React.useState([]); + const [manualAttempted, setManualAttempted] = React.useState(false); const [storedSlug, setStoredSlug] = React.useState(() => { 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') { diff --git a/resources/js/admin/mobile/AlertsPage.tsx b/resources/js/admin/mobile/AlertsPage.tsx index cade079..74b843b 100644 --- a/resources/js/admin/mobile/AlertsPage.tsx +++ b/resources/js/admin/mobile/AlertsPage.tsx @@ -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([]); const [loading, setLoading] = React.useState(true); const [error, setError] = React.useState(null); - const { go } = useMobileNav(slug ?? null); const [events, setEvents] = React.useState([]); const [showEventPicker, setShowEventPicker] = React.useState(false); @@ -84,17 +81,15 @@ export default function MobileAlertsPage() { }, []); return ( - navigate(-1)} - rightSlot={ + headerActions={ reload()}> } - footer={ - - } > {error ? ( @@ -192,6 +187,6 @@ export default function MobileAlertsPage() { )} - + ); } diff --git a/resources/js/admin/mobile/BillingPage.tsx b/resources/js/admin/mobile/BillingPage.tsx new file mode 100644 index 0000000..9c862e0 --- /dev/null +++ b/resources/js/admin/mobile/BillingPage.tsx @@ -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([]); + const [activePackage, setActivePackage] = React.useState(null); + const [transactions, setTransactions] = React.useState([]); + const [addons, setAddons] = React.useState([]); + const [loading, setLoading] = React.useState(true); + const [error, setError] = React.useState(null); + const packagesRef = React.useRef(null); + const invoicesRef = React.useRef(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 ( + navigate(-1)} + headerActions={ + load()}> + + + } + > + {error ? ( + + + {error} + + + ) : null} + + + + + + {t('billing.sections.packages.title', 'Packages')} + + + {loading ? ( + + {t('common.loading', 'Lädt...')} + + ) : ( + + {activePackage ? ( + + ) : null} + {packages + .filter((pkg) => !activePackage || pkg.id !== activePackage.id) + .map((pkg) => ( + + ))} + + )} + + + + + + + {t('billing.sections.invoices.title', 'Invoices & Payments')} + + + {loading ? ( + + {t('common.loading', 'Lädt...')} + + ) : transactions.length === 0 ? ( + + {t('billing.sections.invoices.empty', 'Keine Zahlungen gefunden.')} + + ) : ( + + {transactions.slice(0, 8).map((trx) => ( + + + + {trx.status ?? '—'} + + + {formatDate(trx.created_at)} + + {trx.origin ? ( + + {trx.origin} + + ) : null} + + + + {formatAmount(trx.amount, trx.currency)} + + {trx.tax ? ( + + {t('billing.sections.transactions.labels.tax', { value: formatAmount(trx.tax, trx.currency) })} + + ) : null} + {trx.receipt_url ? ( + + {t('billing.sections.transactions.labels.receipt', 'Beleg')} + + ) : null} + + + ))} + + )} + {null} + + + + + + + {t('billing.sections.addOns.title', 'Add-ons')} + + + {loading ? ( + + {t('common.loading', 'Lädt...')} + + ) : addons.length === 0 ? ( + + {t('billing.sections.addOns.empty', 'Keine Add-ons gebucht.')} + + ) : ( + + {addons.slice(0, 8).map((addon) => ( + + ))} + + )} + {null} + + + ); +} + +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 ( + + + + {pkg.package_name ?? 'Package'} + + {label ? {label} : null} + + {expires ? ( + + {expires} + + ) : null} + + + {remaining} Events + + {pkg.price !== null && pkg.price !== undefined ? ( + {formatAmount(pkg.price, pkg.currency ?? 'EUR')} + ) : null} + + + ); +} + +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 = { + 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 ( + + + + {addon.label ?? addon.addon_key} + + {status.text} + + + {formatDate(addon.purchased_at)} + + {eventName ? ( + + {eventName} + + ) : null} + + {addon.extra_photos ? +{addon.extra_photos} photos : null} + {addon.extra_guests ? +{addon.extra_guests} guests : null} + {addon.extra_gallery_days ? +{addon.extra_gallery_days} days : null} + + + {formatAmount(addon.amount, addon.currency)} + + + ); +} +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' }); +} diff --git a/resources/js/admin/mobile/BrandingPage.tsx b/resources/js/admin/mobile/BrandingPage.tsx index 50069c0..0ccdfaf 100644 --- a/resources/js/admin/mobile/BrandingPage.tsx +++ b/resources/js/admin/mobile/BrandingPage.tsx @@ -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(null); - const { go } = useMobileNav(slug); const [showFontsSheet, setShowFontsSheet] = React.useState(false); const [fonts, setFonts] = React.useState([]); const [fontsLoading, setFontsLoading] = React.useState(false); @@ -108,17 +105,15 @@ export default function MobileBrandingPage() { } return ( - navigate(-1)} - rightSlot={ + headerActions={ handleSave()}> } - footer={ - - } > {error ? ( @@ -280,7 +275,7 @@ export default function MobileBrandingPage() { )} - + ); } diff --git a/resources/js/admin/mobile/DashboardPage.tsx b/resources/js/admin/mobile/DashboardPage.tsx index b0b6568..436c192 100644 --- a/resources/js/admin/mobile/DashboardPage.tsx +++ b/resources/js/admin/mobile/DashboardPage.tsx @@ -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([]); + const [fallbackLoading, setFallbackLoading] = React.useState(false); + const [fallbackAttempted, setFallbackAttempted] = React.useState(false); const { data: stats, isLoading: statsLoading } = useQuery({ 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({ + 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 ( @@ -41,7 +72,7 @@ export default function MobileDashboardPage() { ); } - if (!hasEvents) { + if (!effectiveHasEvents) { return ( @@ -49,10 +80,14 @@ export default function MobileDashboardPage() { ); } - if (hasMultipleEvents && !activeEvent) { + if (effectiveMultiple && !activeEvent) { return ( - - + + ); } @@ -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(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 ( {t('events.detail.pickEvent', 'Select an event')} - {events.map((event) => ( + {localEvents.map((event) => ( selectEvent(event.slug ?? null)} + onPress={() => { + selectEvent(event.slug ?? null); + if (event.slug) { + navigate(adminPath(`/mobile/events/${event.slug}`)); + } + }} > diff --git a/resources/js/admin/mobile/EventDetailPage.tsx b/resources/js/admin/mobile/EventDetailPage.tsx index 4c3761c..c9a02df 100644 --- a/resources/js/admin/mobile/EventDetailPage.tsx +++ b/resources/js/admin/mobile/EventDetailPage.tsx @@ -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(null); const [loading, setLoading] = React.useState(true); const [error, setError] = React.useState(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 ( - navigate(adminPath('/mobile/events'))} - rightSlot={ + navigate(-1)} + headerActions={ - setShowEventPicker(true)}> - - - {activeEvent?.name ?? t('events.detail.pickEvent', 'Event wählen')} - - - - navigate(adminPath('/settings'))}> @@ -91,9 +103,6 @@ export default function MobileEventDetailPage() { } - footer={ - - } > {error ? ( @@ -225,7 +234,7 @@ export default function MobileEventDetailPage() { /> - + ); } diff --git a/resources/js/admin/mobile/EventFormPage.tsx b/resources/js/admin/mobile/EventFormPage.tsx index 90b7c24..62b9f30 100644 --- a/resources/js/admin/mobile/EventFormPage.tsx +++ b/resources/js/admin/mobile/EventFormPage.tsx @@ -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(null); - const { go } = useMobileNav(slug); React.useEffect(() => { if (!slug) return; @@ -102,12 +99,10 @@ export default function MobileEventFormPage() { } return ( - navigate(-1)} - footer={ - - } > {error ? ( @@ -222,7 +217,7 @@ export default function MobileEventFormPage() { ) : null} handleSubmit()} /> - + ); } diff --git a/resources/js/admin/mobile/EventMembersPage.tsx b/resources/js/admin/mobile/EventMembersPage.tsx index dd8869c..ab14443 100644 --- a/resources/js/admin/mobile/EventMembersPage.tsx +++ b/resources/js/admin/mobile/EventMembersPage.tsx @@ -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(null); - const { go } = useMobileNav(slug); - const [search, setSearch] = React.useState(''); - const [confirmRemove, setConfirmRemove] = React.useState(null); const [search, setSearch] = React.useState(''); const [confirmRemove, setConfirmRemove] = React.useState(null); @@ -96,17 +92,15 @@ export default function MobileEventMembersPage() { } return ( - navigate(-1)} - rightSlot={ + headerActions={ load()}> } - footer={ - - } > {error ? ( @@ -289,7 +283,7 @@ export default function MobileEventMembersPage() { {confirmRemove?.name || confirmRemove?.email} - + ); } diff --git a/resources/js/admin/mobile/EventPhotosPage.tsx b/resources/js/admin/mobile/EventPhotosPage.tsx index fe82c2f..9377ae8 100644 --- a/resources/js/admin/mobile/EventPhotosPage.tsx +++ b/resources/js/admin/mobile/EventPhotosPage.tsx @@ -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() { ); } + +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 ( + + + {label} + + {children} + + ); +} diff --git a/resources/js/admin/mobile/EventTasksPage.tsx b/resources/js/admin/mobile/EventTasksPage.tsx index 5e2faa5..d11b3ed 100644 --- a/resources/js/admin/mobile/EventTasksPage.tsx +++ b/resources/js/admin/mobile/EventTasksPage.tsx @@ -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() { - {collection.title} + {collection.name} } subTitle={ diff --git a/resources/js/admin/mobile/EventsPage.tsx b/resources/js/admin/mobile/EventsPage.tsx index d8a8e07..0dc46a2 100644 --- a/resources/js/admin/mobile/EventsPage.tsx +++ b/resources/js/admin/mobile/EventsPage.tsx @@ -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([]); const [loading, setLoading] = React.useState(true); const [error, setError] = React.useState(null); - const { go } = useMobileNav(); const [query, setQuery] = React.useState(''); React.useEffect(() => { @@ -38,17 +35,15 @@ export default function MobileEventsPage() { }, [t]); return ( - navigate(-1)} - rightSlot={ + headerActions={ } - footer={ - - } > {error ? ( @@ -111,7 +106,7 @@ export default function MobileEventsPage() { ))} )} - + ); } diff --git a/resources/js/admin/mobile/ProfilePage.tsx b/resources/js/admin/mobile/ProfilePage.tsx index 5b88270..f31cce0 100644 --- a/resources/js/admin/mobile/ProfilePage.tsx +++ b/resources/js/admin/mobile/ProfilePage.tsx @@ -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 ( - navigate(-1)} - footer={ - - } > {t('profile.settings', 'Settings')} - navigate(adminPath('/settings'))}> + navigate(adminPath('/mobile/settings'))}> {t('profile.account', 'Account & Security')} @@ -82,6 +78,22 @@ export default function MobileProfilePage() { + navigate(adminPath('/mobile/billing#packages'))}> + + + {t('billing.sections.packages.title', 'Packages & Billing')} + + + + + navigate(adminPath('/mobile/billing#invoices'))}> + + + {t('billing.sections.invoices.title', 'Invoices & Payments')} + + + + @@ -127,6 +139,6 @@ export default function MobileProfilePage() { navigate(adminPath('/logout')); }} /> - + ); } diff --git a/resources/js/admin/mobile/QrPrintPage.tsx b/resources/js/admin/mobile/QrPrintPage.tsx index 67a613c..433705c 100644 --- a/resources/js/admin/mobile/QrPrintPage.tsx +++ b/resources/js/admin/mobile/QrPrintPage.tsx @@ -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(''); - 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 ( - navigate(-1)} - rightSlot={ + headerActions={ window.location.reload()}> } - footer={ - - } > {error ? ( @@ -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() { ))} - + ); } diff --git a/resources/js/admin/mobile/SettingsPage.tsx b/resources/js/admin/mobile/SettingsPage.tsx new file mode 100644 index 0000000..684b062 --- /dev/null +++ b/resources/js/admin/mobile/SettingsPage.tsx @@ -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 = { + 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({}); + const [defaults, setDefaults] = React.useState({}); + const [loading, setLoading] = React.useState(true); + const [saving, setSaving] = React.useState(false); + const [error, setError] = React.useState(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 ( + navigate(-1)}> + {error ? ( + + + {error} + + + ) : null} + + + + + + {t('settings.session.title', 'Account')} + + + + {user?.name ?? user?.email ?? t('settings.session.unknown', 'Benutzer')} + + {user?.tenant_id ? ( + Tenant #{user.tenant_id} + ) : null} + + navigate(adminPath('/mobile/profile'))} /> + logout({ redirect: adminPath('/logout') })} /> + + + + + + + + {t('settings.notifications.title', 'Benachrichtigungen')} + + + {loading ? ( + + {t('settings.notifications.loading', 'Lade Einstellungen ...')} + + ) : ( + + {Object.keys(PREFERENCE_LABELS).map((key) => { + const prefKey = key as PreferenceKey; + return ( + + + {PREFERENCE_LABELS[prefKey]} + + togglePref(prefKey)} + /> + + ); + })} + + )} + + handleSave()} /> + handleReset()} /> + + + + + + + + {t('settings.appearance.title', 'Darstellung')} + + + + {t('settings.appearance.description', 'Schalte Dark-Mode oder passe Branding im Admin an.')} + + navigate(adminPath('/settings'))} /> + + + ); +} diff --git a/resources/js/admin/mobile/components/BottomNav.tsx b/resources/js/admin/mobile/components/BottomNav.tsx index 26a8949..6507307 100644 --- a/resources/js/admin/mobile/components/BottomNav.tsx +++ b/resources/js/admin/mobile/components/BottomNav.tsx @@ -43,7 +43,14 @@ export function BottomNav({ active, onNavigate }: { active: NavKey; onNavigate: const IconCmp = item.icon; return ( onNavigate(item.key)}> - + {item.label} diff --git a/resources/js/admin/mobile/components/MobileShell.tsx b/resources/js/admin/mobile/components/MobileShell.tsx index 816558b..2e9e5b7 100644 --- a/resources/js/admin/mobile/components/MobileShell.tsx +++ b/resources/js/admin/mobile/components/MobileShell.tsx @@ -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([]); + 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 ( @@ -65,9 +100,6 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head - - {t('actions.back', 'Back')} - ) : null} @@ -120,7 +152,7 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head {showQr ? ( - navigate(adminPath(`/mobile/events/${activeEvent?.slug}/qr`))}> + navigate(adminPath(`/mobile/events/${effectiveActive?.slug}/qr`))}> - {events.length === 0 ? ( + {effectiveEvents.length === 0 ? ( {t('header.noEventsTitle', 'Create your first event')} @@ -173,12 +205,16 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head ) : ( - events.map((event) => ( + effectiveEvents.map((event) => ( { - selectEvent(event.slug ?? null); + const targetSlug = event.slug ?? null; + selectEvent(targetSlug); setPickerOpen(false); + if (targetSlug) { + navigate(adminPath(`/mobile/events/${targetSlug}`)); + } }} > diff --git a/resources/js/admin/mobile/components/Primitives.tsx b/resources/js/admin/mobile/components/Primitives.tsx index 1f9893f..1fa30a7 100644 --- a/resources/js/admin/mobile/components/Primitives.tsx +++ b/resources/js/admin/mobile/components/Primitives.tsx @@ -122,20 +122,22 @@ export function ActionTile({ onPress: () => void; }) { return ( - + - - + + - + {label} diff --git a/resources/js/admin/pages/SettingsPage.tsx b/resources/js/admin/pages/SettingsPage.tsx index 554c7fd..1d57e7e 100644 --- a/resources/js/admin/pages/SettingsPage.tsx +++ b/resources/js/admin/pages/SettingsPage.tsx @@ -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 { diff --git a/resources/js/admin/pages/__tests__/DashboardPage.guard.test.tsx b/resources/js/admin/pages/__tests__/DashboardPage.guard.test.tsx index 700ed83..f58d332 100644 --- a/resources/js/admin/pages/__tests__/DashboardPage.guard.test.tsx +++ b/resources/js/admin/pages/__tests__/DashboardPage.guard.test.tsx @@ -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), diff --git a/resources/js/admin/pages/components/InviteLayoutCustomizerPanel.tsx b/resources/js/admin/pages/components/InviteLayoutCustomizerPanel.tsx index 8fc0938..4eb7ba2 100644 --- a/resources/js/admin/pages/components/InviteLayoutCustomizerPanel.tsx +++ b/resources/js/admin/pages/components/InviteLayoutCustomizerPanel.tsx @@ -228,6 +228,7 @@ export function InviteLayoutCustomizerPanel({ backgroundImages = preloadedBackgrounds, }: InviteLayoutCustomizerPanelProps): React.JSX.Element { const { t } = useTranslation('management'); + const fabricCanvasRef = React.useRef(null); const { fonts: availableFonts, isLoading: fontsLoading } = useTenantFonts(); const inviteUrl = invite?.url ?? ''; diff --git a/resources/js/admin/router.tsx b/resources/js/admin/router.tsx index c0b8a25..ecc1967 100644 --- a/resources/js/admin/router.tsx +++ b/resources/js/admin/router.tsx @@ -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: }, { path: 'mobile/alerts', element: }, { path: 'mobile/profile', element: }, + { path: 'mobile/billing', element: }, + { path: 'mobile/settings', element: }, { path: 'mobile/dashboard', element: }, { path: 'mobile/tasks', element: }, { path: 'mobile/uploads', element: }, diff --git a/resources/js/guest/main.tsx b/resources/js/guest/main.tsx index f5a95cc..c73ae5d 100644 --- a/resources/js/guest/main.tsx +++ b/resources/js/guest/main.tsx @@ -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) { diff --git a/resources/js/lib/giftVouchers.ts b/resources/js/lib/giftVouchers.ts index f675b2b..7131dda 100644 --- a/resources/js/lib/giftVouchers.ts +++ b/resources/js/lib/giftVouchers.ts @@ -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; + 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); } diff --git a/resources/js/types/shims.d.ts b/resources/js/types/shims.d.ts new file mode 100644 index 0000000..d3e8e36 --- /dev/null +++ b/resources/js/types/shims.d.ts @@ -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; +} diff --git a/tsconfig.json b/tsconfig.json index 7939a83..44841fe 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -127,5 +127,8 @@ "resources/js/guest/**/*.ts", "resources/js/guest/**/*.tsx", "resources/js/guest/**/*.d.ts" + ], + "exclude": [ + "resources/js/actions/**" ] }