631 lines
23 KiB
TypeScript
631 lines
23 KiB
TypeScript
import React from 'react';
|
||
import { useNavigate, useParams } from 'react-router-dom';
|
||
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 { Switch } from '@tamagui/switch';
|
||
import { MobileShell } from './components/MobileShell';
|
||
import { MobileCard, CTAButton } from './components/Primitives';
|
||
import { MobileDateTimeInput, MobileField, MobileInput, MobileSelect, MobileTextArea } from './components/FormControls';
|
||
import { LegalConsentSheet } from './components/LegalConsentSheet';
|
||
import {
|
||
createEvent,
|
||
getEvent,
|
||
updateEvent,
|
||
getEventTypes,
|
||
getPackages,
|
||
getTenantPackagesOverview,
|
||
Package,
|
||
TenantEvent,
|
||
TenantEventType,
|
||
TenantPackageSummary,
|
||
trackOnboarding,
|
||
} from '../api';
|
||
import { resolveEventSlugAfterUpdate } from './eventFormNavigation';
|
||
import { adminPath } from '../constants';
|
||
import { isAuthError } from '../auth/tokens';
|
||
import { getApiErrorMessage, getApiValidationMessage, isApiError } from '../lib/apiError';
|
||
import toast from 'react-hot-toast';
|
||
import { useBackNavigation } from './hooks/useBackNavigation';
|
||
import { useAdminTheme } from './theme';
|
||
import { withAlpha } from './components/colors';
|
||
import { useAuth } from '../auth/context';
|
||
|
||
type FormState = {
|
||
name: string;
|
||
date: string;
|
||
eventTypeId: number | null;
|
||
description: string;
|
||
location: string;
|
||
published: boolean;
|
||
autoApproveUploads: boolean;
|
||
tasksEnabled: boolean;
|
||
packageId: number | null;
|
||
servicePackageSlug: string | null;
|
||
};
|
||
|
||
export default function MobileEventFormPage() {
|
||
const { slug: slugParam } = useParams<{ slug?: string }>();
|
||
const slug = slugParam ?? null;
|
||
const isEdit = Boolean(slug);
|
||
const navigate = useNavigate();
|
||
const { t } = useTranslation(['management', 'common']);
|
||
const { user } = useAuth();
|
||
const { text, muted, subtle, danger, border, surface, primary } = useAdminTheme();
|
||
const isSuperAdmin = user?.role === 'super_admin' || user?.role === 'superadmin';
|
||
|
||
const [form, setForm] = React.useState<FormState>({
|
||
name: '',
|
||
date: '',
|
||
eventTypeId: null,
|
||
description: '',
|
||
location: '',
|
||
published: false,
|
||
autoApproveUploads: true,
|
||
tasksEnabled: true,
|
||
packageId: null,
|
||
servicePackageSlug: null,
|
||
});
|
||
const [eventTypes, setEventTypes] = React.useState<TenantEventType[]>([]);
|
||
const [typesLoading, setTypesLoading] = React.useState(false);
|
||
const [packages, setPackages] = React.useState<Package[]>([]);
|
||
const [packagesLoading, setPackagesLoading] = React.useState(false);
|
||
const [kontingentOptions, setKontingentOptions] = React.useState<Array<{ slug: string; remaining: number }>>([]);
|
||
const [kontingentLoading, setKontingentLoading] = React.useState(false);
|
||
const [loading, setLoading] = React.useState(isEdit);
|
||
const [saving, setSaving] = React.useState(false);
|
||
const [consentOpen, setConsentOpen] = React.useState(false);
|
||
const [consentBusy, setConsentBusy] = React.useState(false);
|
||
const [pendingPayload, setPendingPayload] = React.useState<Parameters<typeof createEvent>[0] | null>(null);
|
||
const [error, setError] = React.useState<string | null>(null);
|
||
const back = useBackNavigation(slug ? adminPath(`/mobile/events/${slug}`) : adminPath('/mobile/events'));
|
||
|
||
React.useEffect(() => {
|
||
if (!slug) return;
|
||
(async () => {
|
||
setLoading(true);
|
||
try {
|
||
const data = await getEvent(slug);
|
||
setForm({
|
||
name: renderName(data.name),
|
||
date: toDateTimeLocal(data.event_date),
|
||
eventTypeId: data.event_type_id ?? data.event_type?.id ?? null,
|
||
description: typeof data.description === 'string' ? data.description : '',
|
||
location: resolveLocation(data),
|
||
published: data.status === 'published',
|
||
autoApproveUploads:
|
||
(data.settings?.guest_upload_visibility as string | undefined) === 'immediate',
|
||
tasksEnabled:
|
||
(data.settings?.engagement_mode as string | undefined) !== 'photo_only' &&
|
||
(data.engagement_mode as string | undefined) !== 'photo_only',
|
||
packageId: null,
|
||
servicePackageSlug: null,
|
||
});
|
||
setError(null);
|
||
} catch (err) {
|
||
if (!isAuthError(err)) {
|
||
setError(getApiErrorMessage(err, t('events.errors.loadFailed', 'Event konnte nicht geladen werden.')));
|
||
}
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
})();
|
||
}, [slug, t, isEdit]);
|
||
|
||
React.useEffect(() => {
|
||
(async () => {
|
||
setTypesLoading(true);
|
||
try {
|
||
const types = await getEventTypes();
|
||
setEventTypes(types);
|
||
const preferredType = types.find((type) => type.slug === 'wedding') ?? types[0] ?? null;
|
||
if (preferredType) {
|
||
setForm((prev) => (prev.eventTypeId ? prev : { ...prev, eventTypeId: preferredType.id }));
|
||
}
|
||
} catch {
|
||
// silently ignore; fallback to null
|
||
} finally {
|
||
setTypesLoading(false);
|
||
}
|
||
})();
|
||
}, []);
|
||
|
||
React.useEffect(() => {
|
||
if (!isSuperAdmin || isEdit) {
|
||
return;
|
||
}
|
||
|
||
(async () => {
|
||
setPackagesLoading(true);
|
||
try {
|
||
const data = await getPackages('endcustomer');
|
||
setPackages(data);
|
||
setForm((prev) => {
|
||
if (prev.packageId) {
|
||
return prev;
|
||
}
|
||
const preferred = data.find((pkg) => pkg.id === 3) ?? data[0] ?? null;
|
||
return { ...prev, packageId: preferred?.id ?? null };
|
||
});
|
||
} catch {
|
||
setPackages([]);
|
||
} finally {
|
||
setPackagesLoading(false);
|
||
}
|
||
})();
|
||
}, [isSuperAdmin, isEdit]);
|
||
|
||
React.useEffect(() => {
|
||
if (isEdit) {
|
||
return;
|
||
}
|
||
|
||
(async () => {
|
||
setKontingentLoading(true);
|
||
try {
|
||
const overview = await getTenantPackagesOverview();
|
||
const packages = overview.packages ?? [];
|
||
|
||
const active = packages.filter((pkg) => pkg.active && pkg.package_type === 'reseller');
|
||
const totals = new Map<string, number>();
|
||
|
||
active.forEach((pkg: TenantPackageSummary) => {
|
||
const slugValue = pkg.included_package_slug ?? 'standard';
|
||
if (!slugValue) {
|
||
return;
|
||
}
|
||
|
||
const remaining = Number.isFinite(pkg.remaining_events as number) ? Number(pkg.remaining_events) : 0;
|
||
if (remaining <= 0) {
|
||
return;
|
||
}
|
||
|
||
totals.set(slugValue, (totals.get(slugValue) ?? 0) + remaining);
|
||
});
|
||
|
||
const options = Array.from(totals.entries())
|
||
.map(([slugValue, remaining]) => ({ slug: slugValue, remaining }))
|
||
.sort((a, b) => a.slug.localeCompare(b.slug));
|
||
|
||
setKontingentOptions(options);
|
||
setForm((prev) => {
|
||
if (prev.servicePackageSlug || options.length === 0) {
|
||
return prev;
|
||
}
|
||
|
||
if (options.length === 1) {
|
||
return { ...prev, servicePackageSlug: options[0].slug };
|
||
}
|
||
|
||
const standard = options.find((row) => row.slug === 'standard');
|
||
return { ...prev, servicePackageSlug: standard?.slug ?? options[0].slug };
|
||
});
|
||
} catch {
|
||
setKontingentOptions([]);
|
||
} finally {
|
||
setKontingentLoading(false);
|
||
}
|
||
})();
|
||
}, [isEdit]);
|
||
|
||
const resolveServiceTierLabel = React.useCallback((slugValue: string) => {
|
||
if (slugValue === 'starter') {
|
||
return 'Starter';
|
||
}
|
||
|
||
if (slugValue === 'standard') {
|
||
return 'Standard';
|
||
}
|
||
|
||
if (slugValue === 'pro') {
|
||
return 'Premium';
|
||
}
|
||
|
||
return slugValue;
|
||
}, []);
|
||
|
||
async function handleSubmit() {
|
||
setSaving(true);
|
||
setError(null);
|
||
try {
|
||
if (isEdit && slug) {
|
||
const updated = await updateEvent(slug, {
|
||
name: form.name,
|
||
event_date: form.date || undefined,
|
||
event_type_id: form.eventTypeId ?? undefined,
|
||
status: form.published ? 'published' : 'draft',
|
||
settings: {
|
||
location: form.location,
|
||
guest_upload_visibility: form.autoApproveUploads ? 'immediate' : 'review',
|
||
engagement_mode: form.tasksEnabled ? 'tasks' : 'photo_only',
|
||
},
|
||
});
|
||
const nextSlug = resolveEventSlugAfterUpdate(slug, updated);
|
||
navigate(adminPath(`/mobile/events/${nextSlug}`));
|
||
} else {
|
||
const payload = {
|
||
name: form.name || t('eventForm.fields.name.fallback', 'Event'),
|
||
slug: `${Date.now()}`,
|
||
event_type_id: form.eventTypeId ?? undefined,
|
||
event_date: form.date || undefined,
|
||
status: form.published ? 'published' : 'draft',
|
||
package_id: isSuperAdmin ? form.packageId ?? undefined : undefined,
|
||
service_package_slug: form.servicePackageSlug ?? undefined,
|
||
settings: {
|
||
location: form.location,
|
||
guest_upload_visibility: form.autoApproveUploads ? 'immediate' : 'review',
|
||
engagement_mode: form.tasksEnabled ? 'tasks' : 'photo_only',
|
||
},
|
||
} as Parameters<typeof createEvent>[0];
|
||
const { event } = await createEvent(payload);
|
||
void trackOnboarding('event_created', { event_id: event.id });
|
||
navigate(adminPath(`/mobile/events/${event.slug}`));
|
||
}
|
||
} catch (err) {
|
||
if (isAuthError(err)) {
|
||
return;
|
||
}
|
||
|
||
if (!isEdit && isWaiverRequiredError(err)) {
|
||
const payload = {
|
||
name: form.name || t('eventForm.fields.name.fallback', 'Event'),
|
||
slug: `${Date.now()}`,
|
||
event_type_id: form.eventTypeId ?? undefined,
|
||
event_date: form.date || undefined,
|
||
status: form.published ? 'published' : 'draft',
|
||
package_id: isSuperAdmin ? form.packageId ?? undefined : undefined,
|
||
service_package_slug: form.servicePackageSlug ?? undefined,
|
||
settings: {
|
||
location: form.location,
|
||
guest_upload_visibility: form.autoApproveUploads ? 'immediate' : 'review',
|
||
engagement_mode: form.tasksEnabled ? 'tasks' : 'photo_only',
|
||
},
|
||
} as Parameters<typeof createEvent>[0];
|
||
setPendingPayload(payload);
|
||
setConsentOpen(true);
|
||
return;
|
||
}
|
||
|
||
const message = getApiValidationMessage(err, t('eventForm.errors.saveFailed', 'Event could not be saved.'));
|
||
setError(message);
|
||
toast.error(message);
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
}
|
||
|
||
async function handleConsentConfirm(consents: { acceptedTerms: boolean; acceptedWaiver: boolean }) {
|
||
if (!pendingPayload) {
|
||
setConsentOpen(false);
|
||
return;
|
||
}
|
||
|
||
setConsentBusy(true);
|
||
try {
|
||
const { event } = await createEvent({
|
||
...pendingPayload,
|
||
accepted_waiver: consents.acceptedWaiver,
|
||
});
|
||
void trackOnboarding('event_created', { event_id: event.id });
|
||
navigate(adminPath(`/mobile/events/${event.slug}`));
|
||
setConsentOpen(false);
|
||
setPendingPayload(null);
|
||
} catch (err) {
|
||
if (!isAuthError(err)) {
|
||
const message = getApiValidationMessage(err, t('eventForm.errors.saveFailed', 'Event could not be saved.'));
|
||
setError(message);
|
||
toast.error(message);
|
||
}
|
||
} finally {
|
||
setConsentBusy(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<MobileShell
|
||
activeTab="home"
|
||
title={isEdit ? t('eventForm.titles.edit', 'Edit event') : t('eventForm.titles.create', 'Create event')}
|
||
onBack={back}
|
||
>
|
||
{error ? (
|
||
<MobileCard>
|
||
<Text fontWeight="700" color={danger}>
|
||
{error}
|
||
</Text>
|
||
</MobileCard>
|
||
) : null}
|
||
|
||
<MobileCard space="$3">
|
||
<MobileField label={t('eventForm.fields.name.label', 'Event name')}>
|
||
<MobileInput
|
||
type="text"
|
||
value={form.name}
|
||
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
|
||
placeholder={t('eventForm.fields.name.placeholder', 'e.g. Summer Party 2025')}
|
||
/>
|
||
</MobileField>
|
||
|
||
{isSuperAdmin && !isEdit ? (
|
||
<MobileField label={t('eventForm.fields.package.label', 'Package')}>
|
||
{packagesLoading ? (
|
||
<Text fontSize="$sm" color={muted}>{t('eventForm.fields.package.loading', 'Loading packages…')}</Text>
|
||
) : packages.length === 0 ? (
|
||
<Text fontSize="$sm" color={muted}>{t('eventForm.fields.package.empty', 'No packages available yet.')}</Text>
|
||
) : (
|
||
<MobileSelect
|
||
value={form.packageId ?? ''}
|
||
onChange={(e) => setForm((prev) => ({ ...prev, packageId: Number(e.target.value) }))}
|
||
>
|
||
<option value="">{t('eventForm.fields.package.placeholder', 'Select package')}</option>
|
||
{packages.map((pkg) => (
|
||
<option key={pkg.id} value={pkg.id}>
|
||
{pkg.name || `#${pkg.id}`}
|
||
</option>
|
||
))}
|
||
</MobileSelect>
|
||
)}
|
||
<Text fontSize="$xs" color={muted}>
|
||
{t('eventForm.fields.package.help', 'This controls the event’s premium limits.')}
|
||
</Text>
|
||
</MobileField>
|
||
) : null}
|
||
|
||
{!isEdit && (kontingentLoading || kontingentOptions.length > 0) ? (
|
||
<MobileField label={t('eventForm.fields.servicePackage.label', 'Event-Level (Event-Kontingent)')}>
|
||
{kontingentLoading ? (
|
||
<Text fontSize="$sm" color={muted}>
|
||
{t('eventForm.fields.servicePackage.loading', 'Loading Event-Kontingente…')}
|
||
</Text>
|
||
) : (
|
||
<MobileSelect
|
||
value={form.servicePackageSlug ?? ''}
|
||
onChange={(e) => setForm((prev) => ({ ...prev, servicePackageSlug: String(e.target.value) }))}
|
||
>
|
||
<option value="">{t('eventForm.fields.servicePackage.placeholder', 'Select tier')}</option>
|
||
{kontingentOptions.map((opt) => (
|
||
<option key={opt.slug} value={opt.slug}>
|
||
{resolveServiceTierLabel(opt.slug)} · {opt.remaining} {t('eventForm.fields.servicePackage.events', 'Events')}
|
||
</option>
|
||
))}
|
||
</MobileSelect>
|
||
)}
|
||
<Text fontSize="$xs" color={muted}>
|
||
{t(
|
||
'eventForm.fields.servicePackage.help',
|
||
'Wählt das Event-Level. Pro Event wird 1 aus dem passenden Event-Kontingent verbraucht.',
|
||
)}
|
||
</Text>
|
||
</MobileField>
|
||
) : null}
|
||
|
||
<MobileField label={t('eventForm.fields.date.label', 'Date & time')}>
|
||
<XStack alignItems="center" space="$2">
|
||
<MobileDateTimeInput
|
||
value={form.date}
|
||
onChange={(event) => setForm((prev) => ({ ...prev, date: event.target.value }))}
|
||
style={{ flex: 1 }}
|
||
/>
|
||
<CalendarDays size={16} color={subtle} />
|
||
</XStack>
|
||
</MobileField>
|
||
|
||
<MobileField label={t('eventForm.fields.type.label', 'Event type')}>
|
||
{typesLoading ? (
|
||
<Text fontSize="$sm" color={muted}>{t('eventForm.fields.type.loading', 'Loading event types…')}</Text>
|
||
) : eventTypes.length === 0 ? (
|
||
<Text fontSize="$sm" color={muted}>{t('eventForm.fields.type.empty', 'No event types available yet. Please add one in the admin area.')}</Text>
|
||
) : (
|
||
<MobileSelect
|
||
value={form.eventTypeId ?? ''}
|
||
onChange={(e) => setForm((prev) => ({ ...prev, eventTypeId: Number(e.target.value) }))}
|
||
>
|
||
<option value="">{t('eventForm.fields.type.placeholder', 'Select event type')}</option>
|
||
{eventTypes.map((type) => (
|
||
<option key={type.id} value={type.id}>
|
||
{renderName(type.name as any) || type.slug}
|
||
</option>
|
||
))}
|
||
</MobileSelect>
|
||
)}
|
||
</MobileField>
|
||
|
||
<MobileField label={t('eventForm.fields.description.label', 'Optional details')}>
|
||
<MobileTextArea
|
||
value={form.description}
|
||
onChange={(e) => setForm((prev) => ({ ...prev, description: e.target.value }))}
|
||
placeholder={t('eventForm.fields.description.placeholder', 'Description')}
|
||
/>
|
||
</MobileField>
|
||
|
||
<MobileField label={t('eventForm.fields.location.label', 'Location')}>
|
||
<XStack alignItems="center" space="$2">
|
||
<MobileInput
|
||
type="text"
|
||
value={form.location}
|
||
onChange={(e) => setForm((prev) => ({ ...prev, location: e.target.value }))}
|
||
placeholder={t('eventForm.fields.location.placeholder', 'Location')}
|
||
style={{ flex: 1 }}
|
||
/>
|
||
<MapPin size={16} color={subtle} />
|
||
</XStack>
|
||
</MobileField>
|
||
|
||
<MobileField label={t('eventForm.fields.publish.label', 'Publish immediately')}>
|
||
<XStack alignItems="center" space="$2">
|
||
<Switch
|
||
checked={form.published}
|
||
onCheckedChange={(checked) =>
|
||
setForm((prev) => ({ ...prev, published: Boolean(checked) }))
|
||
}
|
||
size="$3"
|
||
aria-label={t('eventForm.fields.publish.label', 'Publish immediately')}
|
||
>
|
||
<Switch.Thumb />
|
||
</Switch>
|
||
<Text fontSize="$sm" color={text}>
|
||
{form.published ? t('common:states.enabled', 'Enabled') : t('common:states.disabled', 'Disabled')}
|
||
</Text>
|
||
</XStack>
|
||
<Text fontSize="$xs" color={muted}>{t('eventForm.fields.publish.help', 'Enable if guests should see the event right away. You can change the status later.')}</Text>
|
||
</MobileField>
|
||
|
||
<MobileField label={t('eventForm.fields.tasksMode.label', 'Tasks & challenges')}>
|
||
<XStack alignItems="center" space="$2">
|
||
<Switch
|
||
checked={form.tasksEnabled}
|
||
onCheckedChange={(checked) =>
|
||
setForm((prev) => ({ ...prev, tasksEnabled: Boolean(checked) }))
|
||
}
|
||
size="$3"
|
||
aria-label={t('eventForm.fields.tasksMode.label', 'Tasks & challenges')}
|
||
>
|
||
<Switch.Thumb />
|
||
</Switch>
|
||
<Text fontSize="$sm" color={text}>
|
||
{form.tasksEnabled
|
||
? t('common:states.enabled', 'Enabled')
|
||
: t('common:states.disabled', 'Disabled')}
|
||
</Text>
|
||
</XStack>
|
||
<Text fontSize="$xs" color={muted}>
|
||
{form.tasksEnabled
|
||
? t(
|
||
'eventForm.fields.tasksMode.helpOn',
|
||
'Guests can see tasks, challenges and achievements.',
|
||
)
|
||
: t(
|
||
'eventForm.fields.tasksMode.helpOff',
|
||
'Task mode is off: guests only see the photo feed.',
|
||
)}
|
||
</Text>
|
||
</MobileField>
|
||
|
||
<MobileField label={t('eventForm.fields.uploadVisibility.label', 'Uploads visible immediately')}>
|
||
<XStack alignItems="center" space="$2">
|
||
<Switch
|
||
checked={form.autoApproveUploads}
|
||
onCheckedChange={(checked) =>
|
||
setForm((prev) => ({ ...prev, autoApproveUploads: Boolean(checked) }))
|
||
}
|
||
size="$3"
|
||
aria-label={t('eventForm.fields.uploadVisibility.label', 'Uploads visible immediately')}
|
||
>
|
||
<Switch.Thumb />
|
||
</Switch>
|
||
<Text fontSize="$sm" color={text}>
|
||
{form.autoApproveUploads
|
||
? t('common:states.enabled', 'Enabled')
|
||
: t('common:states.disabled', 'Disabled')}
|
||
</Text>
|
||
</XStack>
|
||
<Text fontSize="$xs" color={muted}>
|
||
{form.autoApproveUploads
|
||
? t(
|
||
'eventForm.fields.uploadVisibility.helpOn',
|
||
'Neue Gast-Uploads erscheinen sofort in der Galerie (Security-Scan läuft im Hintergrund).',
|
||
)
|
||
: t(
|
||
'eventForm.fields.uploadVisibility.helpOff',
|
||
'Uploads werden zunächst geprüft und erscheinen nach Freigabe.',
|
||
)}
|
||
</Text>
|
||
</MobileField>
|
||
</MobileCard>
|
||
|
||
<YStack space="$2">
|
||
{!isEdit ? (
|
||
<CTAButton
|
||
label={t('eventForm.actions.saveDraft', 'Save as draft')}
|
||
tone="ghost"
|
||
onPress={back}
|
||
/>
|
||
) : null}
|
||
<CTAButton
|
||
label={
|
||
saving
|
||
? t('eventForm.actions.saving', 'Saving…')
|
||
: isEdit
|
||
? t('eventForm.actions.update', 'Update event')
|
||
: t('eventForm.actions.create', 'Create event')
|
||
}
|
||
onPress={() => handleSubmit()}
|
||
/>
|
||
</YStack>
|
||
|
||
<LegalConsentSheet
|
||
open={consentOpen}
|
||
onClose={() => {
|
||
if (consentBusy) return;
|
||
setConsentOpen(false);
|
||
setPendingPayload(null);
|
||
}}
|
||
onConfirm={handleConsentConfirm}
|
||
busy={consentBusy}
|
||
requireTerms={false}
|
||
requireWaiver
|
||
copy={{
|
||
title: t('events.eventStartConsent.title', 'Before your first event'),
|
||
description: t(
|
||
'events.eventStartConsent.description',
|
||
'Please confirm the immediate start of the digital service before creating your first event.',
|
||
),
|
||
checkboxWaiver: t(
|
||
'events.eventStartConsent.checkboxWaiver',
|
||
'I expressly request that the digital service begins now and understand my right of withdrawal expires once the contract has been fully performed.',
|
||
),
|
||
errorWaiver: t(
|
||
'events.eventStartConsent.errorWaiver',
|
||
'Please confirm the immediate start of the digital service and the early expiry of the right of withdrawal.',
|
||
),
|
||
confirm: t('events.eventStartConsent.confirm', 'Create event'),
|
||
cancel: t('events.eventStartConsent.cancel', 'Cancel'),
|
||
}}
|
||
t={t}
|
||
/>
|
||
</MobileShell>
|
||
);
|
||
}
|
||
|
||
function renderName(name: TenantEvent['name']): string {
|
||
if (typeof name === 'string') return name;
|
||
if (name && typeof name === 'object') {
|
||
return name.de ?? name.en ?? Object.values(name)[0] ?? '';
|
||
}
|
||
return '';
|
||
}
|
||
|
||
function isWaiverRequiredError(error: unknown): boolean {
|
||
if (!isApiError(error)) {
|
||
return false;
|
||
}
|
||
|
||
const metaErrors = error.meta?.errors;
|
||
if (!metaErrors || typeof metaErrors !== 'object') {
|
||
return false;
|
||
}
|
||
|
||
return 'accepted_waiver' in metaErrors;
|
||
}
|
||
|
||
|
||
function toDateTimeLocal(value?: string | null): string {
|
||
if (!value) return '';
|
||
const parsed = new Date(value);
|
||
if (!Number.isNaN(parsed.getTime())) {
|
||
return parsed.toISOString().slice(0, 16);
|
||
}
|
||
const fallback = value.replace(' ', 'T');
|
||
return fallback.length >= 16 ? fallback.slice(0, 16) : '';
|
||
}
|
||
|
||
function resolveLocation(event: TenantEvent): string {
|
||
const settings = (event.settings ?? {}) as Record<string, unknown>;
|
||
const candidate =
|
||
(settings.location as string | undefined) ??
|
||
(settings.address as string | undefined) ??
|
||
(settings.city as string | undefined);
|
||
if (candidate && candidate.trim()) return candidate;
|
||
return '';
|
||
}
|