fix: resolve typescript and build errors across admin and guest apps
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@
|
||||
import { authorizedFetch } from './auth/tokens';
|
||||
import { ApiError, emitApiErrorEvent } from './lib/apiError';
|
||||
import type { EventLimitSummary } from './lib/limitWarnings';
|
||||
export type { EventLimitSummary };
|
||||
import i18n from './i18n';
|
||||
|
||||
type JsonValue = Record<string, unknown>;
|
||||
|
||||
@@ -14,7 +14,7 @@ export type EventTabCounts = Partial<{
|
||||
tasks: number;
|
||||
}>;
|
||||
|
||||
type Translator = (key: string, fallback: string) => string;
|
||||
type Translator = any;
|
||||
|
||||
export function buildEventTabs(event: TenantEvent, translate: Translator, counts: EventTabCounts = {}) {
|
||||
if (!event.slug) {
|
||||
|
||||
@@ -8,16 +8,6 @@ import { Pressable } from '@tamagui/react-native-web-lite';
|
||||
import toast from 'react-hot-toast';
|
||||
import { MobileShell, HeaderActionButton } from './components/MobileShell';
|
||||
import { MobileCard, CTAButton, PillBadge } from './components/Primitives';
|
||||
import React from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { 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 toast from 'react-hot-toast';
|
||||
import { MobileShell, HeaderActionButton } from './components/MobileShell';
|
||||
import { MobileCard, CTAButton, PillBadge } from './components/Primitives';
|
||||
import {
|
||||
createTenantBillingPortalSession,
|
||||
getTenantPackagesOverview,
|
||||
@@ -235,7 +225,6 @@ export default function MobileBillingPage() {
|
||||
))}
|
||||
</YStack>
|
||||
)}
|
||||
{null}
|
||||
</MobileCard>
|
||||
|
||||
<MobileCard space="$2">
|
||||
@@ -263,7 +252,6 @@ export default function MobileBillingPage() {
|
||||
))}
|
||||
</YStack>
|
||||
)}
|
||||
{null}
|
||||
</MobileCard>
|
||||
</MobileShell>
|
||||
);
|
||||
@@ -547,156 +535,4 @@ function formatDate(value: string | null | undefined): string {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '—';
|
||||
return date.toLocaleDateString(undefined, { day: '2-digit', month: 'short', year: 'numeric' });
|
||||
}
|
||||
|
||||
function renderFeatureBadge(pkg: TenantPackageSummary, t: any, key: string, label: string) {
|
||||
const value = (pkg.package_limits as any)?.[key] ?? (pkg as any)[key];
|
||||
if (value === undefined || value === null) return null;
|
||||
const enabled = value !== false;
|
||||
return <PillBadge tone={enabled ? 'success' : 'muted'}>{enabled ? label : `${label} off`}</PillBadge>;
|
||||
}
|
||||
|
||||
function UsageBar({ metric }: { metric: PackageUsageMetric }) {
|
||||
const { t } = useTranslation('management');
|
||||
const { muted, textStrong, border, primary, subtle, warningText, danger } = useAdminTheme();
|
||||
const labelMap: Record<PackageUsageMetric['key'], string> = {
|
||||
events: t('mobileBilling.usage.events', 'Events'),
|
||||
guests: t('mobileBilling.usage.guests', 'Guests'),
|
||||
photos: t('mobileBilling.usage.photos', 'Photos'),
|
||||
gallery: t('mobileBilling.usage.gallery', 'Gallery days'),
|
||||
};
|
||||
|
||||
if (!metric.limit) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const status = getUsageState(metric);
|
||||
const hasUsage = metric.used !== null;
|
||||
const valueText = hasUsage
|
||||
? t('mobileBilling.usage.value', { used: metric.used, limit: metric.limit })
|
||||
: t('mobileBilling.usage.limit', { limit: metric.limit });
|
||||
const remainingText = metric.remaining !== null
|
||||
? t('mobileBilling.usage.remainingOf', {
|
||||
remaining: metric.remaining,
|
||||
limit: metric.limit,
|
||||
defaultValue: 'Remaining {{remaining}} of {{limit}}',
|
||||
})
|
||||
: null;
|
||||
const fill = usagePercent(metric);
|
||||
const statusLabel =
|
||||
status === 'danger'
|
||||
? t('mobileBilling.usage.statusDanger', 'Limit reached')
|
||||
: status === 'warning'
|
||||
? t('mobileBilling.usage.statusWarning', 'Low')
|
||||
: null;
|
||||
const fillColor = status === 'danger' ? danger : status === 'warning' ? warningText : primary;
|
||||
|
||||
return (
|
||||
<YStack space="$1.5">
|
||||
<XStack alignItems="center" justifyContent="space-between">
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{labelMap[metric.key]}
|
||||
</Text>
|
||||
<XStack alignItems="center" space="$1.5">
|
||||
{statusLabel ? <PillBadge tone={status === 'danger' ? 'danger' : 'warning'}>{statusLabel}</PillBadge> : null}
|
||||
<Text fontSize="$xs" color={textStrong} fontWeight="700">
|
||||
{valueText}
|
||||
</Text>
|
||||
</XStack>
|
||||
</XStack>
|
||||
<YStack height={6} borderRadius={999} backgroundColor={border} overflow="hidden">
|
||||
<YStack height="100%" width={`${fill}%`} backgroundColor={hasUsage ? fillColor : subtle} />
|
||||
</YStack>
|
||||
{remainingText ? (
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{remainingText}
|
||||
</Text>
|
||||
) : null}
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
|
||||
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 { t } = useTranslation('management');
|
||||
const navigate = useNavigate();
|
||||
const { border, textStrong, text, muted, subtle, primary } = useAdminTheme();
|
||||
const labels: Record<TenantAddonHistoryEntry['status'], { tone: 'success' | 'warning' | 'muted'; text: string }> = {
|
||||
completed: { tone: 'success', text: t('mobileBilling.status.completed', 'Completed') },
|
||||
pending: { tone: 'warning', text: t('mobileBilling.status.pending', 'Pending') },
|
||||
failed: { tone: 'muted', text: t('mobileBilling.status.failed', '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;
|
||||
const eventPath = addon.event?.slug ? ADMIN_EVENT_VIEW_PATH(addon.event.slug) : null;
|
||||
const hasImpact = Boolean(addon.extra_photos || addon.extra_guests || addon.extra_gallery_days);
|
||||
const impactBadges = hasImpact ? (
|
||||
<XStack space="$2" marginTop="$1.5" flexWrap="wrap">
|
||||
{addon.extra_photos ? (
|
||||
<PillBadge tone="muted">{t('mobileBilling.extra.photos', '+{{count}} photos', { count: addon.extra_photos })}</PillBadge>
|
||||
) : null}
|
||||
{addon.extra_guests ? (
|
||||
<PillBadge tone="muted">{t('mobileBilling.extra.guests', '+{{count}} guests', { count: addon.extra_guests })}</PillBadge>
|
||||
) : null}
|
||||
{addon.extra_gallery_days ? (
|
||||
<PillBadge tone="muted">{t('mobileBilling.extra.days', '+{{count}} days', { count: addon.extra_gallery_days })}</PillBadge>
|
||||
) : null}
|
||||
</XStack>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<MobileCard borderColor={border} padding="$3" space="$1.5">
|
||||
<XStack alignItems="center" justifyContent="space-between">
|
||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||
{addon.label ?? addon.addon_key}
|
||||
</Text>
|
||||
<PillBadge tone={status.tone}>{status.text}</PillBadge>
|
||||
</XStack>
|
||||
{eventName ? (
|
||||
eventPath ? (
|
||||
<Pressable onPress={() => navigate(eventPath)}>
|
||||
<XStack alignItems="center" justifyContent="space-between">
|
||||
<Text fontSize="$xs" color={textStrong} fontWeight="600">
|
||||
{eventName}
|
||||
</Text>
|
||||
<Text fontSize="$xs" color={primary} fontWeight="700">
|
||||
{t('mobileBilling.openEvent', 'Open event')}
|
||||
</Text>
|
||||
</XStack>
|
||||
</Pressable>
|
||||
) : (
|
||||
<Text fontSize="$xs" color={subtle}>
|
||||
{eventName}
|
||||
</Text>
|
||||
)
|
||||
) : null}
|
||||
{impactBadges}
|
||||
<Text fontSize="$sm" color={text} marginTop="$1.5">
|
||||
{formatAmount(addon.amount, addon.currency)}
|
||||
</Text>
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{formatDate(addon.purchased_at)}
|
||||
</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' });
|
||||
}
|
||||
}
|
||||
@@ -1154,7 +1154,7 @@ function SecondaryGrid({
|
||||
{
|
||||
icon: Settings,
|
||||
label: t('mobileDashboard.shortcutSettings', 'Event settings'),
|
||||
color: ADMIN_ACTION_COLORS.success,
|
||||
color: ADMIN_ACTION_COLORS.settings,
|
||||
action: onSettings,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -36,7 +36,7 @@ export default function MobileEventAnalyticsPage() {
|
||||
|
||||
if (isFeatureLocked) {
|
||||
return (
|
||||
<MobileShell title={t('analytics.title', 'Analytics')} activeTab="events">
|
||||
<MobileShell title={t('analytics.title', 'Analytics')} activeTab="home">
|
||||
<MobileCard
|
||||
space="$4"
|
||||
padding="$6"
|
||||
@@ -75,7 +75,7 @@ export default function MobileEventAnalyticsPage() {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<MobileShell title={t('analytics.title', 'Analytics')} activeTab="events">
|
||||
<MobileShell title={t('analytics.title', 'Analytics')} activeTab="home">
|
||||
<YStack space="$3">
|
||||
<SkeletonCard height={200} />
|
||||
<SkeletonCard height={150} />
|
||||
@@ -87,7 +87,7 @@ export default function MobileEventAnalyticsPage() {
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<MobileShell title={t('analytics.title', 'Analytics')} activeTab="events">
|
||||
<MobileShell title={t('analytics.title', 'Analytics')} activeTab="home">
|
||||
<MobileCard borderColor={border} padding="$4">
|
||||
<Text color={muted}>{t('common.error', 'Something went wrong')}</Text>
|
||||
</MobileCard>
|
||||
@@ -107,8 +107,8 @@ export default function MobileEventAnalyticsPage() {
|
||||
<MobileShell
|
||||
title={t('analytics.title', 'Analytics')}
|
||||
subtitle={activeEvent?.name as string}
|
||||
activeTab="events"
|
||||
showBack
|
||||
activeTab="home"
|
||||
onBack={() => navigate(-1)}
|
||||
>
|
||||
<YStack space="$4">
|
||||
{/* Activity Timeline */}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { createEvent, getEvent, updateEvent, getEventTypes, TenantEvent, TenantE
|
||||
import { resolveEventSlugAfterUpdate } from './eventFormNavigation';
|
||||
import { adminPath } from '../constants';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
import { getApiValidationMessage, isApiError } from '../lib/apiError';
|
||||
import { getApiErrorMessage, getApiValidationMessage, isApiError } from '../lib/apiError';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useBackNavigation } from './hooks/useBackNavigation';
|
||||
import { useAdminTheme } from './theme';
|
||||
@@ -130,7 +130,7 @@ export default function MobileEventFormPage() {
|
||||
slug: `${Date.now()}`,
|
||||
event_type_id: form.eventTypeId ?? undefined,
|
||||
event_date: form.date || undefined,
|
||||
status: (form.published ? 'published' : 'draft') as const,
|
||||
status: form.published ? 'published' : 'draft',
|
||||
settings: {
|
||||
location: form.location,
|
||||
guest_upload_visibility: form.autoApproveUploads ? 'immediate' : 'review',
|
||||
@@ -152,7 +152,7 @@ export default function MobileEventFormPage() {
|
||||
slug: `${Date.now()}`,
|
||||
event_type_id: form.eventTypeId ?? undefined,
|
||||
event_date: form.date || undefined,
|
||||
status: (form.published ? 'published' : 'draft') as const,
|
||||
status: form.published ? 'published' : 'draft',
|
||||
settings: {
|
||||
location: form.location,
|
||||
guest_upload_visibility: form.autoApproveUploads ? 'immediate' : 'review',
|
||||
|
||||
@@ -6,7 +6,7 @@ import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { MobileShell, HeaderActionButton } from './components/MobileShell';
|
||||
import { MobileCard, CTAButton, PillBadge, SkeletonCard } from './components/Primitives';
|
||||
import { MobileSelect } from './components/FormControls';
|
||||
import { MobileSelect, MobileField } from './components/FormControls';
|
||||
import { useEventContext } from '../context/EventContext';
|
||||
import {
|
||||
approveAndLiveShowPhoto,
|
||||
@@ -216,17 +216,18 @@ export default function MobileEventLiveShowQueuePage() {
|
||||
</MobileCard>
|
||||
|
||||
<MobileCard>
|
||||
<MobileSelect
|
||||
label={t('liveShowQueue.filterLabel', 'Live status')}
|
||||
value={statusFilter}
|
||||
onChange={(event) => setStatusFilter(event.target.value as LiveShowQueueStatus)}
|
||||
>
|
||||
{STATUS_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{t(option.labelKey, option.fallback)}
|
||||
</option>
|
||||
))}
|
||||
</MobileSelect>
|
||||
<MobileField label={t('liveShowQueue.filterLabel', 'Live status')}>
|
||||
<MobileSelect
|
||||
value={statusFilter}
|
||||
onChange={(event) => setStatusFilter(event.target.value as LiveShowQueueStatus)}
|
||||
>
|
||||
{STATUS_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{t(option.labelKey, option.fallback)}
|
||||
</option>
|
||||
))}
|
||||
</MobileSelect>
|
||||
</MobileField>
|
||||
</MobileCard>
|
||||
|
||||
{error ? (
|
||||
|
||||
@@ -341,7 +341,7 @@ export default function MobileEventLiveShowSettingsPage() {
|
||||
{liveShowLink?.qr_code_data_url ? (
|
||||
<XStack space="$2" alignItems="center" marginTop="$2" flexWrap="wrap">
|
||||
<Pressable
|
||||
onPress={() => downloadQr(liveShowLink.qr_code_data_url, 'live-show-qr.png')}
|
||||
onPress={() => downloadQr(liveShowLink.qr_code_data_url!, 'live-show-qr.png')}
|
||||
title={t('liveShowSettings.link.downloadQr', 'Download QR')}
|
||||
aria-label={t('liveShowSettings.link.downloadQr', 'Download QR')}
|
||||
style={{ borderRadius: 12, cursor: 'pointer' }}
|
||||
@@ -578,14 +578,14 @@ function resolveName(name: TenantEvent['name']): string {
|
||||
return 'Event';
|
||||
}
|
||||
|
||||
function copyToClipboard(value: string, t: (key: string, fallback?: string) => string) {
|
||||
function copyToClipboard(value: string, t: any) {
|
||||
navigator.clipboard
|
||||
.writeText(value)
|
||||
.then(() => toast.success(t('liveShowSettings.link.copySuccess', 'Link copied')))
|
||||
.catch(() => toast.error(t('liveShowSettings.link.copyFailed', 'Link could not be copied')));
|
||||
}
|
||||
|
||||
async function shareLink(value: string, event: TenantEvent | null, t: (key: string, fallback?: string) => string) {
|
||||
async function shareLink(value: string, event: TenantEvent | null, t: any) {
|
||||
if (navigator.share) {
|
||||
try {
|
||||
await navigator.share({
|
||||
@@ -713,7 +713,7 @@ function IconAction({
|
||||
}}
|
||||
>
|
||||
<XStack alignItems="center" justifyContent="center">
|
||||
{React.isValidElement(children) ? React.cloneElement(children, { color }) : children}
|
||||
{React.isValidElement(children) ? React.cloneElement(children as any, { color }) : children}
|
||||
</XStack>
|
||||
</Pressable>
|
||||
);
|
||||
|
||||
@@ -453,7 +453,7 @@ export default function MobileEventPhotosPage() {
|
||||
toast.error(t('mobilePhotos.loadFailed', 'Fotos konnten nicht geladen werden.'));
|
||||
}
|
||||
if (active) {
|
||||
setLightboxWithUrl(null, { replace: true });
|
||||
setLightboxWithUrl(null);
|
||||
}
|
||||
} finally {
|
||||
if (active) {
|
||||
@@ -616,7 +616,7 @@ export default function MobileEventPhotosPage() {
|
||||
function startAddonCheckout(scopeOrKey: 'photos' | 'gallery' | 'guests' | string) {
|
||||
if (!slug) return;
|
||||
const { scope, addonKey } = resolveScopeAndAddonKey(scopeOrKey);
|
||||
setConsentTarget({ scope, addonKey });
|
||||
setConsentTarget({ scope: scope as any, addonKey });
|
||||
setConsentOpen(true);
|
||||
}
|
||||
|
||||
@@ -635,7 +635,7 @@ export default function MobileEventPhotosPage() {
|
||||
cancel_url: currentUrl,
|
||||
accepted_terms: consents.acceptedTerms,
|
||||
accepted_waiver: consents.acceptedWaiver,
|
||||
});
|
||||
} as any);
|
||||
if (checkout.checkout_url) {
|
||||
window.location.href = checkout.checkout_url;
|
||||
} else {
|
||||
@@ -710,7 +710,7 @@ export default function MobileEventPhotosPage() {
|
||||
<XStack space="$2">
|
||||
<CTAButton
|
||||
label={selectionMode ? t('common.done', 'Done') : t('common.select', 'Select')}
|
||||
tone={selectionMode ? 'solid' : 'ghost'}
|
||||
tone={selectionMode ? 'primary' : 'ghost'}
|
||||
fullWidth={false}
|
||||
onPress={() => {
|
||||
if (selectionMode) {
|
||||
@@ -768,7 +768,7 @@ export default function MobileEventPhotosPage() {
|
||||
addons={catalogAddons}
|
||||
onCheckout={startAddonCheckout}
|
||||
busyScope={busyScope}
|
||||
translate={translateLimits(t)}
|
||||
translate={translateLimits(t as any)}
|
||||
textColor={text}
|
||||
borderColor={border}
|
||||
/>
|
||||
@@ -1343,7 +1343,7 @@ function PhotoQuickActions({ photo, disabled = false, muted, surface, onAction }
|
||||
key={action.key}
|
||||
disabled={disabled}
|
||||
aria-label={action.label}
|
||||
onPress={(event) => {
|
||||
onPress={(event: any) => {
|
||||
event.stopPropagation();
|
||||
if (!disabled) {
|
||||
onAction(action.key);
|
||||
|
||||
@@ -1,74 +1,72 @@
|
||||
import React from 'react';
|
||||
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Check, Copy, Download, Share2, Sparkles, Trophy, Users } 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 { Sparkles, Camera, Link2, QrCode, RefreshCcw, Shield, Archive, ShoppingCart, Clock3, Share2 } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { MobileShell } from './components/MobileShell';
|
||||
import { MobileCard, CTAButton, PillBadge, SkeletonCard } from './components/Primitives';
|
||||
import { LegalConsentSheet } from './components/LegalConsentSheet';
|
||||
import {
|
||||
getEvent,
|
||||
getEventStats,
|
||||
getEventQrInvites,
|
||||
toggleEvent,
|
||||
updateEvent,
|
||||
createEventAddonCheckout,
|
||||
TenantEvent,
|
||||
EventStats,
|
||||
EventQrInvite,
|
||||
EventAddonCatalogItem,
|
||||
getAddonCatalog,
|
||||
submitTenantFeedback,
|
||||
type TenantEvent,
|
||||
type EventStats,
|
||||
type EventQrInvite,
|
||||
type EventAddonCatalogItem,
|
||||
createEventAddonCheckout,
|
||||
} from '../api';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
import { getApiErrorMessage } from '../lib/apiError';
|
||||
import { HeaderActionButton, MobileShell } from './components/MobileShell';
|
||||
import { MobileCard, CTAButton, PillBadge, SkeletonCard } from './components/Primitives';
|
||||
import { adminPath } from '../constants';
|
||||
import { selectAddonKeyForScope } from './addons';
|
||||
import { LegalConsentSheet } from './components/LegalConsentSheet';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useBackNavigation } from './hooks/useBackNavigation';
|
||||
import { useAdminTheme } from './theme';
|
||||
|
||||
type GalleryCounts = {
|
||||
photos: number;
|
||||
likes: number;
|
||||
pending: number;
|
||||
};
|
||||
|
||||
export default function MobileEventRecapPage() {
|
||||
const { slug } = useParams<{ slug?: string }>();
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { t } = useTranslation('management');
|
||||
const { textStrong, text, muted, subtle, danger, border, primary, accentSoft } = useAdminTheme();
|
||||
const { textStrong, text, muted, border, primary, successText, danger } = useAdminTheme();
|
||||
|
||||
const [event, setEvent] = React.useState<TenantEvent | null>(null);
|
||||
const [stats, setStats] = React.useState<EventStats | null>(null);
|
||||
const [stats, setEventStats] = React.useState<EventStats | null>(null);
|
||||
const [invites, setInvites] = React.useState<EventQrInvite[]>([]);
|
||||
const [addons, setAddons] = React.useState<EventAddonCatalogItem[]>([]);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const [archiveBusy, setArchiveBusy] = React.useState(false);
|
||||
const [checkoutBusy, setCheckoutBusy] = React.useState(false);
|
||||
const [consentOpen, setConsentOpen] = React.useState(false);
|
||||
const [consentBusy, setConsentBusy] = React.useState(false);
|
||||
const [consentAddonKey, setConsentAddonKey] = React.useState<string | null>(null);
|
||||
const [busyScope, setBusyScope] = React.useState<string | null>(null);
|
||||
const back = useBackNavigation(slug ? adminPath(`/mobile/events/${slug}`) : adminPath('/mobile/events'));
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
if (!slug) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const [eventData, statsData, inviteData, addonData] = await Promise.all([
|
||||
const [eventData, statsData, invitesData, addonsData] = await Promise.all([
|
||||
getEvent(slug),
|
||||
getEventStats(slug),
|
||||
getEventQrInvites(slug),
|
||||
getAddonCatalog(),
|
||||
]);
|
||||
setEvent(eventData);
|
||||
setStats(statsData);
|
||||
setInvites(inviteData ?? []);
|
||||
setAddons(addonData ?? []);
|
||||
setEventStats(statsData);
|
||||
setInvites(invitesData);
|
||||
setAddons(addonsData);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setError(getApiErrorMessage(err, t('events.errors.loadFailed', 'Event konnte nicht geladen werden.')));
|
||||
setError(getApiErrorMessage(err, t('events.errors.loadFailed', 'Recap konnte nicht geladen werden.')));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -79,323 +77,243 @@ export default function MobileEventRecapPage() {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!location.search) return;
|
||||
const params = new URLSearchParams(location.search);
|
||||
if (params.get('addon_success')) {
|
||||
toast.success(t('events.success.addonApplied', 'Add-on angewendet. Limits aktualisieren sich in Kürze.'));
|
||||
params.delete('addon_success');
|
||||
navigate({ pathname: location.pathname, search: params.toString() ? `?${params.toString()}` : '' }, { replace: true });
|
||||
void load();
|
||||
const handleCheckout = async (addonKey: string) => {
|
||||
if (!slug || busyScope) return;
|
||||
setBusyScope(addonKey);
|
||||
try {
|
||||
const { checkout_url } = await createEventAddonCheckout(slug, {
|
||||
addon_key: addonKey,
|
||||
success_url: window.location.href,
|
||||
cancel_url: window.location.href,
|
||||
});
|
||||
if (checkout_url) {
|
||||
window.location.href = checkout_url;
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('events.errors.checkoutFailed', 'Bezahlvorgang konnte nicht gestartet werden.')));
|
||||
setBusyScope(null);
|
||||
}
|
||||
}, [location.search, location.pathname, t, navigate, load]);
|
||||
};
|
||||
|
||||
if (!slug) {
|
||||
const handleConsentConfirm = async (consents: { acceptedTerms: boolean }) => {
|
||||
if (!slug || !busyScope) return;
|
||||
try {
|
||||
const { checkout_url } = await createEventAddonCheckout(slug, {
|
||||
addon_key: busyScope,
|
||||
success_url: window.location.href,
|
||||
cancel_url: window.location.href,
|
||||
accepted_terms: consents.acceptedTerms,
|
||||
} as any);
|
||||
if (checkout_url) {
|
||||
window.location.href = checkout_url;
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('events.errors.checkoutFailed', 'Bezahlvorgang konnte nicht gestartet werden.')));
|
||||
setBusyScope(null);
|
||||
setConsentOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<MobileShell activeTab="home" title={t('events.errors.notFoundTitle', 'Event nicht gefunden')} onBack={back}>
|
||||
<MobileShell activeTab="home" title={t('events.recap.title', 'Event Recap')} onBack={back}>
|
||||
<YStack space="$3">
|
||||
<SkeletonCard height={120} />
|
||||
<SkeletonCard height={200} />
|
||||
<SkeletonCard height={150} />
|
||||
</YStack>
|
||||
</MobileShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !event) {
|
||||
return (
|
||||
<MobileShell activeTab="home" title={t('events.recap.title', 'Event Recap')} onBack={back}>
|
||||
<MobileCard>
|
||||
<Text color={danger}>{t('events.errors.notFoundBody', 'Kehre zur Eventliste zurück und wähle dort ein Event aus.')}</Text>
|
||||
<Text fontWeight="700" color={danger}>
|
||||
{error || t('common.error', 'Ein Fehler ist aufgetreten.')}
|
||||
</Text>
|
||||
<CTAButton label={t('common.retry', 'Erneut versuchen')} onPress={load} tone="ghost" />
|
||||
</MobileCard>
|
||||
</MobileShell>
|
||||
);
|
||||
}
|
||||
|
||||
const activeInvite = invites.find((invite) => invite.is_active);
|
||||
const guestLink = event?.public_url ?? activeInvite?.url ?? (activeInvite?.token ? `/g/${activeInvite.token}` : null);
|
||||
const galleryCounts = {
|
||||
photos: stats?.uploads_total ?? stats?.total ?? 0,
|
||||
const galleryCounts: GalleryCounts = {
|
||||
photos: stats?.total ?? 0,
|
||||
likes: stats?.likes ?? 0,
|
||||
pending: stats?.pending_photos ?? 0,
|
||||
likes: stats?.likes_total ?? stats?.likes ?? 0,
|
||||
};
|
||||
|
||||
async function toggleGallery() {
|
||||
if (!slug) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const updated = await toggleEvent(slug);
|
||||
setEvent(updated);
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setError(getApiErrorMessage(err, t('events.errors.toggleFailed', 'Status konnte nicht angepasst werden.')));
|
||||
}
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function archiveEvent() {
|
||||
if (!slug || !event) return;
|
||||
setArchiveBusy(true);
|
||||
try {
|
||||
const updated = await updateEvent(slug, { status: 'archived', is_active: false });
|
||||
setEvent(updated);
|
||||
toast.success(t('events.recap.archivedSuccess', 'Event archiviert. Galerie ist geschlossen.'));
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setError(getApiErrorMessage(err, t('events.errors.archiveFailed', 'Archivierung fehlgeschlagen.')));
|
||||
}
|
||||
} finally {
|
||||
setArchiveBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function startAddonCheckout() {
|
||||
if (!slug) return;
|
||||
const addonKey = selectAddonKeyForScope(addons, 'gallery');
|
||||
setConsentAddonKey(addonKey);
|
||||
setConsentOpen(true);
|
||||
}
|
||||
|
||||
async function confirmAddonCheckout(consents: { acceptedTerms: boolean; acceptedWaiver: boolean }) {
|
||||
if (!slug || !consentAddonKey) return;
|
||||
const currentUrl = typeof window !== 'undefined' ? `${window.location.origin}${adminPath(`/mobile/events/${slug}/recap`)}` : '';
|
||||
const successUrl = `${currentUrl}?addon_success=1`;
|
||||
setCheckoutBusy(true);
|
||||
setConsentBusy(true);
|
||||
try {
|
||||
const checkout = await createEventAddonCheckout(slug, {
|
||||
addon_key: consentAddonKey,
|
||||
quantity: 1,
|
||||
success_url: successUrl,
|
||||
cancel_url: currentUrl,
|
||||
accepted_terms: consents.acceptedTerms,
|
||||
accepted_waiver: consents.acceptedWaiver,
|
||||
});
|
||||
if (checkout.checkout_url) {
|
||||
window.location.href = checkout.checkout_url;
|
||||
} else {
|
||||
toast.error(t('events.errors.checkoutMissing', 'Checkout konnte nicht gestartet werden.'));
|
||||
}
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
toast.error(getApiErrorMessage(err, t('events.errors.checkoutFailed', 'Add-on Checkout fehlgeschlagen.')));
|
||||
}
|
||||
} finally {
|
||||
setCheckoutBusy(false);
|
||||
setConsentBusy(false);
|
||||
setConsentOpen(false);
|
||||
setConsentAddonKey(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitFeedback(sentiment: 'positive' | 'neutral' | 'negative') {
|
||||
if (!event) return;
|
||||
try {
|
||||
await submitTenantFeedback({
|
||||
category: 'event_workspace_after_event',
|
||||
event_slug: event.slug,
|
||||
sentiment,
|
||||
metadata: {
|
||||
event_name: resolveName(event.name),
|
||||
guest_link: guestLink,
|
||||
},
|
||||
});
|
||||
toast.success(t('events.feedback.submitted', 'Danke!'));
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('events.feedback.genericError', 'Feedback konnte nicht gesendet werden.')));
|
||||
}
|
||||
}
|
||||
const activeInvite = invites.find((i) => i.is_active) ?? invites[0] ?? null;
|
||||
const guestLink = activeInvite?.url ?? '';
|
||||
|
||||
return (
|
||||
<MobileShell
|
||||
activeTab="home"
|
||||
title={event ? resolveName(event.name) : t('events.placeholders.untitled', 'Unbenanntes Event')}
|
||||
subtitle={event?.event_date ? formatDate(event.event_date) : undefined}
|
||||
title={t('events.recap.title', 'Event Recap')}
|
||||
subtitle={resolveName(event.name)}
|
||||
onBack={back}
|
||||
headerActions={
|
||||
<HeaderActionButton onPress={() => load()} ariaLabel={t('common.refresh', 'Refresh')}>
|
||||
<RefreshCcw size={18} color={textStrong} />
|
||||
</HeaderActionButton>
|
||||
}
|
||||
>
|
||||
{error ? (
|
||||
<MobileCard>
|
||||
<Text color={danger}>{error}</Text>
|
||||
<YStack space="$4">
|
||||
{/* Status & Summary */}
|
||||
<MobileCard space="$3">
|
||||
<XStack alignItems="center" justifyContent="space-between">
|
||||
<YStack space="$1">
|
||||
<Text fontSize="$xl" fontWeight="800" color={textStrong}>
|
||||
{t('events.recap.done', 'Event beendet')}
|
||||
</Text>
|
||||
<Text fontSize="$sm" color={muted}>
|
||||
{formatDate(event.event_date)}
|
||||
</Text>
|
||||
</YStack>
|
||||
<PillBadge tone="success">{t('events.recap.statusClosed', 'Archiviert')}</PillBadge>
|
||||
</XStack>
|
||||
|
||||
<XStack flexWrap="wrap" gap="$2" marginTop="$1">
|
||||
<Stat label={t('events.stats.uploads', 'Uploads')} value={String(galleryCounts.photos)} />
|
||||
<Stat label={t('events.stats.pending', 'Offen')} value={String(galleryCounts.pending)} />
|
||||
<Stat label={t('events.stats.likes', 'Likes')} value={String(galleryCounts.likes)} />
|
||||
</XStack>
|
||||
</MobileCard>
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<YStack space="$2">
|
||||
{Array.from({ length: 4 }).map((_, idx) => (
|
||||
<SkeletonCard key={`sk-${idx}`} height={90} />
|
||||
))}
|
||||
</YStack>
|
||||
) : event && stats ? (
|
||||
<YStack space="$3">
|
||||
<MobileCard space="$2">
|
||||
<XStack alignItems="center" justifyContent="space-between">
|
||||
<YStack space="$1">
|
||||
<Text fontSize="$xs" color={muted} fontWeight="700" letterSpacing={1.2}>
|
||||
{t('events.recap.badge', 'Nachbereitung')}
|
||||
</Text>
|
||||
<Text fontSize="$lg" fontWeight="800" color={textStrong}>{resolveName(event.name)}</Text>
|
||||
<Text fontSize="$sm" color={muted}>
|
||||
{t('events.recap.subtitle', 'Abschluss, Export und Galerie-Laufzeit verwalten.')}
|
||||
</Text>
|
||||
</YStack>
|
||||
<PillBadge tone={event.is_active ? 'success' : 'muted'}>
|
||||
{event.is_active ? t('events.recap.galleryOpen', 'Galerie geöffnet') : t('events.recap.galleryClosed', 'Galerie geschlossen')}
|
||||
</PillBadge>
|
||||
</XStack>
|
||||
<XStack space="$2" marginTop="$2" flexWrap="wrap">
|
||||
<CTAButton
|
||||
label={event.is_active ? t('events.recap.closeGallery', 'Galerie schließen') : t('events.recap.openGallery', 'Galerie öffnen')}
|
||||
onPress={toggleGallery}
|
||||
loading={busy}
|
||||
/>
|
||||
<CTAButton label={t('events.recap.moderate', 'Uploads ansehen')} tone="ghost" onPress={() => navigate(adminPath(`/mobile/events/${slug}/photos`))} />
|
||||
<CTAButton label={t('events.actions.edit', 'Bearbeiten')} tone="ghost" onPress={() => navigate(adminPath(`/mobile/events/${slug}/edit`))} />
|
||||
</XStack>
|
||||
</MobileCard>
|
||||
{/* Share Section */}
|
||||
<MobileCard space="$3">
|
||||
<XStack alignItems="center" space="$2">
|
||||
<Share2 size={18} color={primary} />
|
||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||
{t('events.recap.shareGallery', 'Galerie teilen')}
|
||||
</Text>
|
||||
</XStack>
|
||||
<Text fontSize="$sm" color={text}>
|
||||
{t('events.recap.shareBody', 'Deine Gäste können die Galerie auch nach dem Event weiterhin ansehen.')}
|
||||
</Text>
|
||||
|
||||
<MobileCard space="$2">
|
||||
<XStack alignItems="center" justifyContent="space-between">
|
||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||
{t('events.recap.galleryTitle', 'Galerie-Status')}
|
||||
</Text>
|
||||
<PillBadge tone="muted">{t('events.recap.galleryCounts', '{{photos}} Fotos, {{pending}} offen, {{likes}} Likes', galleryCounts)}</PillBadge>
|
||||
</XStack>
|
||||
<XStack space="$2" marginTop="$2" flexWrap="wrap">
|
||||
<Stat pill label={t('events.stats.uploads', 'Uploads')} value={String(galleryCounts.photos)} />
|
||||
<Stat pill label={t('events.stats.pending', 'Offen')} value={String(galleryCounts.pending)} />
|
||||
<Stat pill label={t('events.stats.likes', 'Likes')} value={String(galleryCounts.likes)} />
|
||||
</XStack>
|
||||
</MobileCard>
|
||||
|
||||
<MobileCard space="$2">
|
||||
<XStack alignItems="center" space="$2">
|
||||
<Link2 size={16} color={textStrong} />
|
||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||
{t('events.recap.shareLink', 'Gäste-Link')}
|
||||
</Text>
|
||||
</XStack>
|
||||
{guestLink ? (
|
||||
<Text fontSize="$sm" color={text} selectable>
|
||||
<YStack space="$2" marginTop="$1">
|
||||
<XStack
|
||||
backgroundColor={border}
|
||||
padding="$3"
|
||||
borderRadius={12}
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<Text fontSize="$xs" color={muted} numberOfLines={1} flex={1}>
|
||||
{guestLink}
|
||||
</Text>
|
||||
) : (
|
||||
<Text fontSize="$sm" color={muted}>
|
||||
{t('events.recap.noPublicUrl', 'Kein Gäste-Link gesetzt. Lege den öffentlichen Link im Event-Setup fest.')}
|
||||
</Text>
|
||||
)}
|
||||
<XStack space="$2" marginTop="$2">
|
||||
<CTAButton label={t('events.recap.copy', 'Kopieren')} tone="ghost" onPress={() => guestLink && copyToClipboard(guestLink, t)} />
|
||||
{guestLink ? (
|
||||
</XStack>
|
||||
{typeof navigator !== 'undefined' && !!navigator.share && (
|
||||
<CTAButton label={t('events.recap.share', 'Teilen')} tone="ghost" onPress={() => shareLink(guestLink, event, t)} />
|
||||
) : null}
|
||||
</XStack>
|
||||
{activeInvite?.qr_code_data_url ? (
|
||||
<XStack space="$2" alignItems="center" marginTop="$2">
|
||||
<img
|
||||
src={activeInvite.qr_code_data_url}
|
||||
alt={t('events.qr.qrAlt', 'QR code')}
|
||||
style={{ width: 96, height: 96 }}
|
||||
/>
|
||||
<CTAButton label={t('events.recap.downloadQr', 'QR herunterladen')} tone="ghost" onPress={() => downloadQr(activeInvite.qr_code_data_url)} />
|
||||
</XStack>
|
||||
) : null}
|
||||
</MobileCard>
|
||||
)}
|
||||
</YStack>
|
||||
|
||||
<MobileCard space="$2">
|
||||
<XStack alignItems="center" space="$2">
|
||||
<ShoppingCart size={16} color={textStrong} />
|
||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||
{t('events.sections.addons.title', 'Add-ons & Upgrades')}
|
||||
</Text>
|
||||
</XStack>
|
||||
<Text fontSize="$sm" color={muted}>
|
||||
{t('events.sections.addons.description', 'Zusätzliche Kontingente freischalten.')}
|
||||
{activeInvite?.qr_code_data_url ? (
|
||||
<YStack alignItems="center" space="$2" marginTop="$2">
|
||||
<YStack
|
||||
padding="$2"
|
||||
backgroundColor="white"
|
||||
borderRadius={12}
|
||||
borderWidth={1}
|
||||
borderColor={border}
|
||||
>
|
||||
<img src={activeInvite.qr_code_data_url} alt="QR" style={{ width: 120, height: 120 }} />
|
||||
</YStack>
|
||||
<CTAButton label={t('events.recap.downloadQr', 'QR herunterladen')} tone="ghost" onPress={() => downloadQr(activeInvite.qr_code_data_url!)} />
|
||||
</YStack>
|
||||
) : null}
|
||||
</MobileCard>
|
||||
|
||||
{/* Settings */}
|
||||
<MobileCard space="$3">
|
||||
<XStack alignItems="center" space="$2">
|
||||
<Users size={18} color={primary} />
|
||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||
{t('events.recap.settings', 'Nachlauf-Optionen')}
|
||||
</Text>
|
||||
<CTAButton
|
||||
label={t('events.recap.extendGallery', 'Galerie verlängern')}
|
||||
onPress={() => {
|
||||
startAddonCheckout();
|
||||
}}
|
||||
loading={checkoutBusy}
|
||||
/>
|
||||
</MobileCard>
|
||||
</XStack>
|
||||
|
||||
<MobileCard space="$2">
|
||||
<XStack alignItems="center" space="$2">
|
||||
<Shield size={16} color={textStrong} />
|
||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||
{t('events.recap.settingsTitle', 'Gast-Einstellungen')}
|
||||
</Text>
|
||||
</XStack>
|
||||
<ToggleRow
|
||||
label={t('events.recap.downloads', 'Downloads erlauben')}
|
||||
<YStack space="$1.5">
|
||||
<ToggleOption
|
||||
label={t('events.recap.allowDownloads', 'Gäste dürfen Fotos laden')}
|
||||
value={Boolean(event.settings?.guest_downloads_enabled)}
|
||||
onToggle={(value) => updateSetting(event, setEvent, slug, 'guest_downloads_enabled', value, setError, t)}
|
||||
onToggle={(value) => updateSetting(event, setEvent, slug, 'guest_downloads_enabled', value, setError, t as any)}
|
||||
/>
|
||||
<ToggleRow
|
||||
label={t('events.recap.sharing', 'Sharing erlauben')}
|
||||
<ToggleOption
|
||||
label={t('events.recap.allowSharing', 'Gäste dürfen Fotos teilen')}
|
||||
value={Boolean(event.settings?.guest_sharing_enabled)}
|
||||
onToggle={(value) => updateSetting(event, setEvent, slug, 'guest_sharing_enabled', value, setError, t)}
|
||||
onToggle={(value) => updateSetting(event, setEvent, slug, 'guest_sharing_enabled', value, setError, t as any)}
|
||||
/>
|
||||
</MobileCard>
|
||||
</YStack>
|
||||
</MobileCard>
|
||||
|
||||
<MobileCard space="$2">
|
||||
<XStack alignItems="center" space="$2">
|
||||
<Archive size={16} color={textStrong} />
|
||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||
{t('events.recap.archiveTitle', 'Event archivieren')}
|
||||
</Text>
|
||||
</XStack>
|
||||
<Text fontSize="$sm" color={muted}>
|
||||
{t('events.recap.archiveCopy', 'Schließt die Galerie und markiert das Event als abgeschlossen.')}
|
||||
{/* Extensions */}
|
||||
<MobileCard space="$3">
|
||||
<XStack alignItems="center" space="$2">
|
||||
<Sparkles size={18} color={primary} />
|
||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||
{t('events.recap.addons', 'Galerie verlängern')}
|
||||
</Text>
|
||||
<CTAButton label={t('events.recap.archive', 'Archivieren')} onPress={() => archiveEvent()} loading={archiveBusy} />
|
||||
</MobileCard>
|
||||
</XStack>
|
||||
<Text fontSize="$sm" color={text}>
|
||||
{t('events.recap.addonBody', 'Die Online-Zeit deiner Galerie neigt sich dem Ende? Hier kannst du sie verlängern.')}
|
||||
</Text>
|
||||
|
||||
<YStack space="$2">
|
||||
{addons
|
||||
.filter((a) => a.key === 'gallery_extension')
|
||||
.map((addon) => (
|
||||
<CTAButton
|
||||
key={addon.key}
|
||||
label={t('events.recap.buyExtension', 'Galerie um 30 Tage verlängern')}
|
||||
onPress={() => handleCheckout(addon.key)}
|
||||
loading={busyScope === addon.key}
|
||||
/>
|
||||
))}
|
||||
</YStack>
|
||||
</MobileCard>
|
||||
</YStack>
|
||||
|
||||
<MobileCard space="$2">
|
||||
<XStack alignItems="center" space="$2">
|
||||
<Sparkles size={16} color={textStrong} />
|
||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||
{t('events.recap.feedbackTitle', 'Wie lief das Event?')}
|
||||
</Text>
|
||||
</XStack>
|
||||
<XStack space="$2" marginTop="$2" flexWrap="wrap">
|
||||
<CTAButton label={t('events.feedback.positive', 'War super')} tone="ghost" onPress={() => void submitFeedback('positive')} />
|
||||
<CTAButton label={t('events.feedback.neutral', 'In Ordnung')} tone="ghost" onPress={() => void submitFeedback('neutral')} />
|
||||
<CTAButton label={t('events.feedback.negative', 'Brauch(t)e Unterstützung')} tone="ghost" onPress={() => void submitFeedback('negative')} />
|
||||
</XStack>
|
||||
</MobileCard>
|
||||
</YStack>
|
||||
) : null}
|
||||
<LegalConsentSheet
|
||||
open={consentOpen}
|
||||
onClose={() => {
|
||||
if (consentBusy) return;
|
||||
setConsentOpen(false);
|
||||
setConsentAddonKey(null);
|
||||
setBusyScope(null);
|
||||
}}
|
||||
onConfirm={confirmAddonCheckout}
|
||||
busy={consentBusy}
|
||||
t={t}
|
||||
onConfirm={handleConsentConfirm}
|
||||
busy={Boolean(busyScope)}
|
||||
t={t as any}
|
||||
/>
|
||||
</MobileShell>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value }: { label: string; value: string }) {
|
||||
const { border, muted, textStrong } = useAdminTheme();
|
||||
function Stat({ label, value, pill }: { label: string; value: string; pill?: boolean }) {
|
||||
const { textStrong, muted, accentSoft, border } = useAdminTheme();
|
||||
return (
|
||||
<MobileCard borderColor={border} space="$1.5">
|
||||
<YStack
|
||||
paddingHorizontal="$3"
|
||||
paddingVertical="$2"
|
||||
borderRadius={12}
|
||||
borderWidth={1}
|
||||
borderColor={border}
|
||||
backgroundColor={pill ? accentSoft : 'transparent'}
|
||||
minWidth={80}
|
||||
>
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||
{value}
|
||||
</Text>
|
||||
</MobileCard>
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleRow({ label, value, onToggle }: { label: string; value: boolean; onToggle: (value: boolean) => void }) {
|
||||
function ToggleOption({ label, value, onToggle }: { label: string; value: boolean; onToggle: (val: boolean) => void }) {
|
||||
const { textStrong } = useAdminTheme();
|
||||
return (
|
||||
<XStack alignItems="center" justifyContent="space-between" marginTop="$1.5">
|
||||
<Text fontSize="$sm" color={textStrong}>
|
||||
<XStack alignItems="center" justifyContent="space-between" paddingVertical="$1">
|
||||
<Text fontSize="$sm" color={textStrong} fontWeight="600">
|
||||
{label}
|
||||
</Text>
|
||||
<input
|
||||
@@ -433,27 +351,25 @@ async function updateSetting(
|
||||
}
|
||||
}
|
||||
|
||||
function copyToClipboard(value: string, t: (key: string, fallback?: string) => string) {
|
||||
navigator.clipboard
|
||||
.writeText(value)
|
||||
.then(() => toast.success(t('events.recap.copySuccess', 'Link kopiert')))
|
||||
.catch(() => toast.error(t('events.recap.copyError', 'Link konnte nicht kopiert werden.')));
|
||||
function copyToClipboard(value: string, t: any) {
|
||||
if (typeof window !== 'undefined') {
|
||||
void window.navigator.clipboard.writeText(value);
|
||||
toast.success(t('events.recap.copySuccess', 'Link kopiert'));
|
||||
}
|
||||
}
|
||||
|
||||
async function shareLink(value: string, event: TenantEvent, t: (key: string, fallback?: string) => string) {
|
||||
if (navigator.share) {
|
||||
async function shareLink(value: string, event: TenantEvent | null, t: any) {
|
||||
if (typeof window !== 'undefined' && navigator.share) {
|
||||
try {
|
||||
await navigator.share({
|
||||
title: resolveName(event.name),
|
||||
text: t('events.recap.shareGuests', 'Gäste-Galerie teilen'),
|
||||
title: resolveName(event?.name ?? ''),
|
||||
text: t('events.recap.shareText', 'Schau dir die Fotos von unserem Event an!'),
|
||||
url: value,
|
||||
});
|
||||
return;
|
||||
} catch {
|
||||
// ignore
|
||||
// silently ignore or fallback to copy
|
||||
}
|
||||
}
|
||||
copyToClipboard(value, t);
|
||||
}
|
||||
|
||||
function downloadQr(dataUrl: string) {
|
||||
@@ -476,4 +392,4 @@ function formatDate(iso?: string | null): string {
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
return date.toLocaleDateString(undefined, { day: '2-digit', month: 'short', year: 'numeric' });
|
||||
}
|
||||
}
|
||||
@@ -183,7 +183,7 @@ export default function MobileEventTasksPage() {
|
||||
setSearchTerm('');
|
||||
}, [slug]);
|
||||
|
||||
const scrollToSection = (ref: React.RefObject<HTMLDivElement>) => {
|
||||
const scrollToSection = (ref: React.RefObject<HTMLDivElement | null>) => {
|
||||
if (ref.current) {
|
||||
ref.current.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
@@ -561,8 +561,8 @@ export default function MobileEventTasksPage() {
|
||||
/>
|
||||
</XStack>
|
||||
</MobileCard>
|
||||
<YGroup borderWidth={1} borderColor={border} borderRadius="$4" overflow="hidden">
|
||||
<YGroup.Item bordered>
|
||||
<YGroup {...({ borderWidth: 1, borderColor: border, borderRadius: "$4", overflow: "hidden" } as any)}>
|
||||
<YGroup.Item>
|
||||
<ListItem
|
||||
hoverTheme
|
||||
pressTheme
|
||||
@@ -661,9 +661,9 @@ export default function MobileEventTasksPage() {
|
||||
<Text fontSize="$sm" color={muted}>
|
||||
{t('events.tasks.count', '{{count}} Tasks', { count: filteredTasks.length })}
|
||||
</Text>
|
||||
<YGroup borderWidth={1} borderColor={border} borderRadius="$4" overflow="hidden">
|
||||
<YGroup {...({ borderWidth: 1, borderColor: border, borderRadius: "$4", overflow: "hidden" } as any)}>
|
||||
{filteredTasks.map((task, idx) => (
|
||||
<YGroup.Item key={task.id} bordered={idx < filteredTasks.length - 1}>
|
||||
<YGroup.Item key={task.id}>
|
||||
<ListItem
|
||||
hoverTheme
|
||||
pressTheme
|
||||
@@ -694,7 +694,7 @@ export default function MobileEventTasksPage() {
|
||||
icon={<Trash2 size={14} color={dangerText} />}
|
||||
aria-label={t('events.tasks.remove', 'Remove task')}
|
||||
disabled={busyId === task.id}
|
||||
onPress={(event) => {
|
||||
onPress={(event: any) => {
|
||||
event?.stopPropagation?.();
|
||||
setDeleteCandidate(task);
|
||||
}}
|
||||
@@ -729,9 +729,9 @@ export default function MobileEventTasksPage() {
|
||||
{t('events.tasks.libraryEmpty', 'Keine weiteren Aufgaben verfügbar.')}
|
||||
</Text>
|
||||
) : (
|
||||
<YGroup borderWidth={1} borderColor={border} borderRadius="$4" overflow="hidden">
|
||||
<YGroup {...({ borderWidth: 1, borderColor: border, borderRadius: "$4", overflow: "hidden" } as any)}>
|
||||
{(expandedLibrary ? library : library.slice(0, 6)).map((task, idx, arr) => (
|
||||
<YGroup.Item key={`lib-${task.id}`} bordered={idx < arr.length - 1}>
|
||||
<YGroup.Item key={`lib-${task.id}`}>
|
||||
<ListItem
|
||||
hoverTheme
|
||||
pressTheme
|
||||
@@ -786,9 +786,9 @@ export default function MobileEventTasksPage() {
|
||||
{t('events.tasks.collectionsEmpty', 'Keine Pakete vorhanden.')}
|
||||
</Text>
|
||||
) : (
|
||||
<YGroup borderWidth={1} borderColor={border} borderRadius="$4" overflow="hidden">
|
||||
<YGroup {...({ borderWidth: 1, borderColor: border, borderRadius: "$4", overflow: "hidden" } as any)}>
|
||||
{(expandedCollections ? collections : collections.slice(0, 6)).map((collection, idx, arr) => (
|
||||
<YGroup.Item key={collection.id} bordered={idx < arr.length - 1}>
|
||||
<YGroup.Item key={collection.id}>
|
||||
<ListItem
|
||||
hoverTheme
|
||||
pressTheme
|
||||
@@ -917,9 +917,9 @@ export default function MobileEventTasksPage() {
|
||||
style={{ padding: 0 }}
|
||||
/>
|
||||
</MobileField>
|
||||
<YGroup borderWidth={1} borderColor={border} borderRadius="$4" overflow="hidden">
|
||||
<YGroup {...({ borderWidth: 1, borderColor: border, borderRadius: "$4", overflow: "hidden" } as any)}>
|
||||
{emotions.map((em, idx) => (
|
||||
<YGroup.Item key={`emo-${em.id}`} bordered={idx < emotions.length - 1}>
|
||||
<YGroup.Item key={`emo-${em.id}`}>
|
||||
<ListItem
|
||||
hoverTheme
|
||||
pressTheme
|
||||
@@ -1000,9 +1000,9 @@ export default function MobileEventTasksPage() {
|
||||
}}
|
||||
>
|
||||
<AlertDialog.Portal>
|
||||
<AlertDialog.Overlay backgroundColor={`${overlay}66`} />
|
||||
<AlertDialog.Overlay backgroundColor={`${overlay}66` as any} />
|
||||
<AlertDialog.Content
|
||||
borderRadius={20}
|
||||
{...({ borderRadius: 20 } as any)}
|
||||
borderWidth={1}
|
||||
borderColor={border}
|
||||
backgroundColor={surface}
|
||||
@@ -1058,8 +1058,8 @@ export default function MobileEventTasksPage() {
|
||||
title={t('events.tasks.actions', 'Aktionen')}
|
||||
footer={null}
|
||||
>
|
||||
<YGroup borderWidth={1} borderColor={border} borderRadius="$4" overflow="hidden">
|
||||
<YGroup.Item bordered>
|
||||
<YGroup {...({ borderWidth: 1, borderColor: border, borderRadius: "$4", overflow: "hidden" } as any)}>
|
||||
<YGroup.Item>
|
||||
<ListItem
|
||||
hoverTheme
|
||||
pressTheme
|
||||
@@ -1077,7 +1077,7 @@ export default function MobileEventTasksPage() {
|
||||
iconAfter={<ChevronRight size={14} color={subtle} />}
|
||||
/>
|
||||
</YGroup.Item>
|
||||
<YGroup.Item bordered>
|
||||
<YGroup.Item>
|
||||
<ListItem
|
||||
hoverTheme
|
||||
pressTheme
|
||||
|
||||
@@ -334,7 +334,7 @@ export default function MobileNotificationsPage() {
|
||||
const reload = React.useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await loadNotifications(t, events, { scope: scopeParam, status: statusParam, eventSlug: slug });
|
||||
const data = await loadNotifications(t as any, events, { scope: scopeParam, status: statusParam, eventSlug: slug });
|
||||
setNotifications(data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
|
||||
@@ -39,7 +39,7 @@ export default function MobilePackageShopPage() {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<MobileShell title={t('shop.title', 'Upgrade Package')} showBack activeTab="profile">
|
||||
<MobileShell title={t('shop.title', 'Upgrade Package')} onBack={() => navigate(-1)} activeTab="profile">
|
||||
<YStack space="$3">
|
||||
<SkeletonCard height={150} />
|
||||
<SkeletonCard height={150} />
|
||||
@@ -71,7 +71,7 @@ export default function MobilePackageShopPage() {
|
||||
});
|
||||
|
||||
return (
|
||||
<MobileShell title={t('shop.title', 'Upgrade Package')} showBack activeTab="profile">
|
||||
<MobileShell title={t('shop.title', 'Upgrade Package')} onBack={() => navigate(-1)} activeTab="profile">
|
||||
<YStack space="$4">
|
||||
{recommendedFeature && (
|
||||
<MobileCard borderColor={primary} backgroundColor={accentSoft} space="$2" padding="$3">
|
||||
@@ -156,7 +156,7 @@ function PackageShopCard({
|
||||
<Text fontSize="$lg" fontWeight="800" color={textStrong}>
|
||||
{pkg.name}
|
||||
</Text>
|
||||
{isRecommended && <PillBadge tone="primary">{t('shop.badges.recommended', 'Recommended')}</PillBadge>}
|
||||
{isRecommended && <PillBadge tone="warning">{t('shop.badges.recommended', 'Recommended')}</PillBadge>}
|
||||
{isActive && <PillBadge tone="success">{t('shop.badges.active', 'Active')}</PillBadge>}
|
||||
</XStack>
|
||||
|
||||
@@ -171,7 +171,9 @@ function PackageShopCard({
|
||||
)}
|
||||
</XStack>
|
||||
</YStack>
|
||||
<ChevronRight size={20} color={muted} marginTop="$2" />
|
||||
<YStack marginTop="$2">
|
||||
<ChevronRight size={20} color={muted} />
|
||||
</YStack>
|
||||
</XStack>
|
||||
|
||||
<YStack space="$1.5">
|
||||
@@ -238,7 +240,7 @@ function CheckoutConfirmation({ pkg, onCancel }: { pkg: Package; onCancel: () =>
|
||||
};
|
||||
|
||||
return (
|
||||
<MobileShell title={t('shop.confirmTitle', 'Confirm Purchase')} onBack={onCancel}>
|
||||
<MobileShell title={t('shop.confirmTitle', 'Confirm Purchase')} onBack={onCancel} activeTab="profile">
|
||||
<YStack space="$4">
|
||||
<MobileCard space="$2" borderColor={border}>
|
||||
<Text fontSize="$sm" color={muted}>{t('shop.confirmSubtitle', 'You are upgrading to:')}</Text>
|
||||
|
||||
@@ -82,127 +82,129 @@ export default function MobileProfilePage() {
|
||||
<Text fontSize="$md" fontWeight="800" color={textColor}>
|
||||
{t('mobileProfile.settings', 'Settings')}
|
||||
</Text>
|
||||
<YGroup borderRadius="$4" borderWidth={1} borderColor={borderColor} overflow="hidden">
|
||||
<YGroup.Item bordered>
|
||||
<Pressable onPress={() => navigate(adminPath('/mobile/settings'))}>
|
||||
<YStack space="$4">
|
||||
<YGroup {...({ borderRadius: "$4", borderWidth: 1, borderColor: borderColor, overflow: "hidden" } as any)}>
|
||||
<YGroup.Item>
|
||||
<Pressable onPress={() => navigate(adminPath('/mobile/profile/security'))}>
|
||||
<ListItem
|
||||
hoverTheme
|
||||
pressTheme
|
||||
paddingVertical="$2"
|
||||
paddingHorizontal="$3"
|
||||
title={
|
||||
<Text fontSize="$sm" color={textColor}>
|
||||
{t('mobileProfile.account', 'Account & security')}
|
||||
</Text>
|
||||
}
|
||||
iconAfter={<Settings size={18} color={subtle} />}
|
||||
/>
|
||||
</Pressable>
|
||||
</YGroup.Item>
|
||||
<YGroup.Item>
|
||||
<Pressable onPress={() => navigate(adminPath('/mobile/billing#packages'))}>
|
||||
<ListItem
|
||||
hoverTheme
|
||||
pressTheme
|
||||
paddingVertical="$2"
|
||||
paddingHorizontal="$3"
|
||||
title={
|
||||
<Text fontSize="$sm" color={textColor}>
|
||||
{t('billing.sections.packages.title', 'Packages & Billing')}
|
||||
</Text>
|
||||
}
|
||||
iconAfter={<Settings size={18} color={subtle} />}
|
||||
/>
|
||||
</Pressable>
|
||||
</YGroup.Item>
|
||||
<YGroup.Item>
|
||||
<Pressable onPress={() => navigate(adminPath('/mobile/billing#invoices'))}>
|
||||
<ListItem
|
||||
hoverTheme
|
||||
pressTheme
|
||||
paddingVertical="$2"
|
||||
paddingHorizontal="$3"
|
||||
title={
|
||||
<Text fontSize="$sm" color={textColor}>
|
||||
{t('billing.sections.invoices.title', 'Invoices & Payments')}
|
||||
</Text>
|
||||
}
|
||||
iconAfter={<Settings size={18} color={subtle} />}
|
||||
/>
|
||||
</Pressable>
|
||||
</YGroup.Item>
|
||||
<YGroup.Item>
|
||||
<Pressable onPress={() => navigate(ADMIN_DATA_EXPORTS_PATH)}>
|
||||
<ListItem
|
||||
hoverTheme
|
||||
pressTheme
|
||||
paddingVertical="$2"
|
||||
paddingHorizontal="$3"
|
||||
title={
|
||||
<Text fontSize="$sm" color={textColor}>
|
||||
{t('dataExports.title', 'Data exports')}
|
||||
</Text>
|
||||
}
|
||||
iconAfter={<Download size={18} color={subtle} />}
|
||||
/>
|
||||
</Pressable>
|
||||
</YGroup.Item>
|
||||
<YGroup.Item>
|
||||
<ListItem
|
||||
hoverTheme
|
||||
pressTheme
|
||||
paddingVertical="$2"
|
||||
paddingHorizontal="$3"
|
||||
title={
|
||||
<Text fontSize="$sm" color={textColor}>
|
||||
{t('mobileProfile.account', 'Account & security')}
|
||||
</Text>
|
||||
<XStack space="$2" alignItems="center">
|
||||
<Globe size={16} color={muted} />
|
||||
<Text fontSize="$sm" color={textColor}>
|
||||
{t('mobileProfile.language', 'Language')}
|
||||
</Text>
|
||||
</XStack>
|
||||
}
|
||||
iconAfter={
|
||||
<MobileSelect
|
||||
value={language}
|
||||
onChange={(e) => {
|
||||
const lng = e.target.value;
|
||||
setLanguage(lng);
|
||||
void i18n.changeLanguage(lng);
|
||||
}}
|
||||
compact
|
||||
style={{ minWidth: 130 }}
|
||||
>
|
||||
<option value="de">{t('mobileProfile.languageDe', 'Deutsch')}</option>
|
||||
<option value="en">{t('mobileProfile.languageEn', 'English')}</option>
|
||||
</MobileSelect>
|
||||
}
|
||||
iconAfter={<Settings size={18} color={subtle} />}
|
||||
/>
|
||||
</Pressable>
|
||||
</YGroup.Item>
|
||||
<YGroup.Item bordered>
|
||||
<Pressable onPress={() => navigate(adminPath('/mobile/billing#packages'))}>
|
||||
</YGroup.Item>
|
||||
<YGroup.Item>
|
||||
<ListItem
|
||||
hoverTheme
|
||||
pressTheme
|
||||
paddingVertical="$2"
|
||||
paddingHorizontal="$3"
|
||||
title={
|
||||
<Text fontSize="$sm" color={textColor}>
|
||||
{t('billing.sections.packages.title', 'Packages & Billing')}
|
||||
</Text>
|
||||
<XStack space="$2" alignItems="center">
|
||||
<Moon size={16} color={muted} />
|
||||
<Text fontSize="$sm" color={textColor}>
|
||||
{t('mobileProfile.theme', 'Theme')}
|
||||
</Text>
|
||||
</XStack>
|
||||
}
|
||||
iconAfter={<Settings size={18} color={subtle} />}
|
||||
/>
|
||||
</Pressable>
|
||||
</YGroup.Item>
|
||||
<YGroup.Item bordered>
|
||||
<Pressable onPress={() => navigate(adminPath('/mobile/billing#invoices'))}>
|
||||
<ListItem
|
||||
hoverTheme
|
||||
pressTheme
|
||||
paddingVertical="$2"
|
||||
paddingHorizontal="$3"
|
||||
title={
|
||||
<Text fontSize="$sm" color={textColor}>
|
||||
{t('billing.sections.invoices.title', 'Invoices & Payments')}
|
||||
</Text>
|
||||
iconAfter={
|
||||
<MobileSelect
|
||||
value={appearance}
|
||||
onChange={(e) => updateAppearance(e.target.value as 'light' | 'dark' | 'system')}
|
||||
compact
|
||||
style={{ minWidth: 130 }}
|
||||
>
|
||||
<option value="light">{t('mobileProfile.themeLight', 'Light')}</option>
|
||||
<option value="dark">{t('mobileProfile.themeDark', 'Dark')}</option>
|
||||
<option value="system">{t('mobileProfile.themeSystem', 'System')}</option>
|
||||
</MobileSelect>
|
||||
}
|
||||
iconAfter={<Settings size={18} color={subtle} />}
|
||||
/>
|
||||
</Pressable>
|
||||
</YGroup.Item>
|
||||
<YGroup.Item bordered>
|
||||
<Pressable onPress={() => navigate(ADMIN_DATA_EXPORTS_PATH)}>
|
||||
<ListItem
|
||||
hoverTheme
|
||||
pressTheme
|
||||
paddingVertical="$2"
|
||||
paddingHorizontal="$3"
|
||||
title={
|
||||
<Text fontSize="$sm" color={textColor}>
|
||||
{t('dataExports.title', 'Data exports')}
|
||||
</Text>
|
||||
}
|
||||
iconAfter={<Download size={18} color={subtle} />}
|
||||
/>
|
||||
</Pressable>
|
||||
</YGroup.Item>
|
||||
<YGroup.Item bordered>
|
||||
<ListItem
|
||||
paddingVertical="$2"
|
||||
paddingHorizontal="$3"
|
||||
title={
|
||||
<XStack space="$2" alignItems="center">
|
||||
<Globe size={16} color={muted} />
|
||||
<Text fontSize="$sm" color={textColor}>
|
||||
{t('mobileProfile.language', 'Language')}
|
||||
</Text>
|
||||
</XStack>
|
||||
}
|
||||
iconAfter={
|
||||
<MobileSelect
|
||||
value={language}
|
||||
onChange={(e) => {
|
||||
const lng = e.target.value;
|
||||
setLanguage(lng);
|
||||
void i18n.changeLanguage(lng);
|
||||
}}
|
||||
compact
|
||||
style={{ minWidth: 130 }}
|
||||
>
|
||||
<option value="de">{t('mobileProfile.languageDe', 'Deutsch')}</option>
|
||||
<option value="en">{t('mobileProfile.languageEn', 'English')}</option>
|
||||
</MobileSelect>
|
||||
}
|
||||
/>
|
||||
</YGroup.Item>
|
||||
<YGroup.Item>
|
||||
<ListItem
|
||||
paddingVertical="$2"
|
||||
paddingHorizontal="$3"
|
||||
title={
|
||||
<XStack space="$2" alignItems="center">
|
||||
<Moon size={16} color={muted} />
|
||||
<Text fontSize="$sm" color={textColor}>
|
||||
{t('mobileProfile.theme', 'Theme')}
|
||||
</Text>
|
||||
</XStack>
|
||||
}
|
||||
iconAfter={
|
||||
<MobileSelect
|
||||
value={appearance}
|
||||
onChange={(e) => updateAppearance(e.target.value as 'light' | 'dark' | 'system')}
|
||||
compact
|
||||
style={{ minWidth: 130 }}
|
||||
>
|
||||
<option value="light">{t('mobileProfile.themeLight', 'Light')}</option>
|
||||
<option value="dark">{t('mobileProfile.themeDark', 'Dark')}</option>
|
||||
<option value="system">{t('mobileProfile.themeSystem', 'System')}</option>
|
||||
</MobileSelect>
|
||||
}
|
||||
/>
|
||||
</YGroup.Item>
|
||||
</YGroup>
|
||||
</YGroup.Item>
|
||||
</YGroup>
|
||||
</YStack>
|
||||
</MobileCard>
|
||||
|
||||
<CTAButton
|
||||
@@ -214,4 +216,4 @@ export default function MobileProfilePage() {
|
||||
/>
|
||||
</MobileShell>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -69,7 +69,7 @@ export default function MobileQrLayoutCustomizePage() {
|
||||
const layoutParam = searchParams.get('layout');
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation('management');
|
||||
const { textStrong, danger } = useAdminTheme();
|
||||
const { textStrong, danger, muted, border, primary, surface, surfaceMuted, overlay, shadow } = useAdminTheme();
|
||||
|
||||
const [event, setEvent] = React.useState<TenantEvent | null>(null);
|
||||
const [invite, setInvite] = React.useState<EventQrInvite | null>(null);
|
||||
@@ -420,9 +420,9 @@ function renderEventName(name: TenantEvent['name'] | null | undefined): string |
|
||||
|
||||
function getDefaultSlots(): Record<string, SlotDefinition> {
|
||||
return {
|
||||
headline: { x: 0.08, y: 0.12, w: 0.84, fontSize: 90, fontWeight: 800, fontFamily: DEFAULT_DISPLAY_FONT, align: 'center' },
|
||||
subtitle: { x: 0.1, y: 0.21, w: 0.8, fontSize: 37, fontWeight: 600, fontFamily: DEFAULT_BODY_FONT, align: 'center' },
|
||||
description: { x: 0.1, y: 0.66, w: 0.8, fontSize: 26, fontFamily: DEFAULT_BODY_FONT, lineHeight: 1.4, align: 'center' },
|
||||
headline: { x: 0.08, y: 0.12, w: 0.84, fontSize: 90, fontWeight: 800, fontFamily: DEFAULT_DISPLAY_FONT, align: 'center' as const },
|
||||
subtitle: { x: 0.1, y: 0.21, w: 0.8, fontSize: 37, fontWeight: 600, fontFamily: DEFAULT_BODY_FONT, align: 'center' as const },
|
||||
description: { x: 0.1, y: 0.66, w: 0.8, fontSize: 26, fontFamily: DEFAULT_BODY_FONT, lineHeight: 1.4, align: 'center' as const },
|
||||
instructions: { x: 0.1, y: 0.7, w: 0.8, fontSize: 24, fontFamily: DEFAULT_BODY_FONT, lineHeight: 1.5 },
|
||||
qr: { x: 0.39, y: 0.37, w: 0.27 },
|
||||
};
|
||||
@@ -453,9 +453,9 @@ function resolveSlots(layout: EventQrInviteLayout | null, isFoldable: boolean, o
|
||||
|
||||
const baseSlots = isFoldable
|
||||
? {
|
||||
headline: { x: 0.08, y: 0.15, w: 0.84, fontSize: 50, fontWeight: 800, fontFamily: DEFAULT_DISPLAY_FONT, align: 'center' },
|
||||
subtitle: { x: 0.1, y: 0.21, w: 0.8, fontSize: 37, fontWeight: 600, fontFamily: DEFAULT_BODY_FONT, align: 'center' },
|
||||
description: { x: 0.1, y: 0.66, w: 0.8, fontSize: 13, fontFamily: DEFAULT_BODY_FONT, lineHeight: 1.4, align: 'center' },
|
||||
headline: { x: 0.08, y: 0.15, w: 0.84, fontSize: 50, fontWeight: 800, fontFamily: DEFAULT_DISPLAY_FONT, align: 'center' as const },
|
||||
subtitle: { x: 0.1, y: 0.21, w: 0.8, fontSize: 37, fontWeight: 600, fontFamily: DEFAULT_BODY_FONT, align: 'center' as const },
|
||||
description: { x: 0.1, y: 0.66, w: 0.8, fontSize: 13, fontFamily: DEFAULT_BODY_FONT, lineHeight: 1.4, align: 'center' as const },
|
||||
instructions: { x: 0.12, y: 0.36, w: 0.76, fontSize: 13, fontFamily: DEFAULT_BODY_FONT, lineHeight: 1.3 },
|
||||
qr: { x: 0.3, y: 0.3, w: 0.28 },
|
||||
}
|
||||
@@ -520,8 +520,8 @@ function buildFabricOptions({
|
||||
const elements: LayoutElement[] = [];
|
||||
const textColor = layout?.preview?.text ?? ADMIN_COLORS.backdrop;
|
||||
const accentColor = layout?.preview?.accent ?? ADMIN_COLORS.primary;
|
||||
const secondaryColor = layout?.preview?.secondary ?? ADMIN_COLORS.text;
|
||||
const badgeColor = layout?.preview?.badge ?? accentColor;
|
||||
const secondaryColor = (layout?.preview as any)?.secondary ?? ADMIN_COLORS.text;
|
||||
const badgeColor = (layout?.preview as any)?.badge ?? accentColor;
|
||||
|
||||
const rotatePoint = (cx: number, cy: number, x: number, y: number, angleDeg: number) => {
|
||||
const rad = (angleDeg * Math.PI) / 180;
|
||||
@@ -862,15 +862,18 @@ function TextStep({
|
||||
textFields,
|
||||
onChange,
|
||||
onSave,
|
||||
onBulkAdd,
|
||||
saving,
|
||||
}: {
|
||||
onBack: () => void;
|
||||
textFields: { headline: string; subtitle: string; description: string; instructions: string[] };
|
||||
onChange: (fields: { headline: string; subtitle: string; description: string; instructions: string[] }) => void;
|
||||
onSave: () => void;
|
||||
onBulkAdd?: () => void;
|
||||
saving: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation('management');
|
||||
const { textStrong, border, surface, muted } = useAdminTheme();
|
||||
|
||||
const updateField = (key: 'headline' | 'subtitle' | 'description', value: string) => {
|
||||
onChange({ ...textFields, [key]: value });
|
||||
@@ -941,7 +944,7 @@ function TextStep({
|
||||
onChangeText={(val) => updateInstruction(idx, val)}
|
||||
placeholder={t('events.qr.instructionsPlaceholder', 'Schritt hinzufügen')}
|
||||
numberOfLines={2}
|
||||
flex={1}
|
||||
{...({ flex: 1 } as any)}
|
||||
size="$4"
|
||||
/>
|
||||
<Pressable onPress={() => removeInstruction(idx)} disabled={textFields.instructions.length === 1}>
|
||||
@@ -1096,7 +1099,8 @@ function PreviewStep({
|
||||
|
||||
const aspectRatio = `${canvasBase.width}/${canvasBase.height}`;
|
||||
const paper = resolvePaper(layout);
|
||||
const isLandscape = ((layout?.orientation ?? (isFoldable ? 'landscape' : 'portrait')) || '').toLowerCase() === 'landscape';
|
||||
const orientation = ((layout?.orientation ?? (isFoldable ? 'landscape' : 'portrait')) || '').toString().toLowerCase();
|
||||
const isLandscape = orientation === 'landscape';
|
||||
const orientationLabel = isLandscape
|
||||
? t('events.qr.orientation.landscape', 'Landscape')
|
||||
: t('events.qr.orientation.portrait', 'Portrait');
|
||||
@@ -1157,14 +1161,14 @@ function PreviewStep({
|
||||
try {
|
||||
await loadFonts();
|
||||
const pdfBytes = await generatePdfBytes(exportOptions, paper, orientation);
|
||||
const blob = new Blob([pdfBytes], { type: 'application/pdf' });
|
||||
const blob = new Blob([pdfBytes as any], { type: 'application/pdf' });
|
||||
triggerDownloadFromBlob(blob, 'qr-layout.pdf');
|
||||
} catch (err) {
|
||||
toast.error(t('events.qr.exportFailed', 'Export fehlgeschlagen'));
|
||||
console.error(err);
|
||||
}
|
||||
}}
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
style={{ flex: 1, minWidth: 0 } as any}
|
||||
/>
|
||||
<CTAButton
|
||||
label={t('events.qr.exportPng', 'Export PNG')}
|
||||
@@ -1178,7 +1182,7 @@ function PreviewStep({
|
||||
console.error(err);
|
||||
}
|
||||
}}
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
style={{ flex: 1, minWidth: 0 } as any}
|
||||
/>
|
||||
</XStack>
|
||||
</YStack>
|
||||
@@ -1315,7 +1319,7 @@ function LayoutControls({
|
||||
|
||||
return (
|
||||
<Accordion.Item value={slotKey} key={slotKey}>
|
||||
<Accordion.Trigger padding="$2" borderWidth={1} borderColor={border} borderRadius={12} backgroundColor={surfaceMuted}>
|
||||
<Accordion.Trigger {...({ padding: "$2", borderWidth: 1, borderColor: border, borderRadius: 12, backgroundColor: surfaceMuted } as any)}>
|
||||
<XStack justifyContent="space-between" alignItems="center" flex={1}>
|
||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||
{label}
|
||||
@@ -1323,7 +1327,7 @@ function LayoutControls({
|
||||
<ChevronDown size={16} color={muted} />
|
||||
</XStack>
|
||||
</Accordion.Trigger>
|
||||
<Accordion.Content paddingTop="$2">
|
||||
<Accordion.Content {...({ paddingTop: "$2" } as any)}>
|
||||
<YStack space="$2" padding="$2" borderWidth={1} borderColor={border} borderRadius={12}>
|
||||
<XStack space="$3">
|
||||
<YStack flex={1} space="$1">
|
||||
@@ -1546,7 +1550,7 @@ function LayoutControls({
|
||||
|
||||
{qrSlot ? (
|
||||
<Accordion.Item value="qr">
|
||||
<Accordion.Trigger padding="$2" borderWidth={1} borderColor={border} borderRadius={12} backgroundColor={surfaceMuted}>
|
||||
<Accordion.Trigger {...({ padding: "$2", borderWidth: 1, borderColor: border, borderRadius: 12, backgroundColor: surfaceMuted } as any)}>
|
||||
<XStack justifyContent="space-between" alignItems="center" flex={1}>
|
||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||
{t('events.qr.qr_code_label', 'QR‑Code')}
|
||||
@@ -1554,7 +1558,7 @@ function LayoutControls({
|
||||
<ChevronDown size={16} color={muted} />
|
||||
</XStack>
|
||||
</Accordion.Trigger>
|
||||
<Accordion.Content paddingTop="$2">
|
||||
<Accordion.Content {...({ paddingTop: "$2" } as any)}>
|
||||
<YStack space="$2" padding="$2" borderWidth={1} borderColor={border} borderRadius={12}>
|
||||
<XStack space="$2">
|
||||
<YStack flex={1} space="$1">
|
||||
|
||||
@@ -544,6 +544,7 @@ function PreviewStep({
|
||||
presets,
|
||||
textFields,
|
||||
qrUrl,
|
||||
qrImage,
|
||||
onExport,
|
||||
}: {
|
||||
onBack: () => void;
|
||||
@@ -552,6 +553,7 @@ function PreviewStep({
|
||||
presets: { id: string; src: string; label: string }[];
|
||||
textFields: { headline: string; subtitle: string; description: string; instructions: string[] };
|
||||
qrUrl: string;
|
||||
qrImage: string;
|
||||
onExport: (format: 'pdf' | 'png') => void;
|
||||
}) {
|
||||
const { t } = useTranslation('management');
|
||||
|
||||
@@ -241,8 +241,8 @@ export default function MobileSettingsPage() {
|
||||
{t('mobileSettings.notificationsLoading', 'Loading settings ...')}
|
||||
</Text>
|
||||
) : (
|
||||
<YGroup borderRadius="$4" borderWidth={1} borderColor={border} overflow="hidden">
|
||||
<YGroup.Item bordered>
|
||||
<YGroup {...({ borderRadius: "$4", borderWidth: 1, borderColor: border, overflow: "hidden" } as any)}>
|
||||
<YGroup.Item>
|
||||
<ListItem
|
||||
hoverTheme
|
||||
pressTheme
|
||||
@@ -280,7 +280,7 @@ export default function MobileSettingsPage() {
|
||||
/>
|
||||
</YGroup.Item>
|
||||
{AVAILABLE_PREFS.map((key, index) => (
|
||||
<YGroup.Item key={key} bordered={index < AVAILABLE_PREFS.length - 1}>
|
||||
<YGroup.Item key={key}>
|
||||
<ListItem
|
||||
hoverTheme
|
||||
pressTheme
|
||||
|
||||
@@ -71,7 +71,7 @@ describe('LimitWarnings', () => {
|
||||
used: 100,
|
||||
remaining: 0,
|
||||
percentage: 100,
|
||||
state: 'limit_reached',
|
||||
state: 'limit_reached' as const,
|
||||
threshold_reached: null,
|
||||
next_threshold: null,
|
||||
thresholds: [],
|
||||
|
||||
@@ -39,9 +39,9 @@ describe('buildInitialTextFields', () => {
|
||||
});
|
||||
|
||||
describe('resolveLayoutForFormat', () => {
|
||||
const layouts: EventQrInviteLayout[] = [
|
||||
{ id: 'portrait-a4', paper: 'a4', orientation: 'portrait', panel_mode: 'single', format_hint: 'poster-a4' } as EventQrInviteLayout,
|
||||
{ id: 'foldable', paper: 'a4', orientation: 'landscape', panel_mode: 'double-mirror', format_hint: 'foldable-a5' } as EventQrInviteLayout,
|
||||
const layouts = [
|
||||
{ id: 'portrait-a4', paper: 'a4', orientation: 'portrait', panel_mode: 'single', format_hint: 'poster-a4' } as any as EventQrInviteLayout,
|
||||
{ id: 'foldable', paper: 'a4', orientation: 'landscape', panel_mode: 'double-mirror', format_hint: 'foldable-a5' } as any as EventQrInviteLayout,
|
||||
];
|
||||
|
||||
it('returns portrait layout for A4 poster', () => {
|
||||
|
||||
@@ -96,10 +96,10 @@ export const buildPackageUsageMetrics = (pkg: TenantPackageSummary): PackageUsag
|
||||
const resolvedGalleryUsed = normalizeCount(galleryUsed);
|
||||
|
||||
return [
|
||||
{ key: 'events', limit: eventLimit, used: eventUsed, remaining: resolvedEventRemaining },
|
||||
{ key: 'guests', limit: guestLimit, used: guestUsed, remaining: resolvedGuestRemaining },
|
||||
{ key: 'photos', limit: photoLimit, used: photoUsed, remaining: resolvedPhotoRemaining },
|
||||
{ key: 'gallery', limit: galleryLimit, used: resolvedGalleryUsed, remaining: resolvedGalleryRemaining },
|
||||
{ key: 'events' as const, limit: eventLimit, used: eventUsed, remaining: resolvedEventRemaining },
|
||||
{ key: 'guests' as const, limit: guestLimit, used: guestUsed, remaining: resolvedGuestRemaining },
|
||||
{ key: 'photos' as const, limit: photoLimit, used: photoUsed, remaining: resolvedPhotoRemaining },
|
||||
{ key: 'gallery' as const, limit: galleryLimit, used: resolvedGalleryUsed, remaining: resolvedGalleryRemaining },
|
||||
].filter((metric) => metric.limit !== null);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import React from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||
import { Home, CheckSquare, Image as ImageIcon, User } from 'lucide-react';
|
||||
import { Home, CheckSquare, Image as ImageIcon, User, LayoutDashboard } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { withAlpha } from './colors';
|
||||
import { useAdminTheme } from '../theme';
|
||||
import { adminPath } from '../../constants';
|
||||
|
||||
const ICON_SIZE = 20;
|
||||
|
||||
@@ -13,12 +15,16 @@ export type NavKey = 'home' | 'tasks' | 'uploads' | 'profile';
|
||||
|
||||
export function BottomNav({ active, onNavigate }: { active: NavKey; onNavigate: (key: NavKey) => void }) {
|
||||
const { t } = useTranslation('mobile');
|
||||
const location = useLocation();
|
||||
const { surface, border, primary, accentSoft, muted, subtle, shadow } = useAdminTheme();
|
||||
const surfaceColor = surface;
|
||||
const navSurface = withAlpha(surfaceColor, 0.92);
|
||||
const [pressedKey, setPressedKey] = React.useState<NavKey | null>(null);
|
||||
|
||||
const isDeepHome = active === 'home' && location.pathname !== adminPath('/mobile/dashboard');
|
||||
|
||||
const items: Array<{ key: NavKey; icon: React.ComponentType<{ size?: number; color?: string }>; label: string }> = [
|
||||
{ key: 'home', icon: Home, label: t('nav.home', 'Home') },
|
||||
{ key: 'home', icon: isDeepHome ? LayoutDashboard : Home, label: t('nav.home', 'Home') },
|
||||
{ key: 'tasks', icon: CheckSquare, label: t('nav.tasks', 'Tasks') },
|
||||
{ key: 'uploads', icon: ImageIcon, label: t('nav.uploads', 'Uploads') },
|
||||
{ key: 'profile', icon: User, label: t('nav.profile', 'Profile') },
|
||||
|
||||
@@ -58,7 +58,7 @@ export const MobileInput = React.forwardRef<HTMLInputElement, React.ComponentPro
|
||||
<Input
|
||||
ref={ref as React.Ref<any>}
|
||||
{...props}
|
||||
type={type}
|
||||
{...({ type } as any)}
|
||||
secureTextEntry={isPassword}
|
||||
onChangeText={(value) => {
|
||||
onChange?.({ target: { value } } as React.ChangeEvent<HTMLInputElement>);
|
||||
@@ -75,11 +75,11 @@ export const MobileInput = React.forwardRef<HTMLInputElement, React.ComponentPro
|
||||
focusStyle={{
|
||||
borderColor: hasError ? danger : primary,
|
||||
boxShadow: `0 0 0 3px ${ringColor}`,
|
||||
}}
|
||||
} as any}
|
||||
hoverStyle={{
|
||||
borderColor,
|
||||
}}
|
||||
style={style}
|
||||
} as any}
|
||||
style={style as any}
|
||||
/>
|
||||
);
|
||||
},
|
||||
@@ -97,11 +97,11 @@ export const MobileTextArea = React.forwardRef<
|
||||
<TextArea
|
||||
ref={ref as React.Ref<any>}
|
||||
{...props}
|
||||
{...({ minHeight: compact ? 72 : 96 } as any)}
|
||||
onChangeText={(value) => {
|
||||
onChange?.({ target: { value } } as React.ChangeEvent<HTMLTextAreaElement>);
|
||||
}}
|
||||
size={compact ? '$3' : '$4'}
|
||||
minHeight={compact ? 72 : 96}
|
||||
borderRadius={12}
|
||||
padding="$3"
|
||||
width="100%"
|
||||
@@ -112,11 +112,11 @@ export const MobileTextArea = React.forwardRef<
|
||||
focusStyle={{
|
||||
borderColor: hasError ? danger : primary,
|
||||
boxShadow: `0 0 0 3px ${ringColor}`,
|
||||
}}
|
||||
} as any}
|
||||
hoverStyle={{
|
||||
borderColor,
|
||||
}}
|
||||
style={{ resize: 'vertical', ...style }}
|
||||
} as any}
|
||||
style={{ resize: 'vertical', ...style } as any}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -173,36 +173,36 @@ export function MobileSelect({
|
||||
width="100%"
|
||||
borderRadius={12}
|
||||
borderWidth={1}
|
||||
borderColor={borderColor}
|
||||
backgroundColor={surface}
|
||||
borderColor={borderColor as any}
|
||||
backgroundColor={surface as any}
|
||||
paddingVertical={compact ? 6 : 10}
|
||||
paddingHorizontal="$3"
|
||||
disabled={props.disabled}
|
||||
onFocus={props.onFocus}
|
||||
onBlur={props.onBlur}
|
||||
onFocus={props.onFocus as any}
|
||||
onBlur={props.onBlur as any}
|
||||
iconAfter={<ChevronDown size={16} color={subtle} />}
|
||||
focusStyle={{
|
||||
borderColor: hasError ? danger : primary,
|
||||
borderColor: (hasError ? danger : primary) as any,
|
||||
boxShadow: `0 0 0 3px ${ringColor}`,
|
||||
}}
|
||||
hoverStyle={{
|
||||
borderColor,
|
||||
borderColor: borderColor as any,
|
||||
}}
|
||||
style={style}
|
||||
style={style as any}
|
||||
>
|
||||
<Select.Value placeholder={props.placeholder ?? emptyOption?.label ?? ''} color={text} />
|
||||
<Select.Value placeholder={props.placeholder ?? (emptyOption?.label as any) ?? ''} {...({ color: text } as any)} />
|
||||
</Select.Trigger>
|
||||
<Select.Content
|
||||
zIndex={200000}
|
||||
borderRadius={14}
|
||||
{...({ borderRadius: 14 } as any)}
|
||||
borderWidth={1}
|
||||
borderColor={border}
|
||||
backgroundColor={surface}
|
||||
backgroundColor={surface as any}
|
||||
>
|
||||
<Select.Viewport padding="$2">
|
||||
<Select.Viewport {...({ padding: "$2" } as any)}>
|
||||
<Select.Group>
|
||||
{options.map((option, index) => (
|
||||
<Select.Item key={`${option.value}-${index}`} value={option.value} disabled={option.disabled}>
|
||||
<Select.Item index={index} key={`${option.value}-${index}`} value={option.value} disabled={option.disabled}>
|
||||
<Select.ItemText>{option.label}</Select.ItemText>
|
||||
</Select.Item>
|
||||
))}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { MobileSheet } from './Sheet';
|
||||
import { CTAButton } from './Primitives';
|
||||
import { useAdminTheme } from '../theme';
|
||||
|
||||
type Translator = (key: string, defaultValue?: string) => string;
|
||||
type Translator = any;
|
||||
|
||||
type LegalConsentSheetProps = {
|
||||
open: boolean;
|
||||
@@ -51,7 +51,7 @@ export function LegalConsentSheet({
|
||||
borderRadius: 4,
|
||||
appearance: 'auto',
|
||||
WebkitAppearance: 'auto',
|
||||
} as const;
|
||||
} as any;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
|
||||
@@ -85,6 +85,9 @@ export function CTAButton({
|
||||
fullWidth = true,
|
||||
disabled = false,
|
||||
loading = false,
|
||||
style,
|
||||
iconLeft,
|
||||
iconRight,
|
||||
}: {
|
||||
label: string;
|
||||
onPress: () => void;
|
||||
@@ -92,6 +95,9 @@ export function CTAButton({
|
||||
fullWidth?: boolean;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
style?: any;
|
||||
iconLeft?: React.ReactNode;
|
||||
iconRight?: React.ReactNode;
|
||||
}) {
|
||||
const { primary, surface, border, text, danger } = useAdminTheme();
|
||||
const isPrimary = tone === 'primary';
|
||||
@@ -108,6 +114,7 @@ export function CTAButton({
|
||||
width: fullWidth ? '100%' : undefined,
|
||||
flex: fullWidth ? undefined : 1,
|
||||
opacity: isDisabled ? 0.6 : 1,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<XStack
|
||||
@@ -118,10 +125,13 @@ export function CTAButton({
|
||||
backgroundColor={backgroundColor}
|
||||
borderWidth={isPrimary || isDanger ? 0 : 1}
|
||||
borderColor={borderColor}
|
||||
space="$2"
|
||||
>
|
||||
{iconLeft}
|
||||
<Text fontSize="$sm" fontWeight="800" color={labelColor}>
|
||||
{label}
|
||||
</Text>
|
||||
{iconRight}
|
||||
</XStack>
|
||||
</Pressable>
|
||||
);
|
||||
|
||||
@@ -28,7 +28,7 @@ export function MobileSheet({ open, title, onClose, children, footer, bottomOffs
|
||||
<Sheet
|
||||
modal
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
onOpenChange={(next: boolean) => {
|
||||
if (!next) {
|
||||
onClose();
|
||||
}
|
||||
@@ -39,26 +39,28 @@ export function MobileSheet({ open, title, onClose, children, footer, bottomOffs
|
||||
dismissOnSnapToBottom
|
||||
zIndex={100000}
|
||||
>
|
||||
<Sheet.Overlay backgroundColor={`${overlay}66`} />
|
||||
<Sheet.Overlay {...({ backgroundColor: `${overlay}66` } as any)} />
|
||||
<Sheet.Frame
|
||||
width="100%"
|
||||
maxWidth={520}
|
||||
alignSelf="center"
|
||||
borderTopLeftRadius={24}
|
||||
borderTopRightRadius={24}
|
||||
backgroundColor={surface}
|
||||
padding="$4"
|
||||
paddingBottom="$7"
|
||||
shadowColor={shadow}
|
||||
shadowOpacity={0.12}
|
||||
shadowRadius={18}
|
||||
shadowOffset={{ width: 0, height: -8 }}
|
||||
{...({
|
||||
width: '100%',
|
||||
maxWidth: 520,
|
||||
alignSelf: 'center',
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
backgroundColor: surface,
|
||||
padding: '$4',
|
||||
paddingBottom: '$7',
|
||||
shadowColor: shadow,
|
||||
shadowOpacity: 0.12,
|
||||
shadowRadius: 18,
|
||||
shadowOffset: { width: 0, height: -8 },
|
||||
} as any)}
|
||||
style={{ marginBottom: bottomOffset }}
|
||||
>
|
||||
<Sheet.Handle height={5} width={48} backgroundColor={border} borderRadius={999} marginBottom="$3" />
|
||||
<Sheet.ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={{ paddingBottom: 6 }}
|
||||
{...({ contentContainerStyle: { paddingBottom: 6 } } as any)}
|
||||
>
|
||||
<YStack space="$3">
|
||||
<XStack alignItems="center" justifyContent="space-between">
|
||||
|
||||
@@ -38,10 +38,10 @@ describe('LegalConsentSheet', () => {
|
||||
it('renders the required consent checkboxes when open', () => {
|
||||
const { getAllByRole } = render(
|
||||
<LegalConsentSheet
|
||||
open
|
||||
open={true}
|
||||
onClose={vi.fn()}
|
||||
onConfirm={vi.fn()}
|
||||
t={(key, fallback) => fallback ?? key}
|
||||
t={(key: string, fallback?: string) => fallback ?? key}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -1,20 +1,30 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useEventContext } from '../../context/EventContext';
|
||||
import { NavKey } from '../components/BottomNav';
|
||||
import { resolveTabTarget } from '../lib/tabHistory';
|
||||
import { adminPath } from '../../constants';
|
||||
|
||||
export function useMobileNav(currentSlug?: string | null) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { activeEvent } = useEventContext();
|
||||
const slug = currentSlug ?? activeEvent?.slug ?? null;
|
||||
|
||||
const go = React.useCallback(
|
||||
(key: NavKey) => {
|
||||
const target = resolveTabTarget(key, slug);
|
||||
|
||||
// Tap-to-reset: If we are already at the target, and it is the home tab,
|
||||
// and we are not at the dashboard root, then go to dashboard.
|
||||
if (key === 'home' && location.pathname === target && target !== adminPath('/mobile/dashboard')) {
|
||||
navigate(adminPath('/mobile/dashboard'));
|
||||
return;
|
||||
}
|
||||
|
||||
navigate(target);
|
||||
},
|
||||
[navigate, slug]
|
||||
[navigate, location.pathname, slug]
|
||||
);
|
||||
|
||||
return { go, slug };
|
||||
|
||||
@@ -15,6 +15,8 @@ const baseEvent = (overrides: Partial<TenantEvent>): TenantEvent => ({
|
||||
photo_count: overrides.photo_count ?? 0,
|
||||
likes_sum: overrides.likes_sum ?? 0,
|
||||
engagement_mode: overrides.engagement_mode ?? 'tasks',
|
||||
event_type_id: overrides.event_type_id ?? null,
|
||||
event_type: overrides.event_type ?? null,
|
||||
});
|
||||
|
||||
describe('resolveEventStatusKey', () => {
|
||||
|
||||
@@ -15,6 +15,8 @@ const baseEvent = (overrides: Partial<TenantEvent>): TenantEvent => ({
|
||||
photo_count: overrides.photo_count ?? 0,
|
||||
likes_sum: overrides.likes_sum ?? 0,
|
||||
engagement_mode: overrides.engagement_mode ?? 'tasks',
|
||||
event_type_id: overrides.event_type_id ?? null,
|
||||
event_type: overrides.event_type ?? null,
|
||||
});
|
||||
|
||||
describe('buildEventListStats', () => {
|
||||
|
||||
@@ -34,7 +34,7 @@ export function resolveOnboardingRedirect({
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!hasEvents && (!hasActivePackage || (remainingEvents !== null && remainingEvents <= 0))) {
|
||||
if (!hasEvents && (!hasActivePackage || (remainingEvents !== undefined && remainingEvents !== null && remainingEvents <= 0))) {
|
||||
return ADMIN_BILLING_PATH;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { TenantPackageSummary } from '../../api';
|
||||
|
||||
type Translate = (key: string, options?: Record<string, unknown> | string) => string;
|
||||
type Translate = any;
|
||||
|
||||
type LimitUsageOverrides = {
|
||||
remainingEvents?: number | null;
|
||||
|
||||
@@ -3,10 +3,10 @@ export function prefetchMobileRoutes() {
|
||||
|
||||
const schedule = (callback: () => void) => {
|
||||
if ('requestIdleCallback' in window) {
|
||||
(window as Window & { requestIdleCallback: (cb: () => void) => number }).requestIdleCallback(callback);
|
||||
(window as any).requestIdleCallback(callback);
|
||||
return;
|
||||
}
|
||||
window.setTimeout(callback, 1200);
|
||||
(window as any).setTimeout(callback, 1200);
|
||||
};
|
||||
|
||||
schedule(() => {
|
||||
|
||||
@@ -30,6 +30,7 @@ export const ADMIN_ACTION_COLORS = {
|
||||
recap: ADMIN_COLORS.warning,
|
||||
packages: ADMIN_COLORS.primary,
|
||||
analytics: '#8b5cf6',
|
||||
settings: ADMIN_COLORS.success,
|
||||
};
|
||||
|
||||
export const ADMIN_GRADIENTS = {
|
||||
|
||||
@@ -136,9 +136,9 @@ function PackageCard({
|
||||
const { t } = useTranslation('onboarding');
|
||||
const { primary, border, accentSoft, muted } = useAdminTheme();
|
||||
const badges = [
|
||||
t('packages.card.badges.photos', { count: pkg.max_photos ?? t('summary.details.infinity', '∞') }),
|
||||
t('packages.card.badges.guests', { count: pkg.max_guests ?? t('summary.details.infinity', '∞') }),
|
||||
t('packages.card.badges.days', { count: pkg.gallery_days ?? t('summary.details.infinity', '∞') }),
|
||||
t('packages.card.badges.photos', { count: pkg.max_photos ?? 0, defaultValue: 'Unlimited photos' } as any),
|
||||
t('packages.card.badges.guests', { count: pkg.max_guests ?? 0, defaultValue: 'Unlimited guests' } as any),
|
||||
t('packages.card.badges.days', { count: pkg.gallery_days ?? 0, defaultValue: 'Unlimited days' } as any),
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -164,8 +164,8 @@ function PackageCard({
|
||||
</XStack>
|
||||
<XStack flexWrap="wrap" space="$2">
|
||||
{badges.map((badge) => (
|
||||
<PillBadge key={badge} tone="muted">
|
||||
{badge}
|
||||
<PillBadge key={badge as any} tone="muted">
|
||||
{badge as any}
|
||||
</PillBadge>
|
||||
))}
|
||||
</XStack>
|
||||
|
||||
@@ -127,15 +127,17 @@ export default function WelcomeSummaryPage() {
|
||||
<SummaryRow
|
||||
label={t('summary.details.section.photosTitle', 'Photos & gallery')}
|
||||
value={t('summary.details.section.photosValue', {
|
||||
count: resolvedPackage.max_photos ?? t('summary.details.infinity', '∞'),
|
||||
days: resolvedPackage.gallery_days ?? t('summary.details.infinity', '∞'),
|
||||
})}
|
||||
count: resolvedPackage.max_photos ?? 0,
|
||||
days: resolvedPackage.gallery_days ?? 0,
|
||||
defaultValue: 'Unlimited photos for {{days}} days',
|
||||
} as any) as string}
|
||||
/>
|
||||
<SummaryRow
|
||||
label={t('summary.details.section.guestsTitle', 'Guests & team')}
|
||||
value={t('summary.details.section.guestsValue', {
|
||||
count: resolvedPackage.max_guests ?? t('summary.details.infinity', '∞'),
|
||||
})}
|
||||
count: resolvedPackage.max_guests ?? 0,
|
||||
defaultValue: 'Unlimited guests',
|
||||
} as any) as string}
|
||||
/>
|
||||
{resolvedPackage.remaining_events !== undefined && resolvedPackage.remaining_events !== null ? (
|
||||
<SummaryRow
|
||||
|
||||
@@ -509,7 +509,7 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, butt
|
||||
t={t}
|
||||
/>
|
||||
</div>,
|
||||
typeof document !== 'undefined' ? document.body : undefined
|
||||
(typeof document !== 'undefined' ? document.body : null) as any
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -90,7 +90,7 @@ export default function RouteTransition({ children }: { children?: React.ReactNo
|
||||
initial="enter"
|
||||
animate="center"
|
||||
exit="exit"
|
||||
transition={transition}
|
||||
transition={transition as any}
|
||||
style={{ willChange: 'transform, opacity' }}
|
||||
>
|
||||
{content}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import Header from '../Header';
|
||||
|
||||
vi.mock('../settings-sheet', () => ({
|
||||
@@ -87,7 +88,11 @@ vi.mock('../../i18n/useTranslation', () => ({
|
||||
|
||||
describe('Header notifications toggle', () => {
|
||||
it('closes the panel when clicking the bell again', () => {
|
||||
render(<Header eventToken="demo" title="Demo" />);
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<Header eventToken="demo" title="Demo" />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const bellButton = screen.getByLabelText('Benachrichtigungen anzeigen');
|
||||
fireEvent.click(bellButton);
|
||||
|
||||
@@ -9,7 +9,7 @@ import { registerRoute } from 'workbox-routing';
|
||||
import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from 'workbox-strategies';
|
||||
|
||||
declare const self: ServiceWorkerGlobalScope & {
|
||||
__WB_MANIFEST: Array<import('workbox-precaching').ManifestEntry>;
|
||||
__WB_MANIFEST: Array<any>;
|
||||
};
|
||||
|
||||
clientsClaim();
|
||||
@@ -97,7 +97,7 @@ self.addEventListener('message', (event) => {
|
||||
}
|
||||
});
|
||||
|
||||
self.addEventListener('sync', (event) => {
|
||||
self.addEventListener('sync', (event: any) => {
|
||||
if (event.tag === 'upload-queue') {
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
|
||||
@@ -11,20 +11,20 @@ describe('BadgesGrid', () => {
|
||||
<BadgesGrid
|
||||
badges={[
|
||||
{
|
||||
id: 1,
|
||||
title: 'First Badge',
|
||||
description: 'Earned badge',
|
||||
id: '1',
|
||||
title: 'First Upload',
|
||||
description: 'Uploaded your first photo',
|
||||
earned: true,
|
||||
progress: 1,
|
||||
target: 1,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Second Badge',
|
||||
description: 'Pending badge',
|
||||
id: '2',
|
||||
title: 'Social Star',
|
||||
description: 'Received 10 likes',
|
||||
earned: false,
|
||||
progress: 0,
|
||||
target: 5,
|
||||
progress: 3,
|
||||
target: 10,
|
||||
},
|
||||
]}
|
||||
t={t}
|
||||
|
||||
@@ -81,6 +81,7 @@
|
||||
"esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */,
|
||||
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
|
||||
"forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */,
|
||||
"types": ["vitest/globals", "@testing-library/jest-dom"],
|
||||
|
||||
/* Type Checking */
|
||||
"strict": true /* Enable all strict type-checking options. */,
|
||||
@@ -129,6 +130,7 @@
|
||||
"resources/js/guest/**/*.d.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"resources/js/actions/**"
|
||||
"resources/js/actions/**",
|
||||
"resources/js/routes/**"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user