zu fabricjs gewechselt, noch nicht funktionsfähig
This commit is contained in:
@@ -1,569 +1,7 @@
|
||||
import React from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Camera,
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
RefreshCw,
|
||||
Send,
|
||||
Sparkles,
|
||||
ThumbsDown,
|
||||
ThumbsUp,
|
||||
QrCode,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
|
||||
import { AdminLayout } from '../components/AdminLayout';
|
||||
import {
|
||||
ADMIN_EVENT_PHOTOS_PATH,
|
||||
ADMIN_EVENT_TASKS_PATH,
|
||||
ADMIN_EVENT_VIEW_PATH,
|
||||
ADMIN_EVENT_INVITES_PATH,
|
||||
} from '../constants';
|
||||
import {
|
||||
EventToolkit,
|
||||
EventToolkitTask,
|
||||
getEventToolkit,
|
||||
submitTenantFeedback,
|
||||
TenantPhoto,
|
||||
TenantEvent,
|
||||
} from '../api';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
|
||||
interface ToolkitState {
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
data: EventToolkit | null;
|
||||
}
|
||||
import EventDetailPage from './EventDetailPage';
|
||||
|
||||
export default function EventToolkitPage(): JSX.Element {
|
||||
const { slug } = useParams<{ slug?: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { t, i18n } = useTranslation('management');
|
||||
|
||||
const [state, setState] = React.useState<ToolkitState>({ loading: true, error: null, data: null });
|
||||
const [feedbackSentiment, setFeedbackSentiment] = React.useState<'positive' | 'neutral' | 'negative' | null>(null);
|
||||
const [feedbackMessage, setFeedbackMessage] = React.useState('');
|
||||
const [feedbackSubmitting, setFeedbackSubmitting] = React.useState(false);
|
||||
const [feedbackSubmitted, setFeedbackSubmitted] = React.useState(false);
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
if (!slug) {
|
||||
setState({ loading: false, error: t('toolkit.errors.missingSlug', 'Kein Event-Slug angegeben.'), data: null });
|
||||
return;
|
||||
}
|
||||
|
||||
setState((prev) => ({ ...prev, loading: true, error: null }));
|
||||
try {
|
||||
const toolkit = await getEventToolkit(slug);
|
||||
setState({ loading: false, error: null, data: toolkit });
|
||||
} catch (error) {
|
||||
if (!isAuthError(error)) {
|
||||
setState({ loading: false, error: t('toolkit.errors.loadFailed', 'Toolkit konnte nicht geladen werden.'), data: null });
|
||||
}
|
||||
}
|
||||
}, [slug, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const { data, loading } = state;
|
||||
const eventName = data?.event ? resolveEventName(data.event.name, i18n.language) : '';
|
||||
|
||||
const actions = (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" onClick={() => navigate(ADMIN_EVENT_VIEW_PATH(slug ?? ''))}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
{t('toolkit.actions.backToEvent', 'Zurück zum Event')}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => navigate(ADMIN_EVENT_PHOTOS_PATH(slug ?? ''))}>
|
||||
<Camera className="h-4 w-4" />
|
||||
{t('toolkit.actions.moderate', 'Fotos moderieren')}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => navigate(ADMIN_EVENT_TASKS_PATH(slug ?? ''))}>
|
||||
<Sparkles className="h-4 w-4" />
|
||||
{t('toolkit.actions.manageTasks', 'Tasks öffnen')}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => navigate(ADMIN_EVENT_INVITES_PATH(slug ?? ''))}>
|
||||
<QrCode className="h-4 w-4" />
|
||||
{t('toolkit.actions.manageInvites', 'QR-Einladungen')}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => void load()} disabled={loading}>
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
|
||||
{t('toolkit.actions.refresh', 'Aktualisieren')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<AdminLayout
|
||||
title={eventName || t('toolkit.titleFallback', 'Event-Day Toolkit')}
|
||||
subtitle={t('toolkit.subtitle', 'Behalte Uploads, Aufgaben und Einladungen am Eventtag im Blick.')}
|
||||
actions={actions}
|
||||
>
|
||||
{state.error && (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertTitle>{t('toolkit.alerts.errorTitle', 'Fehler')}</AlertTitle>
|
||||
<AlertDescription>{state.error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<ToolkitSkeleton />
|
||||
) : data ? (
|
||||
<div className="space-y-6">
|
||||
<AlertList alerts={data.alerts} />
|
||||
|
||||
<MetricsGrid metrics={data.metrics} />
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[2fr,1fr]">
|
||||
<PendingPhotosCard
|
||||
photos={data.photos.pending}
|
||||
navigateToModeration={() => navigate(ADMIN_EVENT_PHOTOS_PATH(slug ?? ''))}
|
||||
/>
|
||||
<InviteSummary invites={data.invites} navigateToEvent={() => navigate(ADMIN_EVENT_VIEW_PATH(slug ?? ''))} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<TaskOverviewCard tasks={data.tasks} navigateToTasks={() => navigate(ADMIN_EVENT_TASKS_PATH(slug ?? ''))} />
|
||||
<RecentUploadsCard photos={data.photos.recent} />
|
||||
</div>
|
||||
|
||||
<FeedbackCard
|
||||
submitting={feedbackSubmitting}
|
||||
submitted={feedbackSubmitted}
|
||||
sentiment={feedbackSentiment}
|
||||
message={feedbackMessage}
|
||||
onSelectSentiment={setFeedbackSentiment}
|
||||
onMessageChange={setFeedbackMessage}
|
||||
onSubmit={async () => {
|
||||
if (!slug) return;
|
||||
setFeedbackSubmitting(true);
|
||||
try {
|
||||
await submitTenantFeedback({
|
||||
category: 'event_toolkit',
|
||||
sentiment: feedbackSentiment ?? undefined,
|
||||
message: feedbackMessage ? feedbackMessage.trim() : undefined,
|
||||
event_slug: slug,
|
||||
});
|
||||
setFeedbackSentiment(null);
|
||||
setFeedbackMessage('');
|
||||
setFeedbackSubmitted(true);
|
||||
} catch (error) {
|
||||
if (!isAuthError(error)) {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
error: t('toolkit.errors.feedbackFailed', 'Feedback konnte nicht gesendet werden.'),
|
||||
}));
|
||||
}
|
||||
} finally {
|
||||
setFeedbackSubmitting(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function resolveEventName(name: TenantEvent['name'], locale?: string): string {
|
||||
if (typeof name === 'string') {
|
||||
return name;
|
||||
}
|
||||
if (name && typeof name === 'object') {
|
||||
if (locale && name[locale]) {
|
||||
return name[locale];
|
||||
}
|
||||
const short = locale && locale.includes('-') ? locale.split('-')[0] : null;
|
||||
if (short && name[short]) {
|
||||
return name[short];
|
||||
}
|
||||
return name.de ?? name.en ?? Object.values(name)[0] ?? 'Event';
|
||||
}
|
||||
return 'Event';
|
||||
}
|
||||
|
||||
function AlertList({ alerts }: { alerts: string[] }) {
|
||||
const { t } = useTranslation('management');
|
||||
if (!alerts.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const alertMap: Record<string, string> = {
|
||||
no_tasks: t('toolkit.alerts.noTasks', 'Noch keine Tasks zugeordnet.'),
|
||||
no_invites: t('toolkit.alerts.noInvites', 'Es gibt keine aktiven QR-Einladungen.'),
|
||||
pending_photos: t('toolkit.alerts.pendingPhotos', 'Es warten Fotos auf Moderation.'),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{alerts.map((code) => (
|
||||
<Alert key={code} variant="warning" className="border-amber-200 bg-amber-50 text-amber-900">
|
||||
<AlertTitle>{t('toolkit.alerts.attention', 'Achtung')}</AlertTitle>
|
||||
<AlertDescription>{alertMap[code] ?? code}</AlertDescription>
|
||||
</Alert>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricsGrid({
|
||||
metrics,
|
||||
}: {
|
||||
metrics: EventToolkit['metrics'];
|
||||
}) {
|
||||
const { t } = useTranslation('management');
|
||||
const cards = [
|
||||
{
|
||||
label: t('toolkit.metrics.uploadsTotal', 'Uploads gesamt'),
|
||||
value: metrics.uploads_total,
|
||||
},
|
||||
{
|
||||
label: t('toolkit.metrics.uploads24h', 'Uploads (24h)'),
|
||||
value: metrics.uploads_24h,
|
||||
},
|
||||
{
|
||||
label: t('toolkit.metrics.pendingPhotos', 'Unmoderierte Fotos'),
|
||||
value: metrics.pending_photos,
|
||||
},
|
||||
{
|
||||
label: t('toolkit.metrics.activeInvites', 'Aktive Einladungen'),
|
||||
value: metrics.active_invites,
|
||||
},
|
||||
{
|
||||
label: t('toolkit.metrics.engagementMode', 'Modus'),
|
||||
value:
|
||||
metrics.engagement_mode === 'photo_only'
|
||||
? t('toolkit.metrics.modePhotoOnly', 'Foto-Modus')
|
||||
: t('toolkit.metrics.modeTasks', 'Aufgaben'),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||
{cards.map((card) => (
|
||||
<Card key={card.label} className="border-0 bg-white/90 shadow-sm shadow-amber-100/50">
|
||||
<CardContent className="space-y-1 p-4">
|
||||
<p className="text-xs uppercase tracking-wide text-slate-500">{card.label}</p>
|
||||
<p className="text-2xl font-semibold text-slate-900">{card.value}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PendingPhotosCard({
|
||||
photos,
|
||||
navigateToModeration,
|
||||
}: {
|
||||
photos: TenantPhoto[];
|
||||
navigateToModeration: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation('management');
|
||||
|
||||
return (
|
||||
<Card className="border-0 bg-white/90 shadow-xl shadow-slate-100/70">
|
||||
<CardHeader className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
|
||||
<Camera className="h-5 w-5 text-amber-500" />
|
||||
{t('toolkit.pending.title', 'Wartende Fotos')}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
{t('toolkit.pending.subtitle', 'Moderationsempfehlung für neue Uploads.')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button variant="outline" onClick={navigateToModeration}>
|
||||
{t('toolkit.pending.cta', 'Zur Moderation')}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{photos.length === 0 ? (
|
||||
<p className="text-xs text-slate-500">{t('toolkit.pending.empty', 'Aktuell warten keine Fotos auf Freigabe.')}</p>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{photos.map((photo) => (
|
||||
<div key={photo.id} className="flex gap-3 rounded-xl border border-amber-100 bg-amber-50/60 p-3">
|
||||
<img
|
||||
src={photo.thumbnail_url}
|
||||
alt={photo.filename}
|
||||
className="h-16 w-16 rounded-lg object-cover"
|
||||
/>
|
||||
<div className="space-y-1 text-xs text-slate-600">
|
||||
<p className="font-semibold text-slate-800">{photo.uploader_name ?? t('toolkit.pending.unknownUploader', 'Unbekannter Gast')}</p>
|
||||
<p>{t('toolkit.pending.uploadedAt', 'Hochgeladen:')} {formatDateTime(photo.uploaded_at)}</p>
|
||||
<p className="text-[11px] text-amber-700">{t('toolkit.pending.statusPending', 'Status: Prüfung ausstehend')}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function InviteSummary({
|
||||
invites,
|
||||
navigateToEvent,
|
||||
}: {
|
||||
invites: EventToolkit['invites'];
|
||||
navigateToEvent: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation('management');
|
||||
return (
|
||||
<Card className="border-0 bg-white/90 shadow-md shadow-amber-100/60">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg text-slate-900">
|
||||
<Sparkles className="h-5 w-5 text-amber-500" />
|
||||
{t('toolkit.invites.title', 'QR-Einladungen')}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
{t('toolkit.invites.subtitle', 'Aktive Links und Layouts im Blick behalten.')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-xs text-slate-600">
|
||||
<div className="flex gap-2 text-sm text-slate-900">
|
||||
<Badge variant="outline" className="border-amber-200 text-amber-700">
|
||||
{t('toolkit.invites.activeCount', { defaultValue: '{{count}} aktiv', count: invites.summary.active })}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="border-amber-200 text-amber-700">
|
||||
{t('toolkit.invites.totalCount', { defaultValue: '{{count}} gesamt', count: invites.summary.total })}
|
||||
</Badge>
|
||||
</div>
|
||||
{invites.items.length === 0 ? (
|
||||
<p>{t('toolkit.invites.empty', 'Noch keine QR-Einladungen erstellt.')}</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{invites.items.map((invite) => (
|
||||
<li key={invite.id} className="rounded-lg border border-amber-100 bg-amber-50/70 p-3">
|
||||
<p className="text-sm font-semibold text-slate-900">{invite.label ?? invite.url}</p>
|
||||
<p className="truncate text-xs text-slate-500">{invite.url}</p>
|
||||
<p className="text-[11px] text-amber-700">
|
||||
{invite.is_active
|
||||
? t('toolkit.invites.statusActive', 'Aktiv')
|
||||
: t('toolkit.invites.statusInactive', 'Inaktiv')}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<Button variant="outline" onClick={navigateToEvent}>
|
||||
{t('toolkit.invites.manage', 'Einladungen verwalten')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskOverviewCard({ tasks, navigateToTasks }: { tasks: EventToolkit['tasks']; navigateToTasks: () => void }) {
|
||||
const { t } = useTranslation('management');
|
||||
|
||||
return (
|
||||
<Card className="border-0 bg-white/90 shadow-md shadow-pink-100/60">
|
||||
<CardHeader className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
|
||||
<Sparkles className="h-5 w-5 text-pink-500" />
|
||||
{t('toolkit.tasks.title', 'Aktive Aufgaben')}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
{t('toolkit.tasks.subtitle', 'Motiviere Gäste mit klaren Aufgaben & Highlights.')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="outline" className="border-pink-200 text-pink-600">
|
||||
{t('toolkit.tasks.summary', {
|
||||
defaultValue: '{{completed}} von {{total}} erledigt',
|
||||
completed: tasks.summary.completed,
|
||||
total: tasks.summary.total,
|
||||
})}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{tasks.items.length === 0 ? (
|
||||
<p className="text-xs text-slate-500">{t('toolkit.tasks.empty', 'Noch keine Aufgaben zugewiesen.')}</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{tasks.items.map((task) => (
|
||||
<TaskRow key={task.id} task={task} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Button variant="outline" onClick={navigateToTasks}>
|
||||
{t('toolkit.tasks.manage', 'Tasks verwalten')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskRow({ task }: { task: EventToolkitTask }) {
|
||||
const { t } = useTranslation('management');
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-3 rounded-xl border border-pink-100 bg-white/80 p-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-900">{task.title}</p>
|
||||
{task.description ? <p className="text-xs text-slate-600">{task.description}</p> : null}
|
||||
</div>
|
||||
<span className={`flex items-center gap-1 text-xs font-medium ${task.is_completed ? 'text-emerald-600' : 'text-slate-500'}`}>
|
||||
{task.is_completed ? <CheckCircle2 className="h-4 w-4" /> : <Circle className="h-4 w-4" />}
|
||||
{task.is_completed ? t('toolkit.tasks.completed', 'Erledigt') : t('toolkit.tasks.open', 'Offen')}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RecentUploadsCard({ photos }: { photos: TenantPhoto[] }) {
|
||||
const { t } = useTranslation('management');
|
||||
return (
|
||||
<Card className="border-0 bg-white/90 shadow-md shadow-sky-100/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg text-slate-900">
|
||||
<Camera className="h-5 w-5 text-sky-500" />
|
||||
{t('toolkit.recent.title', 'Neueste Uploads')}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
{t('toolkit.recent.subtitle', 'Ein Blick auf die letzten Fotos der Gäste.')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{photos.length === 0 ? (
|
||||
<p className="text-xs text-slate-500">{t('toolkit.recent.empty', 'Noch keine freigegebenen Fotos vorhanden.')}</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{photos.map((photo) => (
|
||||
<img
|
||||
key={photo.id}
|
||||
src={photo.thumbnail_url}
|
||||
alt={photo.filename}
|
||||
className="h-24 w-full rounded-lg object-cover"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function FeedbackCard({
|
||||
submitting,
|
||||
submitted,
|
||||
sentiment,
|
||||
message,
|
||||
onSelectSentiment,
|
||||
onMessageChange,
|
||||
onSubmit,
|
||||
}: {
|
||||
submitting: boolean;
|
||||
submitted: boolean;
|
||||
sentiment: 'positive' | 'neutral' | 'negative' | null;
|
||||
message: string;
|
||||
onSelectSentiment: (value: 'positive' | 'neutral' | 'negative') => void;
|
||||
onMessageChange: (value: string) => void;
|
||||
onSubmit: () => Promise<void>;
|
||||
}) {
|
||||
const { t } = useTranslation('management');
|
||||
|
||||
return (
|
||||
<Card className="border-0 bg-white/95 shadow-lg shadow-amber-100/60">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg text-slate-900">
|
||||
<MessageSquare className="h-5 w-5 text-amber-500" />
|
||||
{t('toolkit.feedback.title', 'Wie hilfreich ist dieses Toolkit?')}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
{t('toolkit.feedback.subtitle', 'Dein Feedback hilft uns, den Eventtag noch besser zu begleiten.')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{submitted ? (
|
||||
<Alert variant="success">
|
||||
<AlertTitle>{t('toolkit.feedback.thanksTitle', 'Danke!')}</AlertTitle>
|
||||
<AlertDescription>{t('toolkit.feedback.thanksDescription', 'Wir haben dein Feedback erhalten.')}</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant={sentiment === 'positive' ? 'default' : 'outline'}
|
||||
onClick={() => onSelectSentiment('positive')}
|
||||
disabled={submitting}
|
||||
>
|
||||
<ThumbsUp className="mr-2 h-4 w-4" /> {t('toolkit.feedback.positive', 'Hilfreich')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={sentiment === 'neutral' ? 'default' : 'outline'}
|
||||
onClick={() => onSelectSentiment('neutral')}
|
||||
disabled={submitting}
|
||||
>
|
||||
<Sparkles className="mr-2 h-4 w-4" /> {t('toolkit.feedback.neutral', 'Ganz okay')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={sentiment === 'negative' ? 'default' : 'outline'}
|
||||
onClick={() => onSelectSentiment('negative')}
|
||||
disabled={submitting}
|
||||
>
|
||||
<ThumbsDown className="mr-2 h-4 w-4" /> {t('toolkit.feedback.negative', 'Verbesserungsbedarf')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Textarea
|
||||
rows={3}
|
||||
placeholder={t('toolkit.feedback.placeholder', 'Erzähle uns kurz, was dir gefallen hat oder was fehlt …')}
|
||||
value={message}
|
||||
onChange={(event) => onMessageChange(event.target.value)}
|
||||
disabled={submitting}
|
||||
/>
|
||||
<p className="text-[11px] text-slate-500">
|
||||
{t('toolkit.feedback.disclaimer', 'Dein Feedback wird vertraulich behandelt und hilft uns beim Feinschliff.')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => void onSubmit()} disabled={submitting || (!sentiment && message.trim() === '')}>
|
||||
{submitting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Send className="mr-2 h-4 w-4" />}
|
||||
{t('toolkit.feedback.submit', 'Feedback senden')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolkitSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<div key={index} className="h-40 animate-pulse rounded-2xl bg-gradient-to-r from-white/40 via-white/60 to-white/40" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDateTime(value: string | null): string {
|
||||
if (!value) {
|
||||
return '–';
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '–';
|
||||
}
|
||||
return date.toLocaleString();
|
||||
return <EventDetailPage mode="toolkit" />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user