391 lines
15 KiB
TypeScript
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>
|
|
);
|
|
}
|