Services/Helpers sind entfernt, API/Frontend angepasst, Tests auf Paddle umgestellt. Außerdem wurde die CSP gestrafft und Stripe‑Texte in den Abandoned‑Checkout‑Mails ersetzt.
387 lines
14 KiB
TypeScript
387 lines
14 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 { createEvent, getEvent, updateEvent, getEventTypes, TenantEvent, TenantEventType } from '../api';
|
|
import { resolveEventSlugAfterUpdate } from './eventFormNavigation';
|
|
import { adminPath } from '../constants';
|
|
import { isAuthError } from '../auth/tokens';
|
|
import { getApiValidationMessage } from '../lib/apiError';
|
|
import toast from 'react-hot-toast';
|
|
|
|
type FormState = {
|
|
name: string;
|
|
date: string;
|
|
eventTypeId: number | null;
|
|
description: string;
|
|
location: string;
|
|
published: boolean;
|
|
autoApproveUploads: boolean;
|
|
tasksEnabled: boolean;
|
|
};
|
|
|
|
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 [form, setForm] = React.useState<FormState>({
|
|
name: '',
|
|
date: '',
|
|
eventTypeId: null,
|
|
description: '',
|
|
location: '',
|
|
published: false,
|
|
autoApproveUploads: true,
|
|
tasksEnabled: true,
|
|
});
|
|
const [eventTypes, setEventTypes] = React.useState<TenantEventType[]>([]);
|
|
const [typesLoading, setTypesLoading] = React.useState(false);
|
|
const [loading, setLoading] = React.useState(isEdit);
|
|
const [saving, setSaving] = React.useState(false);
|
|
const [error, setError] = React.useState<string | null>(null);
|
|
|
|
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',
|
|
});
|
|
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);
|
|
// Default to first type if none set
|
|
if (!form.eventTypeId && types.length) {
|
|
setForm((prev) => ({ ...prev, eventTypeId: types[0].id }));
|
|
}
|
|
} catch {
|
|
// silently ignore; fallback to null
|
|
} finally {
|
|
setTypesLoading(false);
|
|
}
|
|
})();
|
|
}, []);
|
|
|
|
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') as const,
|
|
settings: {
|
|
location: form.location,
|
|
guest_upload_visibility: form.autoApproveUploads ? 'immediate' : 'review',
|
|
engagement_mode: form.tasksEnabled ? 'tasks' : 'photo_only',
|
|
},
|
|
};
|
|
const { event } = await createEvent(payload as any);
|
|
navigate(adminPath(`/mobile/events/${event.slug}`));
|
|
}
|
|
} catch (err) {
|
|
if (!isAuthError(err)) {
|
|
const message = getApiValidationMessage(err, t('eventForm.errors.saveFailed', 'Event could not be saved.'));
|
|
setError(message);
|
|
toast.error(message);
|
|
}
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<MobileShell
|
|
activeTab="home"
|
|
title={isEdit ? t('eventForm.titles.edit', 'Edit event') : t('eventForm.titles.create', 'Create event')}
|
|
onBack={() => navigate(-1)}
|
|
>
|
|
{error ? (
|
|
<MobileCard>
|
|
<Text fontWeight="700" color="#b91c1c">
|
|
{error}
|
|
</Text>
|
|
</MobileCard>
|
|
) : null}
|
|
|
|
<MobileCard space="$3">
|
|
<Field label={t('eventForm.fields.name.label', 'Event name')}>
|
|
<input
|
|
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')}
|
|
style={inputStyle}
|
|
/>
|
|
</Field>
|
|
|
|
<Field label={t('eventForm.fields.date.label', 'Date & time')}>
|
|
<XStack alignItems="center" space="$2">
|
|
<input
|
|
type="datetime-local"
|
|
value={form.date}
|
|
onChange={(e) => setForm((prev) => ({ ...prev, date: e.target.value }))}
|
|
style={{ ...inputStyle, flex: 1 }}
|
|
/>
|
|
<CalendarDays size={16} color="#9ca3af" />
|
|
</XStack>
|
|
</Field>
|
|
|
|
<Field label={t('eventForm.fields.type.label', 'Event type')}>
|
|
{typesLoading ? (
|
|
<Text fontSize="$sm" color="#6b7280">{t('eventForm.fields.type.loading', 'Loading event types…')}</Text>
|
|
) : eventTypes.length === 0 ? (
|
|
<Text fontSize="$sm" color="#6b7280">{t('eventForm.fields.type.empty', 'No event types available yet. Please add one in the admin area.')}</Text>
|
|
) : (
|
|
<select
|
|
value={form.eventTypeId ?? ''}
|
|
onChange={(e) => setForm((prev) => ({ ...prev, eventTypeId: Number(e.target.value) }))}
|
|
style={{ ...inputStyle, height: 44 }}
|
|
>
|
|
<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>
|
|
))}
|
|
</select>
|
|
)}
|
|
</Field>
|
|
|
|
<Field label={t('eventForm.fields.description.label', 'Optional details')}>
|
|
<textarea
|
|
value={form.description}
|
|
onChange={(e) => setForm((prev) => ({ ...prev, description: e.target.value }))}
|
|
placeholder={t('eventForm.fields.description.placeholder', 'Description')}
|
|
style={{ ...inputStyle, minHeight: 96 }}
|
|
/>
|
|
</Field>
|
|
|
|
<Field label={t('eventForm.fields.location.label', 'Location')}>
|
|
<XStack alignItems="center" space="$2">
|
|
<input
|
|
type="text"
|
|
value={form.location}
|
|
onChange={(e) => setForm((prev) => ({ ...prev, location: e.target.value }))}
|
|
placeholder={t('eventForm.fields.location.placeholder', 'Location')}
|
|
style={{ ...inputStyle, flex: 1 }}
|
|
/>
|
|
<MapPin size={16} color="#9ca3af" />
|
|
</XStack>
|
|
</Field>
|
|
|
|
<Field 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="#111827">
|
|
{form.published ? t('common:states.enabled', 'Enabled') : t('common:states.disabled', 'Disabled')}
|
|
</Text>
|
|
</XStack>
|
|
<Text fontSize="$xs" color="#6b7280">{t('eventForm.fields.publish.help', 'Enable if guests should see the event right away. You can change the status later.')}</Text>
|
|
</Field>
|
|
|
|
<Field 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="#111827">
|
|
{form.tasksEnabled
|
|
? t('common:states.enabled', 'Enabled')
|
|
: t('common:states.disabled', 'Disabled')}
|
|
</Text>
|
|
</XStack>
|
|
<Text fontSize="$xs" color="#6b7280">
|
|
{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>
|
|
</Field>
|
|
|
|
<Field 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="#111827">
|
|
{form.autoApproveUploads
|
|
? t('common:states.enabled', 'Enabled')
|
|
: t('common:states.disabled', 'Disabled')}
|
|
</Text>
|
|
</XStack>
|
|
<Text fontSize="$xs" color="#6b7280">
|
|
{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>
|
|
</Field>
|
|
</MobileCard>
|
|
|
|
<YStack space="$2">
|
|
{!isEdit ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => navigate(-1)}
|
|
style={{
|
|
...inputStyle,
|
|
height: 48,
|
|
borderRadius: 12,
|
|
border: '1px solid #e5e7eb',
|
|
background: '#f1f5f9',
|
|
fontWeight: 700,
|
|
}}
|
|
>
|
|
{t('eventForm.actions.saveDraft', 'Save as draft')}
|
|
</button>
|
|
) : 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>
|
|
</MobileShell>
|
|
);
|
|
}
|
|
|
|
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
|
return (
|
|
<YStack space="$1.5">
|
|
<Text fontSize="$sm" fontWeight="800" color="#111827">
|
|
{label}
|
|
</Text>
|
|
{children}
|
|
</YStack>
|
|
);
|
|
}
|
|
|
|
const inputStyle: React.CSSProperties = {
|
|
width: '100%',
|
|
height: 44,
|
|
borderRadius: 10,
|
|
border: '1px solid #e5e7eb',
|
|
padding: '0 12px',
|
|
fontSize: 14,
|
|
background: 'white',
|
|
};
|
|
|
|
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 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 '';
|
|
}
|