Files
fotospiel-app/resources/js/admin/mobile/EventFormPage.tsx
Codex Agent 918bff08aa
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
Fix auth translations and admin PWA UI
2026-01-16 12:14:53 +01:00

631 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 events 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 '';
}