411 lines
15 KiB
TypeScript
411 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 { useTheme } from '@tamagui/core';
|
|
import { RefreshCcw, Users, User } from 'lucide-react';
|
|
import toast from 'react-hot-toast';
|
|
import { MobileShell } from './components/MobileShell';
|
|
import { MobileCard, CTAButton, PillBadge } from './components/Primitives';
|
|
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';
|
|
|
|
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 theme = useTheme();
|
|
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 [form, setForm] = React.useState<FormState>({
|
|
title: '',
|
|
message: '',
|
|
audience: 'all',
|
|
guest_identifier: '',
|
|
cta_label: '',
|
|
cta_url: '',
|
|
expires_in_minutes: '',
|
|
priority: '1',
|
|
});
|
|
|
|
const inputStyle = React.useMemo<React.CSSProperties>(() => {
|
|
const border = String(theme.borderColor?.val ?? '#e5e7eb');
|
|
const surface = String(theme.surface?.val ?? 'white');
|
|
const text = String(theme.color?.val ?? '#111827');
|
|
return {
|
|
width: '100%',
|
|
borderRadius: 10,
|
|
border: `1px solid ${border}`,
|
|
padding: '10px 12px',
|
|
fontSize: 13,
|
|
background: surface,
|
|
color: text,
|
|
};
|
|
}, [theme]);
|
|
|
|
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 = String(theme.gray?.val ?? '#6b7280');
|
|
|
|
return (
|
|
<MobileShell
|
|
activeTab="home"
|
|
title={t('guestMessages.title', 'Guest messages')}
|
|
subtitle={t('guestMessages.subtitle', 'Send push messages to guests')}
|
|
onBack={() => navigate(-1)}
|
|
headerActions={
|
|
<Pressable onPress={() => loadHistory()}>
|
|
<RefreshCcw size={18} color={String(theme.color?.val ?? '#111827')} />
|
|
</Pressable>
|
|
}
|
|
>
|
|
{error ? (
|
|
<MobileCard>
|
|
<Text fontWeight="700" color={String(theme.red10?.val ?? '#b91c1c')}>
|
|
{error}
|
|
</Text>
|
|
</MobileCard>
|
|
) : null}
|
|
|
|
<MobileCard space="$3">
|
|
<Text fontSize="$md" fontWeight="800" color={String(theme.color?.val ?? '#111827')}>
|
|
{t('guestMessages.composeTitle', 'Send a message')}
|
|
</Text>
|
|
<YStack space="$2">
|
|
<Field label={t('guestMessages.form.title', 'Title')}>
|
|
<input
|
|
type="text"
|
|
value={form.title}
|
|
onChange={(e) => setForm((prev) => ({ ...prev, title: e.target.value }))}
|
|
placeholder={t('guestMessages.form.titlePlaceholder', 'Gallery reminder, upload nudge, ...')}
|
|
style={{ ...inputStyle, height: 40 }}
|
|
/>
|
|
</Field>
|
|
<Field label={t('guestMessages.form.message', 'Message')}>
|
|
<textarea
|
|
value={form.message}
|
|
onChange={(e) => setForm((prev) => ({ ...prev, message: e.target.value }))}
|
|
placeholder={t('guestMessages.form.messagePlaceholder', 'Write a short note for your guests.')}
|
|
style={{ ...inputStyle, minHeight: 96, resize: 'vertical' }}
|
|
/>
|
|
</Field>
|
|
<Field label={t('guestMessages.form.audience', 'Audience')}>
|
|
<select
|
|
value={form.audience}
|
|
onChange={(e) => setForm((prev) => ({ ...prev, audience: e.target.value as FormState['audience'] }))}
|
|
style={{ ...inputStyle, height: 42 }}
|
|
>
|
|
<option value="all">{t('guestMessages.form.audienceAll', 'All guests')}</option>
|
|
<option value="guest">{t('guestMessages.form.audienceGuest', 'Specific guest (name or device)')}</option>
|
|
</select>
|
|
</Field>
|
|
{form.audience === 'guest' ? (
|
|
<Field label={t('guestMessages.form.guestIdentifier', 'Guest name or device ID')}>
|
|
<input
|
|
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')}
|
|
style={{ ...inputStyle, height: 40 }}
|
|
/>
|
|
</Field>
|
|
) : null}
|
|
<Field label={t('guestMessages.form.cta', 'CTA (optional)')}>
|
|
<YStack space="$1.5">
|
|
<input
|
|
type="text"
|
|
value={form.cta_label}
|
|
onChange={(e) => setForm((prev) => ({ ...prev, cta_label: e.target.value }))}
|
|
placeholder={t('guestMessages.form.ctaLabel', 'Button label')}
|
|
style={{ ...inputStyle, height: 40 }}
|
|
/>
|
|
<input
|
|
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')}
|
|
style={{ ...inputStyle, height: 40 }}
|
|
/>
|
|
<Text fontSize="$xs" color={mutedText}>
|
|
{t('guestMessages.form.ctaHint', 'Both fields are required to add a button.')}
|
|
</Text>
|
|
</YStack>
|
|
</Field>
|
|
<XStack space="$2">
|
|
<Field label={t('guestMessages.form.expiresIn', 'Expires in (minutes)')}>
|
|
<input
|
|
type="number"
|
|
min={5}
|
|
max={2880}
|
|
value={form.expires_in_minutes}
|
|
onChange={(e) => setForm((prev) => ({ ...prev, expires_in_minutes: e.target.value }))}
|
|
placeholder="60"
|
|
style={{ ...inputStyle, height: 40 }}
|
|
/>
|
|
</Field>
|
|
<Field label={t('guestMessages.form.priority', 'Priority')}>
|
|
<select
|
|
value={form.priority}
|
|
onChange={(e) => setForm((prev) => ({ ...prev, priority: e.target.value }))}
|
|
style={{ ...inputStyle, height: 40 }}
|
|
>
|
|
{[0, 1, 2, 3, 4, 5].map((value) => (
|
|
<option key={value} value={value}>
|
|
{t('guestMessages.form.priorityValue', 'Priority {{value}}', { value })}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</Field>
|
|
</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>
|
|
|
|
<MobileCard space="$2">
|
|
<XStack alignItems="center" justifyContent="space-between">
|
|
<Text fontSize="$md" fontWeight="800" color={String(theme.color?.val ?? '#111827')}>
|
|
{t('guestMessages.historyTitle', 'Recent messages')}
|
|
</Text>
|
|
<Pressable onPress={() => loadHistory()}>
|
|
<RefreshCcw size={18} color={String(theme.color?.val ?? '#111827')} />
|
|
</Pressable>
|
|
</XStack>
|
|
|
|
{loading ? (
|
|
<YStack space="$2">
|
|
{Array.from({ length: 3 }).map((_, idx) => (
|
|
<MobileCard key={`s-${idx}`} height={72} opacity={0.6} />
|
|
))}
|
|
</YStack>
|
|
) : history.length === 0 ? (
|
|
<YStack space="$1.5">
|
|
<Text fontSize="$sm" color={mutedText}>
|
|
{t('guestMessages.empty', 'No guest messages yet.')}
|
|
</Text>
|
|
</YStack>
|
|
) : (
|
|
<YStack space="$2">
|
|
{history.map((item) => (
|
|
<MobileCard key={item.id} space="$2" borderColor={String(theme.borderColor?.val ?? '#e5e7eb')}>
|
|
<XStack alignItems="center" justifyContent="space-between">
|
|
<Text fontSize="$sm" fontWeight="800" color={String(theme.color?.val ?? '#111827')}>
|
|
{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={String(theme.color?.val ?? '#111827')}>
|
|
{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={String(theme.gray10?.val ?? '#6b7280')} />
|
|
<Text fontSize="$xs" fontWeight="700" color={String(theme.gray10?.val ?? '#6b7280')}>
|
|
{item.target_identifier}
|
|
</Text>
|
|
</XStack>
|
|
</PillBadge>
|
|
) : (
|
|
<PillBadge tone="muted">
|
|
<XStack alignItems="center" space="$1.5">
|
|
<Users size={12} color={String(theme.gray10?.val ?? '#6b7280')} />
|
|
<Text fontSize="$xs" fontWeight="700" color={String(theme.gray10?.val ?? '#6b7280')}>
|
|
{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>
|
|
);
|
|
}
|
|
|
|
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>
|
|
);
|
|
}
|