Files
fotospiel-app/resources/js/admin/mobile/EventGuestNotificationsPage.tsx

391 lines
15 KiB
TypeScript

import React from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite';
import { RefreshCcw, Users, User } from 'lucide-react';
import toast from 'react-hot-toast';
import { MobileShell, HeaderActionButton } from './components/MobileShell';
import { MobileCard, CTAButton, PillBadge, SkeletonCard } from './components/Primitives';
import { MobileField, MobileInput, MobileSelect, MobileTextArea } from './components/FormControls';
import { useEventContext } from '../context/EventContext';
import {
GuestNotificationSummary,
SendGuestNotificationPayload,
getEvents,
listGuestNotifications,
sendGuestNotification,
TenantEvent,
} from '../api';
import { isAuthError } from '../auth/tokens';
import { getApiErrorMessage } from '../lib/apiError';
import { adminPath } from '../constants';
import { formatGuestMessageDate } from './guestMessages';
import { useBackNavigation } from './hooks/useBackNavigation';
import { useAdminTheme } from './theme';
type FormState = {
title: string;
message: string;
audience: 'all' | 'guest';
guest_identifier: string;
cta_label: string;
cta_url: string;
expires_in_minutes: string;
priority: string;
};
export default function MobileEventGuestNotificationsPage() {
const { slug: slugParam } = useParams<{ slug?: string }>();
const navigate = useNavigate();
const { t, i18n } = useTranslation('management');
const { textStrong, text, muted, border, danger } = useAdminTheme();
const { activeEvent, selectEvent } = useEventContext();
const slug = slugParam ?? activeEvent?.slug ?? null;
const [history, setHistory] = React.useState<GuestNotificationSummary[]>([]);
const [loading, setLoading] = React.useState(true);
const [sending, setSending] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const [fallbackAttempted, setFallbackAttempted] = React.useState(false);
const formRef = React.useRef<HTMLDivElement | null>(null);
const back = useBackNavigation(slug ? adminPath(`/mobile/events/${slug}`) : adminPath('/mobile/events'));
const [form, setForm] = React.useState<FormState>({
title: '',
message: '',
audience: 'all',
guest_identifier: '',
cta_label: '',
cta_url: '',
expires_in_minutes: '',
priority: '1',
});
React.useEffect(() => {
if (slugParam && activeEvent?.slug !== slugParam) {
selectEvent(slugParam);
}
}, [slugParam, activeEvent?.slug, selectEvent]);
const loadHistory = React.useCallback(async () => {
if (!slug) {
if (!fallbackAttempted) {
setFallbackAttempted(true);
try {
const events = await getEvents({ force: true });
const first = events[0] as TenantEvent | undefined;
if (first?.slug) {
selectEvent(first.slug);
navigate(adminPath(`/mobile/events/${first.slug}/guest-notifications`), { replace: true });
}
} catch {
// ignore
}
}
setLoading(false);
setError(t('events.errors.missingSlug', 'No event selected.'));
return;
}
setLoading(true);
setError(null);
try {
const notifications = await listGuestNotifications(slug);
setHistory(notifications);
} catch (err) {
if (!isAuthError(err)) {
const message = getApiErrorMessage(err, t('guestMessages.errorLoad', 'Messages could not be loaded.'));
setError(message);
toast.error(message);
}
} finally {
setLoading(false);
}
}, [slug, t, fallbackAttempted, selectEvent, navigate]);
React.useEffect(() => {
void loadHistory();
}, [loadHistory]);
const canSend =
form.title.trim().length > 0 &&
form.message.trim().length > 0 &&
(form.audience === 'all' || form.guest_identifier.trim().length > 0);
async function handleSend() {
if (!slug || sending) return;
if (!canSend) {
const message = t('guestMessages.form.validation', 'Add a title, message, and target guest when needed.');
setError(message);
toast.error(message);
return;
}
const ctaLabel = form.cta_label.trim();
const ctaUrl = form.cta_url.trim();
if ((ctaLabel && !ctaUrl) || (!ctaLabel && ctaUrl)) {
const message = t('guestMessages.form.ctaError', 'CTA label and link are required together.');
setError(message);
toast.error(message);
return;
}
const payload: SendGuestNotificationPayload = {
title: form.title.trim(),
message: form.message.trim(),
audience: form.audience,
};
if (form.audience === 'guest' && form.guest_identifier.trim()) {
payload.guest_identifier = form.guest_identifier.trim();
}
if (ctaLabel && ctaUrl) {
payload.cta = { label: ctaLabel, url: ctaUrl };
}
if (form.expires_in_minutes.trim()) {
payload.expires_in_minutes = Number(form.expires_in_minutes);
}
if (form.priority.trim()) {
payload.priority = Number(form.priority);
}
setSending(true);
setError(null);
try {
const created = await sendGuestNotification(slug, payload);
setHistory((prev) => [created, ...prev]);
setForm((prev) => ({
...prev,
title: '',
message: '',
guest_identifier: '',
cta_label: '',
cta_url: '',
expires_in_minutes: '',
}));
toast.success(t('guestMessages.sendSuccess', 'Notification sent to guests.'));
} catch (err) {
if (!isAuthError(err)) {
const message = getApiErrorMessage(err, t('guestMessages.errorSend', 'Message could not be sent.'));
setError(message);
toast.error(message);
}
} finally {
setSending(false);
}
}
const locale = i18n.language?.startsWith('en') ? 'en-GB' : 'de-DE';
const mutedText = muted;
return (
<MobileShell
activeTab="home"
title={t('guestMessages.title', 'Guest messages')}
subtitle={t('guestMessages.subtitle', 'Send push messages to guests')}
onBack={back}
headerActions={
<HeaderActionButton onPress={() => loadHistory()} ariaLabel={t('common.refresh', 'Refresh')}>
<RefreshCcw size={18} color={textStrong} />
</HeaderActionButton>
}
>
{error ? (
<MobileCard>
<Text fontWeight="700" color={danger}>
{error}
</Text>
</MobileCard>
) : null}
<div ref={formRef}>
<MobileCard space="$3">
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('guestMessages.composeTitle', 'Send a message')}
</Text>
<YStack space="$2">
<MobileField label={t('guestMessages.form.title', 'Title')}>
<MobileInput
type="text"
value={form.title}
onChange={(e) => setForm((prev) => ({ ...prev, title: e.target.value }))}
placeholder={t('guestMessages.form.titlePlaceholder', 'Gallery reminder, upload nudge, ...')}
/>
</MobileField>
<MobileField label={t('guestMessages.form.message', 'Message')}>
<MobileTextArea
value={form.message}
onChange={(e) => setForm((prev) => ({ ...prev, message: e.target.value }))}
placeholder={t('guestMessages.form.messagePlaceholder', 'Write a short note for your guests.')}
/>
</MobileField>
<MobileField label={t('guestMessages.form.audience', 'Audience')}>
<MobileSelect
value={form.audience}
onChange={(e) => setForm((prev) => ({ ...prev, audience: e.target.value as FormState['audience'] }))}
>
<option value="all">{t('guestMessages.form.audienceAll', 'All guests')}</option>
<option value="guest">{t('guestMessages.form.audienceGuest', 'Specific guest (name or device)')}</option>
</MobileSelect>
</MobileField>
{form.audience === 'guest' ? (
<MobileField label={t('guestMessages.form.guestIdentifier', 'Guest name or device ID')}>
<MobileInput
type="text"
value={form.guest_identifier}
onChange={(e) => setForm((prev) => ({ ...prev, guest_identifier: e.target.value }))}
placeholder={t('guestMessages.form.guestPlaceholder', 'e.g., Alex or device token')}
/>
</MobileField>
) : null}
<MobileField label={t('guestMessages.form.cta', 'CTA (optional)')}>
<YStack space="$1.5">
<MobileInput
type="text"
value={form.cta_label}
onChange={(e) => setForm((prev) => ({ ...prev, cta_label: e.target.value }))}
placeholder={t('guestMessages.form.ctaLabel', 'Button label')}
/>
<MobileInput
type="url"
value={form.cta_url}
onChange={(e) => setForm((prev) => ({ ...prev, cta_url: e.target.value }))}
placeholder={t('guestMessages.form.ctaUrl', 'https://your-link.com')}
/>
<Text fontSize="$xs" color={mutedText}>
{t('guestMessages.form.ctaHint', 'Both fields are required to add a button.')}
</Text>
</YStack>
</MobileField>
<XStack space="$2">
<MobileField label={t('guestMessages.form.expiresIn', 'Expires in (minutes)')}>
<MobileInput
type="number"
min={5}
max={2880}
value={form.expires_in_minutes}
onChange={(e) => setForm((prev) => ({ ...prev, expires_in_minutes: e.target.value }))}
placeholder={t('guestMessages.form.expiresPlaceholder', '60')}
/>
</MobileField>
<MobileField label={t('guestMessages.form.priority', 'Priority')}>
<MobileSelect
value={form.priority}
onChange={(e) => setForm((prev) => ({ ...prev, priority: e.target.value }))}
>
{[0, 1, 2, 3, 4, 5].map((value) => (
<option key={value} value={value}>
{t('guestMessages.form.priorityValue', 'Priority {{value}}', { value })}
</option>
))}
</MobileSelect>
</MobileField>
</XStack>
<CTAButton
label={sending ? t('common.processing', 'Processing…') : t('guestMessages.form.send', 'Send notification')}
onPress={() => handleSend()}
tone="primary"
fullWidth
/>
{!canSend ? (
<Text fontSize="$xs" color={mutedText}>
{t('guestMessages.form.validation', 'Add a title and message. Target guests need an identifier.')}
</Text>
) : null}
</YStack>
</MobileCard>
</div>
<MobileCard space="$2">
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('guestMessages.historyTitle', 'Recent messages')}
</Text>
<Pressable onPress={() => loadHistory()}>
<RefreshCcw size={18} color={textStrong} />
</Pressable>
</XStack>
{loading ? (
<YStack space="$2">
{Array.from({ length: 3 }).map((_, idx) => (
<SkeletonCard key={`s-${idx}`} height={72} />
))}
</YStack>
) : history.length === 0 ? (
<YStack space="$2">
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{t('guestMessages.emptyTitle', 'Send your first guest message')}
</Text>
<Text fontSize="$xs" color={mutedText}>
{t('guestMessages.emptyBody', 'Share a quick reminder or highlight to keep guests engaged.')}
</Text>
<CTAButton
label={t('guestMessages.emptyAction', 'Compose message')}
fullWidth={false}
onPress={() => formRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })}
/>
</YStack>
) : (
<YStack space="$2">
{history.map((item) => (
<MobileCard key={item.id} space="$2" borderColor={border}>
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$sm" fontWeight="800" color={textStrong}>
{item.title || t('guestMessages.history.untitled', 'Untitled')}
</Text>
<XStack space="$1.5" alignItems="center">
<PillBadge tone={item.status === 'active' ? 'success' : 'muted'}>
{t(`guestMessages.status.${item.status}`, item.status)}
</PillBadge>
<PillBadge tone={item.audience_scope === 'guest' ? 'warning' : 'muted'}>
{item.audience_scope === 'guest'
? t('guestMessages.audience.guest', 'Specific guest')
: t('guestMessages.audience.all', 'All guests')}
</PillBadge>
</XStack>
</XStack>
<Text fontSize="$sm" color={text}>
{item.body ?? t('guestMessages.history.noBody', 'No body provided.')}
</Text>
<XStack alignItems="center" justifyContent="space-between">
<XStack space="$1.5" alignItems="center">
<PillBadge tone="muted">{t(`guestMessages.type.${item.type}`, item.type)}</PillBadge>
{item.target_identifier ? (
<PillBadge tone="muted">
<XStack alignItems="center" space="$1.5">
<User size={12} color={muted} />
<Text fontSize="$xs" fontWeight="700" color={muted}>
{item.target_identifier}
</Text>
</XStack>
</PillBadge>
) : (
<PillBadge tone="muted">
<XStack alignItems="center" space="$1.5">
<Users size={12} color={muted} />
<Text fontSize="$xs" fontWeight="700" color={muted}>
{t('guestMessages.audience.all', 'All guests')}
</Text>
</XStack>
</PillBadge>
)}
</XStack>
<Text fontSize="$xs" color={mutedText}>
{formatGuestMessageDate(item.created_at, locale)}
</Text>
</XStack>
</MobileCard>
))}
</YStack>
)}
</MobileCard>
</MobileShell>
);
}