570 lines
21 KiB
TypeScript
570 lines
21 KiB
TypeScript
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;
|
||
}
|
||
|
||
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();
|
||
}
|