weiterer fortschritt mit tamagui und dem neuen mobile event admin
This commit is contained in:
@@ -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';
|
||||
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
276
resources/js/admin/mobile/BillingPage.tsx
Normal file
276
resources/js/admin/mobile/BillingPage.tsx
Normal 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' });
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,8 +46,19 @@ export default function MobileEventDetailPage() {
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,6 +196,7 @@ export default function MobileQrPrintPage() {
|
||||
onPress={async () => {
|
||||
if (!slug) return;
|
||||
try {
|
||||
if (!slug) return;
|
||||
const invite = await createQrInvite(slug, { label: 'Mobile Link' });
|
||||
setQrUrl(invite.url);
|
||||
toast.success(t('events.qr.created', 'Neuer QR-Link erstellt'));
|
||||
@@ -265,6 +262,6 @@ export default function MobileQrPrintPage() {
|
||||
))}
|
||||
</YStack>
|
||||
</MobileSheet>
|
||||
</MobileScaffold>
|
||||
</MobileShell>
|
||||
);
|
||||
}
|
||||
|
||||
160
resources/js/admin/mobile/SettingsPage.tsx
Normal file
160
resources/js/admin/mobile/SettingsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 ?? '';
|
||||
|
||||
@@ -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 /> },
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
30
resources/js/types/shims.d.ts
vendored
Normal 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;
|
||||
}
|
||||
@@ -127,5 +127,8 @@
|
||||
"resources/js/guest/**/*.ts",
|
||||
"resources/js/guest/**/*.tsx",
|
||||
"resources/js/guest/**/*.d.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"resources/js/actions/**"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user