257 lines
10 KiB
TypeScript
257 lines
10 KiB
TypeScript
import React from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { toast } from 'react-hot-toast';
|
|
import { AlertCircle, Send, RefreshCw } from 'lucide-react';
|
|
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import type { GuestNotificationSummary, SendGuestNotificationPayload } from '../api';
|
|
import { listGuestNotifications, sendGuestNotification } from '../api';
|
|
|
|
const TYPE_OPTIONS = [
|
|
{ value: 'broadcast', label: 'Allgemein' },
|
|
{ value: 'support_tip', label: 'Support-Hinweis' },
|
|
{ value: 'upload_alert', label: 'Upload-Status' },
|
|
{ value: 'feedback_request', label: 'Feedback' },
|
|
];
|
|
|
|
const AUDIENCE_OPTIONS = [
|
|
{ value: 'all', label: 'Alle Gäste' },
|
|
{ value: 'guest', label: 'Einzelne Geräte-ID' },
|
|
];
|
|
|
|
type GuestBroadcastCardProps = {
|
|
eventSlug: string;
|
|
eventName?: string | null;
|
|
};
|
|
|
|
export function GuestBroadcastCard({ eventSlug, eventName }: GuestBroadcastCardProps) {
|
|
const { t } = useTranslation('management');
|
|
const [form, setForm] = React.useState({
|
|
title: '',
|
|
message: '',
|
|
type: 'broadcast',
|
|
audience: 'all',
|
|
guest_identifier: '',
|
|
cta_label: '',
|
|
cta_url: '',
|
|
expires_in_minutes: 120,
|
|
});
|
|
const [history, setHistory] = React.useState<GuestNotificationSummary[]>([]);
|
|
const [loadingHistory, setLoadingHistory] = React.useState(true);
|
|
const [submitting, setSubmitting] = React.useState(false);
|
|
const [error, setError] = React.useState<string | null>(null);
|
|
|
|
const loadHistory = React.useCallback(async () => {
|
|
setLoadingHistory(true);
|
|
try {
|
|
const data = await listGuestNotifications(eventSlug);
|
|
setHistory(data.slice(0, 5));
|
|
} catch (err) {
|
|
console.error(err);
|
|
} finally {
|
|
setLoadingHistory(false);
|
|
}
|
|
}, [eventSlug]);
|
|
|
|
React.useEffect(() => {
|
|
void loadHistory();
|
|
}, [loadHistory]);
|
|
|
|
function updateField(field: string, value: string): void {
|
|
setForm((prev) => ({ ...prev, [field]: value }));
|
|
}
|
|
|
|
async function handleSubmit(event: React.FormEvent<HTMLFormElement>): Promise<void> {
|
|
event.preventDefault();
|
|
setSubmitting(true);
|
|
setError(null);
|
|
|
|
const payload: SendGuestNotificationPayload = {
|
|
title: form.title.trim(),
|
|
message: form.message.trim(),
|
|
type: form.type,
|
|
audience: form.audience as 'all' | 'guest',
|
|
guest_identifier: form.audience === 'guest' ? form.guest_identifier.trim() : undefined,
|
|
expires_in_minutes: Number(form.expires_in_minutes) || undefined,
|
|
cta:
|
|
form.cta_label.trim() && form.cta_url.trim()
|
|
? { label: form.cta_label.trim(), url: form.cta_url.trim() }
|
|
: undefined,
|
|
};
|
|
|
|
try {
|
|
await sendGuestNotification(eventSlug, payload);
|
|
toast.success(t('events.notifications.toastSuccess', 'Nachricht gesendet.'));
|
|
setForm((prev) => ({
|
|
...prev,
|
|
title: '',
|
|
message: '',
|
|
guest_identifier: '',
|
|
cta_label: '',
|
|
cta_url: '',
|
|
}));
|
|
void loadHistory();
|
|
} catch (err) {
|
|
console.error(err);
|
|
setError(t('events.notifications.toastError', 'Nachricht konnte nicht gesendet werden.'));
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div>
|
|
<p className="text-sm text-muted-foreground">
|
|
{t('events.notifications.description', 'Sende kurze Hinweise direkt an deine Gäste. Ideal für Programmpunkte, Upload-Hilfe oder Feedback-Aufrufe.')} {eventName && <span className="font-semibold text-foreground">{eventName}</span>}
|
|
</p>
|
|
</div>
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
<div>
|
|
<Label htmlFor="notification-type">{t('events.notifications.type', 'Art der Nachricht')}</Label>
|
|
<select
|
|
id="notification-type"
|
|
className="mt-1 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
|
value={form.type}
|
|
onChange={(event) => updateField('type', event.target.value)}
|
|
>
|
|
{TYPE_OPTIONS.map((option) => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="notification-audience">{t('events.notifications.audience', 'Zielgruppe')}</Label>
|
|
<select
|
|
id="notification-audience"
|
|
className="mt-1 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
|
value={form.audience}
|
|
onChange={(event) => updateField('audience', event.target.value)}
|
|
>
|
|
{AUDIENCE_OPTIONS.map((option) => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
{form.audience === 'guest' && (
|
|
<div>
|
|
<Label htmlFor="notification-target">{t('events.notifications.target', 'Geräte-ID oder Gastname')}</Label>
|
|
<Input
|
|
id="notification-target"
|
|
value={form.guest_identifier}
|
|
onChange={(event) => updateField('guest_identifier', event.target.value)}
|
|
placeholder="z. B. device-123"
|
|
required
|
|
/>
|
|
</div>
|
|
)}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="notification-title">{t('events.notifications.titleLabel', 'Überschrift')}</Label>
|
|
<Input
|
|
id="notification-title"
|
|
value={form.title}
|
|
onChange={(event) => updateField('title', event.target.value)}
|
|
placeholder={t('events.notifications.titlePlaceholder', 'Buffet schließt in 10 Minuten')}
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="notification-message">{t('events.notifications.message', 'Nachricht')}</Label>
|
|
<Textarea
|
|
id="notification-message"
|
|
value={form.message}
|
|
onChange={(event) => updateField('message', event.target.value)}
|
|
rows={4}
|
|
placeholder={t('events.notifications.messagePlaceholder', 'Kommt zur Hauptbühne für das Gruppenfoto.')}
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
<div>
|
|
<Label htmlFor="notification-cta-label">{t('events.notifications.ctaLabel', 'CTA-Label (optional)')}</Label>
|
|
<Input
|
|
id="notification-cta-label"
|
|
value={form.cta_label}
|
|
onChange={(event) => updateField('cta_label', event.target.value)}
|
|
placeholder={t('events.notifications.ctaLabelPlaceholder', 'Zum Upload')}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="notification-cta-url">{t('events.notifications.ctaUrl', 'CTA-Link')}</Label>
|
|
<Input
|
|
id="notification-cta-url"
|
|
value={form.cta_url}
|
|
onChange={(event) => updateField('cta_url', event.target.value)}
|
|
placeholder="https://... oder /e/token/queue"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
<div>
|
|
<Label htmlFor="notification-expiry">{t('events.notifications.expiry', 'Automatisch ausblenden nach (Minuten)')}</Label>
|
|
<Input
|
|
id="notification-expiry"
|
|
type="number"
|
|
min={5}
|
|
max={2880}
|
|
value={form.expires_in_minutes}
|
|
onChange={(event) => updateField('expires_in_minutes', event.target.value)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
{error && (
|
|
<div className="flex items-center gap-2 rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-sm text-destructive">
|
|
<AlertCircle className="h-4 w-4" />
|
|
<span>{error}</span>
|
|
</div>
|
|
)}
|
|
<Button type="submit" className="inline-flex items-center gap-2" disabled={submitting}>
|
|
{submitting && <RefreshCw className="h-4 w-4 animate-spin" aria-hidden />}
|
|
{!submitting && <Send className="h-4 w-4" aria-hidden />}
|
|
{t('events.notifications.sendCta', 'Benachrichtigung senden')}
|
|
</Button>
|
|
</form>
|
|
<div>
|
|
<div className="mb-2 flex items-center justify-between">
|
|
<p className="text-sm font-semibold text-foreground">{t('events.notifications.historyTitle', 'Zuletzt versendet')}</p>
|
|
<Button variant="ghost" size="sm" className="gap-2" onClick={() => loadHistory()} disabled={loadingHistory}>
|
|
<RefreshCw className={`h-4 w-4 ${loadingHistory ? 'animate-spin' : ''}`} aria-hidden />
|
|
{t('events.notifications.reload', 'Aktualisieren')}
|
|
</Button>
|
|
</div>
|
|
{loadingHistory ? (
|
|
<p className="text-sm text-muted-foreground">{t('events.notifications.historyLoading', 'Verlauf wird geladen ...')}</p>
|
|
) : history.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground">{t('events.notifications.historyEmpty', 'Noch keine Benachrichtigungen versendet.')}</p>
|
|
) : (
|
|
<ul className="space-y-2">
|
|
{history.map((notification) => (
|
|
<li key={notification.id} className="rounded-lg border border-border bg-card px-3 py-2 text-sm">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<div>
|
|
<p className="font-semibold text-foreground">{notification.title}</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
{new Date(notification.created_at ?? '').toLocaleString()} · {notification.type}
|
|
</p>
|
|
</div>
|
|
<Badge variant="outline">{notification.audience_scope === 'all' ? t('events.notifications.audienceAll', 'Alle') : t('events.notifications.audienceGuest', 'Gast')}</Badge>
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|