931 lines
42 KiB
TypeScript
931 lines
42 KiB
TypeScript
// @ts-nocheck
|
||
import React from 'react';
|
||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||
import { useTranslation } from 'react-i18next';
|
||
import { ArrowLeft, Camera, Clock3, Loader2, MessageSquare, Printer, ShoppingCart, Sparkles } from 'lucide-react';
|
||
import toast from 'react-hot-toast';
|
||
|
||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||
import { Badge } from '@/components/ui/badge';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Switch } from '@/components/ui/switch';
|
||
import { Checkbox } from '@/components/ui/checkbox';
|
||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||
import { Label } from '@/components/ui/label';
|
||
import { AdminLayout } from '../components/AdminLayout';
|
||
import {
|
||
createEventAddonCheckout,
|
||
EventQrInvite,
|
||
EventStats,
|
||
getEvent,
|
||
getEventStats,
|
||
getEventQrInvites,
|
||
getAddonCatalog,
|
||
type EventAddonCatalogItem,
|
||
TenantEvent,
|
||
toggleEvent,
|
||
submitTenantFeedback,
|
||
} from '../api';
|
||
import { updateEvent } from '../api';
|
||
import { buildEventTabs } from '../lib/eventTabs';
|
||
import { formatEventDate } from '../lib/events';
|
||
import { isAuthError } from '../auth/tokens';
|
||
import { getApiErrorMessage } from '../lib/apiError';
|
||
import {
|
||
ADMIN_EVENT_EDIT_PATH,
|
||
ADMIN_EVENT_PHOTOS_PATH,
|
||
ADMIN_EVENTS_PATH,
|
||
} from '../constants';
|
||
|
||
export default function EventRecapPage() {
|
||
const { slug: slugParam } = useParams<{ slug?: string }>();
|
||
const navigate = useNavigate();
|
||
const { t } = useTranslation('management');
|
||
const [searchParams, setSearchParams] = useSearchParams();
|
||
|
||
const slug = slugParam ?? null;
|
||
|
||
const [event, setEvent] = React.useState<TenantEvent | null>(null);
|
||
const [stats, setStats] = React.useState<EventStats | null>(null);
|
||
const [loading, setLoading] = React.useState(true);
|
||
const [busy, setBusy] = React.useState(false);
|
||
const [settingsBusy, setSettingsBusy] = React.useState(false);
|
||
const [error, setError] = React.useState<string | null>(null);
|
||
const [joinTokens, setJoinTokens] = React.useState<EventQrInvite[]>([]);
|
||
const [addonsCatalog, setAddonsCatalog] = React.useState<EventAddonCatalogItem[]>([]);
|
||
const [addonBusyKey, setAddonBusyKey] = React.useState<string | null>(null);
|
||
|
||
const loadEventData = React.useCallback(async () => {
|
||
if (!slug) {
|
||
setLoading(false);
|
||
setError(t('events.errors.missingSlug', 'Kein Event ausgewählt.'));
|
||
return;
|
||
}
|
||
|
||
setLoading(true);
|
||
setError(null);
|
||
|
||
try {
|
||
const [eventData, statsData, invites, addons] = await Promise.all([
|
||
getEvent(slug),
|
||
getEventStats(slug),
|
||
getEventQrInvites(slug),
|
||
getAddonCatalog(),
|
||
]);
|
||
|
||
setEvent(eventData);
|
||
setStats(statsData);
|
||
setJoinTokens(invites);
|
||
setAddonsCatalog(addons);
|
||
} catch (err) {
|
||
if (!isAuthError(err)) {
|
||
setError(getApiErrorMessage(err, t('events.errors.loadFailed', 'Event konnte nicht geladen werden.')));
|
||
}
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [slug, t]);
|
||
|
||
React.useEffect(() => {
|
||
void loadEventData();
|
||
}, [loadEventData]);
|
||
|
||
React.useEffect(() => {
|
||
if (!searchParams.get('addon_success')) {
|
||
return;
|
||
}
|
||
|
||
toast(t('events.success.addonApplied', 'Add-on angewendet. Limits aktualisieren sich in Kürze.'));
|
||
void loadEventData();
|
||
|
||
const next = new URLSearchParams(searchParams);
|
||
next.delete('addon_success');
|
||
setSearchParams(next, { replace: true });
|
||
}, [loadEventData, searchParams, setSearchParams, t]);
|
||
|
||
const handleToggleEvent = React.useCallback(async () => {
|
||
if (!slug) return;
|
||
setBusy(true);
|
||
setError(null);
|
||
try {
|
||
const updated = await toggleEvent(slug);
|
||
setEvent(updated);
|
||
} catch (err) {
|
||
if (!isAuthError(err)) {
|
||
setError(getApiErrorMessage(err, t('events.errors.toggleFailed', 'Status konnte nicht angepasst werden.')));
|
||
}
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}, [slug, t]);
|
||
|
||
const handleAddonCheckout = React.useCallback(async (addonKey: string) => {
|
||
if (!slug) return;
|
||
|
||
setAddonBusyKey(addonKey);
|
||
setError(null);
|
||
|
||
try {
|
||
const currentUrl = window.location.origin + window.location.pathname;
|
||
const successUrl = `${currentUrl}?addon_success=1`;
|
||
const checkout = await createEventAddonCheckout(slug, {
|
||
addon_key: addonKey,
|
||
quantity: 1,
|
||
success_url: successUrl,
|
||
cancel_url: currentUrl,
|
||
});
|
||
|
||
if (checkout.checkout_url) {
|
||
window.location.href = checkout.checkout_url;
|
||
} else {
|
||
toast(t('events.errors.checkoutMissing', 'Checkout konnte nicht gestartet werden.'));
|
||
}
|
||
} catch (err) {
|
||
if (!isAuthError(err)) {
|
||
toast(getApiErrorMessage(err, t('events.errors.checkoutFailed', 'Add-on Checkout fehlgeschlagen.')));
|
||
}
|
||
} finally {
|
||
setAddonBusyKey(null);
|
||
}
|
||
}, [slug, t]);
|
||
|
||
const handleArchive = React.useCallback(async () => {
|
||
if (!slug || !event) return;
|
||
setArchiveBusy(true);
|
||
setError(null);
|
||
|
||
try {
|
||
const updated = await updateEvent(slug, { status: 'archived', is_active: false });
|
||
setEvent(updated);
|
||
setArchiveOpen(false);
|
||
toast.success(t('events.recap.archivedSuccess', 'Event archiviert. Galerie ist geschlossen.'));
|
||
} catch (err) {
|
||
if (!isAuthError(err)) {
|
||
setError(getApiErrorMessage(err, t('events.errors.archiveFailed', 'Archivierung fehlgeschlagen.')));
|
||
}
|
||
} finally {
|
||
setArchiveBusy(false);
|
||
}
|
||
}, [event, slug, t]);
|
||
|
||
const handleToggleSetting = React.useCallback(async (key: 'guest_downloads_enabled' | 'guest_sharing_enabled', value: boolean) => {
|
||
if (!slug || !event) return;
|
||
setSettingsBusy(true);
|
||
setError(null);
|
||
try {
|
||
const updated = await updateEvent(slug, {
|
||
settings: {
|
||
...(event.settings ?? {}),
|
||
[key]: value,
|
||
},
|
||
});
|
||
setEvent(updated);
|
||
} catch (err) {
|
||
if (!isAuthError(err)) {
|
||
setError(getApiErrorMessage(err, t('events.errors.toggleFailed', 'Einstellung konnte nicht gespeichert werden.')));
|
||
}
|
||
} finally {
|
||
setSettingsBusy(false);
|
||
}
|
||
}, [event, slug, t]);
|
||
|
||
if (!slug) {
|
||
return (
|
||
<AdminLayout
|
||
title={t('events.errors.notFoundTitle', 'Event nicht gefunden')}
|
||
subtitle={t('events.errors.notFoundCopy', 'Bitte wähle ein Event aus der Übersicht.')}
|
||
>
|
||
<Alert variant="destructive">
|
||
<AlertTitle>{t('events.errors.notFoundTitle', 'Event nicht gefunden')}</AlertTitle>
|
||
<AlertDescription>{t('events.errors.notFoundBody', 'Kehre zur Eventliste zurück und wähle dort ein Event aus.')}</AlertDescription>
|
||
</Alert>
|
||
</AdminLayout>
|
||
);
|
||
}
|
||
|
||
const eventName = event ? resolveName(event.name) : t('events.placeholders.untitled', 'Unbenanntes Event');
|
||
const activeInvite = joinTokens.find((token) => token.is_active);
|
||
const guestLinkRaw = event?.public_url ?? activeInvite?.url ?? (activeInvite?.token ? `/g/${activeInvite.token}` : null);
|
||
const guestLink = buildAbsoluteGuestLink(guestLinkRaw);
|
||
const guestQrCodeDataUrl = activeInvite?.qr_code_data_url ?? null;
|
||
const eventTabs = event
|
||
? buildEventTabs(event, (key, fallback) => t(key, { defaultValue: fallback }), {
|
||
photos: stats?.uploads_total ?? event.photo_count ?? undefined,
|
||
tasks: event.tasks_count ?? undefined,
|
||
})
|
||
: [];
|
||
|
||
return (
|
||
<AdminLayout
|
||
title={eventName}
|
||
subtitle={t('events.workspace.detailSubtitle', 'Behalte Status, Aufgaben und QR-Codes deines Events im Blick.')}
|
||
tabs={eventTabs}
|
||
currentTabKey="recap"
|
||
>
|
||
{error && (
|
||
<Alert variant="destructive">
|
||
<AlertTitle>{t('events.alerts.failedTitle', 'Aktion fehlgeschlagen')}</AlertTitle>
|
||
<AlertDescription>{error}</AlertDescription>
|
||
</Alert>
|
||
)}
|
||
|
||
{loading ? (
|
||
<WorkspaceSkeleton />
|
||
) : event && stats ? (
|
||
<RecapContent
|
||
event={event}
|
||
stats={stats}
|
||
busy={busy}
|
||
onToggleEvent={handleToggleEvent}
|
||
guestLink={guestLink}
|
||
guestQrCodeDataUrl={guestQrCodeDataUrl}
|
||
addonsCatalog={addonsCatalog}
|
||
addonBusyKey={addonBusyKey}
|
||
onCheckoutAddon={handleAddonCheckout}
|
||
onArchive={handleArchive}
|
||
onCopyLink={guestLink ? () => {
|
||
navigator.clipboard.writeText(guestLink);
|
||
toast.success(t('events.recap.copySuccess', 'Link kopiert'));
|
||
} : undefined}
|
||
onOpenPhotos={() => navigate(ADMIN_EVENT_PHOTOS_PATH(event.slug))}
|
||
onEditEvent={() => navigate(ADMIN_EVENT_EDIT_PATH(event.slug))}
|
||
onBack={() => navigate(ADMIN_EVENTS_PATH)}
|
||
settingsBusy={settingsBusy}
|
||
onToggleDownloads={(value) => handleToggleSetting('guest_downloads_enabled', value)}
|
||
onToggleSharing={(value) => handleToggleSetting('guest_sharing_enabled', value)}
|
||
/>
|
||
) : (
|
||
<Alert variant="destructive">
|
||
<AlertTitle>{t('events.errors.notFoundTitle', 'Event nicht gefunden')}</AlertTitle>
|
||
<AlertDescription>{t('events.errors.notFoundBody', 'Kehre zur Eventliste zurück und wähle dort ein Event aus.')}</AlertDescription>
|
||
</Alert>
|
||
)}
|
||
</AdminLayout>
|
||
);
|
||
}
|
||
|
||
type RecapContentProps = {
|
||
event: TenantEvent;
|
||
stats: EventStats;
|
||
busy: boolean;
|
||
onToggleEvent: () => void;
|
||
guestLink: string | null;
|
||
guestQrCodeDataUrl: string | null;
|
||
addonsCatalog: EventAddonCatalogItem[];
|
||
addonBusyKey: string | null;
|
||
onCheckoutAddon: (addonKey: string) => void;
|
||
onArchive: () => void;
|
||
onCopyLink?: () => void;
|
||
onOpenPhotos: () => void;
|
||
onEditEvent: () => void;
|
||
onBack: () => void;
|
||
settingsBusy: boolean;
|
||
onToggleDownloads: (value: boolean) => void;
|
||
onToggleSharing: (value: boolean) => void;
|
||
};
|
||
|
||
function RecapContent({
|
||
event,
|
||
stats,
|
||
busy,
|
||
onToggleEvent,
|
||
guestLink,
|
||
guestQrCodeDataUrl,
|
||
addonsCatalog,
|
||
addonBusyKey,
|
||
onCheckoutAddon,
|
||
onArchive,
|
||
onCopyLink,
|
||
onOpenPhotos,
|
||
onEditEvent,
|
||
onBack,
|
||
settingsBusy,
|
||
onToggleDownloads,
|
||
onToggleSharing,
|
||
}: RecapContentProps) {
|
||
const { t } = useTranslation('management');
|
||
const galleryExpiresAt = event.package?.expires_at ?? event.limits?.gallery?.expires_at ?? null;
|
||
const galleryStatusLabel = event.is_active
|
||
? t('events.recap.galleryOpen', 'Galerie geöffnet')
|
||
: t('events.recap.galleryClosed', 'Galerie geschlossen');
|
||
|
||
const counts = {
|
||
photos: stats.uploads_total ?? stats.total ?? 0,
|
||
pending: stats.pending_photos ?? 0,
|
||
likes: stats.likes_total ?? stats.likes ?? 0,
|
||
};
|
||
|
||
const [sentiment, setSentiment] = React.useState<'positive' | 'neutral' | 'negative' | null>(null);
|
||
const [feedbackMessage, setFeedbackMessage] = React.useState('');
|
||
const [feedbackBusy, setFeedbackBusy] = React.useState(false);
|
||
const [feedbackSubmitted, setFeedbackSubmitted] = React.useState(false);
|
||
const [feedbackError, setFeedbackError] = React.useState<string | undefined>(undefined);
|
||
const [archiveOpen, setArchiveOpen] = React.useState(false);
|
||
const [archiveBusy, setArchiveBusy] = React.useState(false);
|
||
const [archiveConfirmed, setArchiveConfirmed] = React.useState(false);
|
||
const [feedbackOpen, setFeedbackOpen] = React.useState(false);
|
||
const [feedbackBestArea, setFeedbackBestArea] = React.useState<string | null>(null);
|
||
const [feedbackNeedsSupport, setFeedbackNeedsSupport] = React.useState(false);
|
||
|
||
const galleryAddons = React.useMemo(
|
||
() => addonsCatalog.filter((addon) => addon.key.includes('gallery') || addon.key.includes('boost')),
|
||
[addonsCatalog],
|
||
);
|
||
const addonsToShow = galleryAddons.length ? galleryAddons : addonsCatalog;
|
||
const defaultAddon = addonsToShow[0] ?? null;
|
||
|
||
const describeAddon = React.useCallback((addon: EventAddonCatalogItem): string | null => {
|
||
const increments = addon.increments ?? {};
|
||
const photos = (increments as Record<string, number | undefined>).photos ?? (increments as Record<string, number | undefined>).extra_photos;
|
||
const guests = (increments as Record<string, number | undefined>).guests ?? (increments as Record<string, number | undefined>).extra_guests;
|
||
const galleryDays = (increments as Record<string, number | undefined>).gallery_days
|
||
?? (increments as Record<string, number | undefined>).extra_gallery_days;
|
||
|
||
const parts: string[] = [];
|
||
|
||
if (typeof photos === 'number' && photos > 0) {
|
||
parts.push(t('events.sections.addons.summary.photos', `+${photos} Fotos`, { count: photos.toLocaleString() }));
|
||
}
|
||
|
||
if (typeof guests === 'number' && guests > 0) {
|
||
parts.push(t('events.sections.addons.summary.guests', `+${guests} Gäste`, { count: guests.toLocaleString() }));
|
||
}
|
||
|
||
if (typeof galleryDays === 'number' && galleryDays > 0) {
|
||
parts.push(t('events.sections.addons.summary.gallery', `+${galleryDays} Tage`, { count: galleryDays }));
|
||
}
|
||
|
||
return parts.length ? parts.join(' · ') : null;
|
||
}, [t]);
|
||
|
||
const copy = {
|
||
positive: t('events.feedback.positive', 'War super'),
|
||
neutral: t('events.feedback.neutral', 'In Ordnung'),
|
||
negative: t('events.feedback.negative', 'Brauch(t)e Unterstützung'),
|
||
};
|
||
|
||
const bestAreaOptions = [
|
||
{ key: 'uploads', label: t('events.feedback.best.uploads', 'Uploads & Geschwindigkeit') },
|
||
{ key: 'invites', label: t('events.feedback.best.invites', 'QR-Einladungen & Layouts') },
|
||
{ key: 'moderation', label: t('events.feedback.best.moderation', 'Moderation & Export') },
|
||
{ key: 'experience', label: t('events.feedback.best.experience', 'Allgemeine App-Erfahrung') },
|
||
];
|
||
|
||
const handleQrDownload = React.useCallback(() => {
|
||
if (!guestQrCodeDataUrl) return;
|
||
|
||
const link = document.createElement('a');
|
||
link.href = guestQrCodeDataUrl;
|
||
link.download = 'guest-gallery-qr.png';
|
||
link.click();
|
||
}, [guestQrCodeDataUrl]);
|
||
|
||
const handleQrShare = React.useCallback(async () => {
|
||
if (!guestLink) return;
|
||
|
||
if (navigator.share) {
|
||
try {
|
||
await navigator.share({
|
||
title: resolveName(event.name),
|
||
text: t('events.recap.shareGuests', 'Gäste-Galerie teilen'),
|
||
url: guestLink,
|
||
});
|
||
return;
|
||
} catch {
|
||
// Ignore share cancellation and fall back to copy.
|
||
}
|
||
}
|
||
|
||
try {
|
||
await navigator.clipboard.writeText(guestLink);
|
||
toast.success(t('events.recap.copySuccess', 'Link kopiert'));
|
||
} catch {
|
||
toast.error(t('events.recap.copyError', 'Link konnte nicht geteilt werden.'));
|
||
}
|
||
}, [event.name, guestLink, t]);
|
||
|
||
const handleFeedbackSubmit = React.useCallback(async () => {
|
||
if (feedbackBusy) return;
|
||
|
||
setFeedbackBusy(true);
|
||
setFeedbackError(undefined);
|
||
|
||
try {
|
||
await submitTenantFeedback({
|
||
category: 'event_workspace_after_event',
|
||
event_slug: event.slug,
|
||
sentiment: sentiment ?? undefined,
|
||
message: feedbackMessage.trim() ? feedbackMessage.trim() : undefined,
|
||
metadata: {
|
||
best_area: feedbackBestArea,
|
||
needs_support: feedbackNeedsSupport,
|
||
event_name: resolveName(event.name),
|
||
guest_link: guestLink,
|
||
},
|
||
});
|
||
|
||
setFeedbackSubmitted(true);
|
||
setFeedbackOpen(false);
|
||
setFeedbackMessage('');
|
||
setFeedbackNeedsSupport(false);
|
||
toast.success(t('events.feedback.submitted', 'Danke!'));
|
||
} catch (err) {
|
||
setFeedbackError(isAuthError(err)
|
||
? t('events.feedback.authError', 'Deine Session ist abgelaufen. Bitte melde dich erneut an.')
|
||
: t('events.feedback.genericError', 'Feedback konnte nicht gesendet werden.'));
|
||
} finally {
|
||
setFeedbackBusy(false);
|
||
}
|
||
}, [event.slug, event.name, feedbackBestArea, feedbackBusy, feedbackMessage, feedbackNeedsSupport, feedbackSubmitted, guestLink, sentiment, t]);
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-3xl border border-slate-200 bg-white/85 p-4 shadow-sm dark:border-white/10 dark:bg-white/5">
|
||
<div className="space-y-2">
|
||
<p className="text-[10px] font-semibold uppercase tracking-[0.35em] text-rose-500 dark:text-rose-200">
|
||
{t('events.recap.badge', 'Nachbereitung')}
|
||
</p>
|
||
<h1 className="text-xl font-semibold text-slate-900 dark:text-white">{resolveName(event.name)}</h1>
|
||
<p className="text-sm text-slate-600 dark:text-slate-300">
|
||
{t('events.recap.subtitle', 'Abschluss, Export und Galerie-Laufzeit verwalten.')}
|
||
</p>
|
||
<div className="flex flex-wrap gap-2 text-xs">
|
||
<Badge variant="secondary" className="gap-1 rounded-full bg-emerald-100 text-emerald-800 dark:bg-emerald-500/20 dark:text-emerald-100">
|
||
<Sparkles className="h-3.5 w-3.5" />
|
||
{event.status === 'published' ? t('events.status.published', 'Veröffentlicht') : t('events.status.draft', 'Entwurf')}
|
||
</Badge>
|
||
<Badge variant="outline" className="gap-1 rounded-full border-slate-200 text-slate-700 dark:border-white/10 dark:text-white">
|
||
<Clock3 className="h-3.5 w-3.5" />
|
||
{event.event_date ? formatEventDate(event.event_date, undefined) : t('events.workspace.noDate', 'Kein Datum')}
|
||
</Badge>
|
||
</div>
|
||
</div>
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<Button variant="outline" size="sm" onClick={onBack} className="rounded-full border-pink-200 text-pink-600 hover:bg-pink-50">
|
||
<ArrowLeft className="h-4 w-4" /> {t('events.actions.backToList', 'Zurück zur Liste')}
|
||
</Button>
|
||
<Button variant="outline" size="sm" onClick={onEditEvent} className="rounded-full border-fuchsia-200 text-fuchsia-700 hover:bg-fuchsia-50">
|
||
<Sparkles className="h-4 w-4" /> {t('events.actions.edit', 'Bearbeiten')}
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={onToggleEvent}
|
||
disabled={busy}
|
||
className="rounded-full border-slate-200"
|
||
>
|
||
{busy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Sparkles className="mr-2 h-4 w-4" />}
|
||
{event.is_active ? t('events.recap.closeGallery', 'Galerie schließen') : t('events.recap.openGallery', 'Galerie öffnen')}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid gap-4 lg:grid-cols-2">
|
||
<div className="rounded-3xl border border-slate-200 bg-white/85 p-5 shadow-sm dark:border-white/10 dark:bg-white/5">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div>
|
||
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-emerald-500">
|
||
{t('events.recap.galleryTitle', 'Galerie-Status')}
|
||
</p>
|
||
<p className="mt-1 text-lg font-semibold text-slate-900 dark:text-white">{galleryStatusLabel}</p>
|
||
<p className="text-sm text-slate-600 dark:text-slate-300">
|
||
{t('events.recap.galleryCounts', '{{photos}} Fotos, {{pending}} offen, {{likes}} Likes', counts)}
|
||
</p>
|
||
</div>
|
||
<Badge variant={event.is_active ? 'default' : 'outline'} className="bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-100">
|
||
{event.is_active ? t('events.recap.open', 'Offen') : t('events.recap.closed', 'Geschlossen')}
|
||
</Badge>
|
||
</div>
|
||
<div className="mt-4 flex flex-wrap gap-2">
|
||
<Button size="sm" variant={event.is_active ? 'secondary' : 'default'} disabled={busy} onClick={onToggleEvent}>
|
||
{busy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Sparkles className="mr-2 h-4 w-4" />}
|
||
{event.is_active ? t('events.recap.closeGallery', 'Galerie schließen') : t('events.recap.openGallery', 'Galerie öffnen')}
|
||
</Button>
|
||
<Button size="sm" variant="outline" onClick={onOpenPhotos}>
|
||
<Camera className="mr-2 h-4 w-4" />
|
||
{t('events.recap.moderate', 'Uploads ansehen')}
|
||
</Button>
|
||
</div>
|
||
<div className="mt-4 space-y-3 rounded-2xl border border-dashed border-emerald-200 bg-emerald-50/60 p-3 text-sm text-emerald-900 dark:border-emerald-900/40 dark:bg-emerald-900/20 dark:text-emerald-50">
|
||
<div className="flex flex-wrap items-center gap-2 sm:flex-nowrap">
|
||
<div className="min-w-0 flex-1">
|
||
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-emerald-600 dark:text-emerald-200">
|
||
{t('events.recap.shareLink', 'Gäste-Link')}
|
||
</p>
|
||
{guestLink ? (
|
||
<span className="block truncate text-emerald-900" title={guestLink}>{guestLink}</span>
|
||
) : (
|
||
<p className="text-xs text-emerald-800/80 dark:text-emerald-100">
|
||
{t('events.recap.noPublicUrl', 'Kein Gäste-Link gesetzt. Lege den öffentlichen Link im Event-Setup fest.')}
|
||
</p>
|
||
)}
|
||
</div>
|
||
{guestLink && onCopyLink ? (
|
||
<Button size="sm" variant="secondary" className="rounded-full bg-emerald-600 text-white hover:bg-emerald-700" onClick={onCopyLink}>
|
||
{t('events.recap.copyLink', 'Link kopieren')}
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
<div className="grid gap-3 sm:grid-cols-2">
|
||
<div className="flex items-center justify-between rounded-xl border border-emerald-100/70 bg-white/80 px-3 py-2 text-sm text-emerald-900 dark:border-emerald-900/40 dark:bg-white/5 dark:text-emerald-50">
|
||
<div>
|
||
<p className="text-xs uppercase tracking-[0.2em] text-emerald-500">{t('events.recap.allowDownloads', 'Downloads erlauben')}</p>
|
||
<p className="text-[11px] text-emerald-600 dark:text-emerald-200">{t('events.recap.allowDownloadsHint', 'Gäste dürfen Fotos speichern')}</p>
|
||
</div>
|
||
<Switch
|
||
checked={Boolean((event.settings as any)?.guest_downloads_enabled ?? true)}
|
||
onCheckedChange={(checked) => onToggleDownloads(Boolean(checked))}
|
||
disabled={settingsBusy}
|
||
/>
|
||
</div>
|
||
<div className="flex items-center justify-between rounded-xl border border-emerald-100/70 bg-white/80 px-3 py-2 text-sm text-emerald-900 dark:border-emerald-900/40 dark:bg-white/5 dark:text-emerald-50">
|
||
<div>
|
||
<p className="text-xs uppercase tracking-[0.2em] text-emerald-500">{t('events.recap.allowSharing', 'Teilen erlauben')}</p>
|
||
<p className="text-[11px] text-emerald-600 dark:text-emerald-200">{t('events.recap.allowSharingHint', 'Gäste dürfen Links teilen')}</p>
|
||
</div>
|
||
<Switch
|
||
checked={Boolean((event.settings as any)?.guest_sharing_enabled ?? true)}
|
||
onCheckedChange={(checked) => onToggleSharing(Boolean(checked))}
|
||
disabled={settingsBusy}
|
||
/>
|
||
</div>
|
||
</div>
|
||
{guestQrCodeDataUrl ? (
|
||
<div className="mt-2 grid gap-3 rounded-2xl border border-emerald-100/80 bg-white/90 p-3 text-emerald-900 shadow-sm dark:border-emerald-900/40 dark:bg-white/10 dark:text-emerald-50 sm:grid-cols-[auto,1fr]">
|
||
<div className="flex items-center justify-center rounded-xl border border-emerald-100/70 bg-white/70 p-2 dark:border-emerald-900/50 dark:bg-emerald-900/30">
|
||
<img src={guestQrCodeDataUrl} alt={t('events.recap.qrAlt', 'QR-Code zur Gäste-Galerie')} className="h-28 w-28 rounded-lg" />
|
||
</div>
|
||
<div className="flex min-w-0 flex-col gap-2">
|
||
<div>
|
||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-emerald-500">{t('events.recap.qrTitle', 'QR-Code teilen')}</p>
|
||
{guestLink ? (
|
||
<p className="truncate text-sm text-emerald-800 dark:text-emerald-100" title={guestLink}>{guestLink}</p>
|
||
) : null}
|
||
</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
<Button size="sm" variant="secondary" className="bg-emerald-600 text-white hover:bg-emerald-700" onClick={handleQrDownload}>
|
||
{t('events.recap.qrDownload', 'QR-Code herunterladen')}
|
||
</Button>
|
||
<Button size="sm" variant="outline" onClick={handleQrShare}>
|
||
{t('events.recap.qrShare', 'Link/QR teilen')}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="rounded-3xl border border-slate-200 bg-white/85 p-5 shadow-sm dark:border-white/10 dark:bg-white/5">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div>
|
||
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-indigo-500">
|
||
{t('events.recap.exportTitle', 'Export & Backup')}
|
||
</p>
|
||
<p className="mt-1 text-lg font-semibold text-slate-900 dark:text-white">
|
||
{t('events.recap.exportCopy', 'Alle Assets sichern')}
|
||
</p>
|
||
<p className="text-sm text-slate-600 dark:text-slate-300">
|
||
{t('events.recap.exportHint', 'Zip/CSV Export und Backup anstoßen.')}
|
||
</p>
|
||
</div>
|
||
<Badge variant="outline" className="border-indigo-200 text-indigo-700 dark:border-indigo-800 dark:text-indigo-200">
|
||
{t('events.recap.backup', 'Backup')}
|
||
</Badge>
|
||
</div>
|
||
<div className="mt-4 flex flex-wrap gap-2">
|
||
<Button size="sm" onClick={onOpenPhotos}>
|
||
<Printer className="mr-2 h-4 w-4" />
|
||
{t('events.recap.downloadAll', 'Alles herunterladen')}
|
||
</Button>
|
||
<Button size="sm" variant="outline" onClick={onOpenPhotos}>
|
||
<Printer className="mr-2 h-4 w-4" />
|
||
{t('events.recap.downloadHighlights', 'Highlights herunterladen')}
|
||
</Button>
|
||
</div>
|
||
<p className="mt-2 text-xs text-slate-500 dark:text-slate-400">
|
||
{t('events.recap.highlightsHint', '“Highlights” = als Highlight markierte Fotos in der Galerie.')}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid gap-4 lg:grid-cols-2">
|
||
<div className="rounded-3xl border border-slate-200 bg-white/85 p-5 shadow-sm dark:border-white/10 dark:bg-white/5">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div>
|
||
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-amber-500">
|
||
{t('events.recap.retentionTitle', 'Verlängerung / Archivierung')}
|
||
</p>
|
||
<p className="mt-1 text-lg font-semibold text-slate-900 dark:text-white">
|
||
{galleryExpiresAt
|
||
? t('events.recap.expiresAt', 'Läuft ab am {{date}}', { date: formatEventDate(galleryExpiresAt, undefined) })
|
||
: t('events.recap.noExpiry', 'Ablaufdatum nicht gesetzt')}
|
||
</p>
|
||
<p className="text-sm text-slate-600 dark:text-slate-300">
|
||
{t('events.recap.retentionHint', 'Verlängere die Galerie-Laufzeit mit einem Add-on. Verlängerungen addieren sich.')}
|
||
</p>
|
||
</div>
|
||
<Badge variant="outline" className="border-amber-200 text-amber-700 dark:border-amber-800 dark:text-amber-100">
|
||
{t('events.recap.expiry', 'Ablauf')}
|
||
</Badge>
|
||
</div>
|
||
<div className="mt-4 flex flex-wrap gap-2">
|
||
<Button
|
||
size="sm"
|
||
variant="default"
|
||
disabled={!defaultAddon || addonBusyKey === defaultAddon?.key}
|
||
onClick={() => defaultAddon && onCheckoutAddon(defaultAddon.key)}
|
||
>
|
||
{addonBusyKey === defaultAddon?.key ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Clock3 className="mr-2 h-4 w-4" />}
|
||
{defaultAddon?.label ?? t('events.actions.extendGallery', 'Galerie verlängern')}
|
||
</Button>
|
||
<Button size="sm" variant="outline" onClick={() => setArchiveOpen(true)}>
|
||
{t('events.recap.archive', 'Archivieren/Löschen')}
|
||
</Button>
|
||
</div>
|
||
|
||
{addonsToShow.length ? (
|
||
<div className="mt-4 space-y-3 rounded-2xl border border-amber-100/80 bg-white/80 p-3 text-sm text-slate-700 shadow-sm dark:border-amber-900/40 dark:bg-white/10 dark:text-slate-200">
|
||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-amber-600 dark:text-amber-200">
|
||
{t('events.recap.extendOptions', 'Alle Add-ons für dieses Event')}
|
||
</p>
|
||
|
||
<div className="space-y-2">
|
||
{addonsToShow.map((addon) => (
|
||
<div
|
||
key={addon.key}
|
||
className="flex flex-col gap-2 rounded-xl border border-amber-100/60 bg-white/80 p-3 shadow-sm dark:border-amber-900/40 dark:bg-amber-900/20 sm:flex-row sm:items-center sm:justify-between"
|
||
>
|
||
<div className="min-w-0 space-y-1">
|
||
<p className="truncate font-semibold text-slate-900 dark:text-white" title={addon.label}>
|
||
{addon.label}
|
||
</p>
|
||
<p className="text-xs text-slate-500 dark:text-slate-300">
|
||
{describeAddon(addon) ?? t('events.recap.extendHint', 'Laufzeitverlängerungen addieren sich. Checkout öffnet in einem neuen Tab.')}
|
||
</p>
|
||
</div>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
disabled={!addon.price_id || addonBusyKey === addon.key}
|
||
onClick={() => onCheckoutAddon(addon.key)}
|
||
>
|
||
{addonBusyKey === addon.key ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <ShoppingCart className="mr-2 h-4 w-4" />}
|
||
{addon.price_id ? t('addons.buyNow', 'Jetzt freischalten') : t('events.recap.priceMissing', 'Preis nicht verknüpft')}
|
||
</Button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<p className="text-[11px] text-slate-500 dark:text-slate-400">
|
||
{t('events.recap.extendHint', 'Laufzeitverlängerungen addieren sich. Checkout öffnet in einem neuen Tab.')}
|
||
</p>
|
||
</div>
|
||
) : (
|
||
<p className="mt-4 text-xs text-slate-500 dark:text-slate-300">
|
||
{t('events.recap.noAddons', 'Aktuell keine Add-ons verfügbar.')}
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
<div className="rounded-3xl border border-slate-200 bg-white/85 p-5 shadow-sm dark:border-white/10 dark:bg-white/5">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="space-y-1">
|
||
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-rose-500">
|
||
{t('events.feedback.badge', 'Feedback')}
|
||
</p>
|
||
<p className="text-lg font-semibold text-slate-900 dark:text-white">
|
||
{t('events.feedback.afterEventTitle', 'Event beendet – kurzes Feedback?')}
|
||
</p>
|
||
<p className="text-sm text-slate-600 dark:text-slate-300">
|
||
{t('events.feedback.afterEventCopy', 'Hat alles geklappt? Deine Antwort hilft uns für kommende Events.')}<br />
|
||
<span className="text-[11px] text-slate-500">{t('events.feedback.privacyHint', 'Nur Admin-Feedback, keine Gastdaten')}</span>
|
||
</p>
|
||
</div>
|
||
<Badge variant="outline" className="border-rose-200 text-rose-700 dark:border-rose-800 dark:text-rose-100">
|
||
{t('events.feedback.badgeShort', 'Feedback')}
|
||
</Badge>
|
||
</div>
|
||
|
||
<div className="mt-4 flex flex-wrap gap-3 text-sm text-slate-600 dark:text-slate-300">
|
||
<Badge variant="secondary" className="rounded-full bg-rose-100 text-rose-700 dark:bg-rose-500/20 dark:text-rose-50">
|
||
{resolveName(event.name)}
|
||
</Badge>
|
||
{event.event_date ? (
|
||
<Badge variant="outline" className="rounded-full border-slate-200 text-slate-700 dark:border-white/10 dark:text-white">
|
||
{formatEventDate(event.event_date, undefined)}
|
||
</Badge>
|
||
) : null}
|
||
</div>
|
||
|
||
{feedbackSubmitted ? (
|
||
<div className="mt-4 rounded-2xl border border-emerald-200/60 bg-emerald-50/80 p-4 text-emerald-900 shadow-sm dark:border-emerald-900/40 dark:bg-emerald-900/20 dark:text-emerald-50">
|
||
<p className="font-semibold">{t('events.feedback.submitted', 'Danke!')}</p>
|
||
<p className="text-sm">{t('events.feedback.afterEventThanks', 'Dein Feedback ist angekommen. Wir melden uns, falls Rückfragen bestehen.')}</p>
|
||
<div className="mt-3 flex flex-wrap gap-2">
|
||
<Button size="sm" variant="outline" onClick={() => { setFeedbackSubmitted(false); setFeedbackOpen(true); }}>
|
||
{t('events.feedback.sendAnother', 'Weiteres Feedback senden')}
|
||
</Button>
|
||
<Button size="sm" variant="secondary" className="bg-rose-600 text-white hover:bg-rose-700" onClick={() => { setFeedbackNeedsSupport(true); setFeedbackSubmitted(false); setFeedbackOpen(true); }}>
|
||
{t('events.feedback.supportFollowup', 'Support anfragen')}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="mt-4 flex flex-wrap items-center gap-3">
|
||
<Button size="sm" onClick={() => setFeedbackOpen(true)} disabled={feedbackBusy}>
|
||
<MessageSquare className="mr-2 h-4 w-4" />
|
||
{t('events.feedback.cta', 'Feedback geben')}
|
||
</Button>
|
||
<div className="flex flex-wrap gap-2 text-xs text-slate-500">
|
||
<span>{t('events.feedback.quickSentiment', 'Stimmung auswählbar (positiv/neutral/Support).')}</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{feedbackError ? (
|
||
<Alert variant="destructive" className="mt-4">
|
||
<AlertTitle>{t('events.feedback.errorTitle', 'Feedback konnte nicht gesendet werden.')}</AlertTitle>
|
||
<AlertDescription>{feedbackError}</AlertDescription>
|
||
</Alert>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
|
||
<Dialog open={feedbackOpen} onOpenChange={(open) => { setFeedbackOpen(open); setFeedbackError(undefined); }}>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>{t('events.feedback.dialogTitle', 'Kurzes After-Event Feedback')}</DialogTitle>
|
||
<DialogDescription>
|
||
{t('events.feedback.dialogCopy', 'Wähle eine Stimmung, was am besten lief und optional, was wir verbessern sollen.')}
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
<div className="space-y-3">
|
||
<div>
|
||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">{t('events.feedback.sentiment', 'Stimmung')}</p>
|
||
<div className="mt-2 flex flex-wrap gap-2">
|
||
{(Object.keys(copy) as Array<'positive' | 'neutral' | 'negative'>).map((key) => (
|
||
<Button
|
||
key={key}
|
||
type="button"
|
||
size="sm"
|
||
variant={sentiment === key ? 'default' : 'outline'}
|
||
className={sentiment === key ? 'bg-slate-900 text-white' : 'border-slate-300 text-slate-700'}
|
||
onClick={() => setSentiment(key)}
|
||
>
|
||
{copy[key]}
|
||
</Button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">{t('events.feedback.bestQuestion', 'Was lief am besten?')}</p>
|
||
<div className="mt-2 grid gap-2 sm:grid-cols-2">
|
||
{bestAreaOptions.map((option) => (
|
||
<Button
|
||
key={option.key}
|
||
type="button"
|
||
variant={feedbackBestArea === option.key ? 'secondary' : 'outline'}
|
||
className={feedbackBestArea === option.key ? 'border-slate-900 bg-slate-900 text-white' : 'border-slate-200 text-slate-700'}
|
||
onClick={() => setFeedbackBestArea(option.key)}
|
||
>
|
||
{option.label}
|
||
</Button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-1">
|
||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">{t('events.feedback.improve', 'Was sollen wir verbessern?')}</p>
|
||
<textarea
|
||
value={feedbackMessage}
|
||
onChange={(event) => setFeedbackMessage(event.target.value)}
|
||
placeholder={t('events.feedback.placeholder', 'Optional: Lass uns wissen, was gut funktioniert oder wo du Unterstützung brauchst.')}
|
||
className="min-h-[120px] w-full rounded-lg border border-slate-200 bg-white/90 p-3 text-sm text-slate-700 outline-none focus:border-slate-400 focus:ring-1 focus:ring-slate-300 dark:border-white/10 dark:bg-white/5 dark:text-slate-100"
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex items-start gap-2 rounded-lg border border-slate-200 bg-slate-50/70 p-3 text-xs text-slate-700 dark:border-white/10 dark:bg-white/5 dark:text-slate-100">
|
||
<Checkbox
|
||
id="needs-support"
|
||
checked={feedbackNeedsSupport}
|
||
onCheckedChange={(checked) => setFeedbackNeedsSupport(Boolean(checked))}
|
||
className="mt-0.5"
|
||
/>
|
||
<Label htmlFor="needs-support" className="cursor-pointer text-sm leading-5">
|
||
{t('events.feedback.supportHelp', 'Ich hätte gern ein kurzes Follow-up (Support).')}
|
||
</Label>
|
||
</div>
|
||
</div>
|
||
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => setFeedbackOpen(false)} disabled={feedbackBusy}>
|
||
{t('common.cancel', 'Abbrechen')}
|
||
</Button>
|
||
<Button onClick={() => { void handleFeedbackSubmit(); }} disabled={feedbackBusy}>
|
||
{feedbackBusy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <MessageSquare className="mr-2 h-4 w-4" />}
|
||
{t('events.feedback.submit', 'Feedback senden')}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
<Dialog open={archiveOpen} onOpenChange={(open) => {
|
||
setArchiveOpen(open);
|
||
if (!open) {
|
||
setArchiveConfirmed(false);
|
||
}
|
||
}}>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>{t('events.recap.archiveTitle', 'Event archivieren')}</DialogTitle>
|
||
<DialogDescription>
|
||
{t('events.recap.archiveDesc', 'Das Archivieren schließt die Galerie, deaktiviert Gäste-Links und stoppt neue Uploads. Exporte solltest du vorher abschließen.')}
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
<div className="space-y-2 rounded-lg border border-amber-200/60 bg-amber-50/70 p-3 text-sm text-amber-900 dark:border-amber-900/40 dark:bg-amber-900/30 dark:text-amber-50">
|
||
<p className="font-semibold">{t('events.recap.archiveImpact', 'Was passiert?')}</p>
|
||
<ul className="list-disc space-y-1 pl-4 text-amber-900 dark:text-amber-50">
|
||
<li>{t('events.recap.archiveImpactClose', 'Gäste-Zugriff wird beendet, Uploads/Downloads werden deaktiviert.')}</li>
|
||
<li>{t('events.recap.archiveImpactLinks', 'Öffentliche Links und QR-Codes werden ungültig, bestehende Sessions laufen aus.')}</li>
|
||
<li>{t('events.recap.archiveImpactData', 'Daten bleiben intern für Compliance & Support sichtbar, können aber auf Anfrage gelöscht werden (DSGVO).')}</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div className="flex items-start gap-3 rounded-lg border border-slate-200 bg-slate-50/70 p-3 text-sm text-slate-800 dark:border-white/10 dark:bg-white/5 dark:text-slate-100">
|
||
<Checkbox
|
||
id="archive-confirm"
|
||
checked={archiveConfirmed}
|
||
onCheckedChange={(checked) => setArchiveConfirmed(Boolean(checked))}
|
||
className="mt-0.5"
|
||
/>
|
||
<Label htmlFor="archive-confirm" className="cursor-pointer text-sm leading-5">
|
||
{t('events.recap.archiveConfirm', 'Ich habe Exporte abgeschlossen und möchte die Galerie jetzt archivieren.')}
|
||
</Label>
|
||
</div>
|
||
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => setArchiveOpen(false)} disabled={archiveBusy}>
|
||
{t('common.cancel', 'Abbrechen')}
|
||
</Button>
|
||
<Button variant="destructive" onClick={onArchive} disabled={!archiveConfirmed || archiveBusy}>
|
||
{archiveBusy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||
{t('events.recap.archiveConfirmCta', 'Archivierung starten')}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function resolveName(name: TenantEvent['name']): string {
|
||
if (typeof name === 'string' && name.trim().length > 0) {
|
||
return name.trim();
|
||
}
|
||
|
||
if (name && typeof name === 'object') {
|
||
return name.de ?? name.en ?? Object.values(name)[0] ?? 'Event';
|
||
}
|
||
|
||
return 'Event';
|
||
}
|
||
|
||
function buildAbsoluteGuestLink(link: string | null): string | null {
|
||
if (!link) return null;
|
||
|
||
try {
|
||
const base = typeof window !== 'undefined' ? window.location.origin : undefined;
|
||
return base ? new URL(link, base).toString() : new URL(link).toString();
|
||
} catch {
|
||
return link;
|
||
}
|
||
}
|
||
|
||
function WorkspaceSkeleton() {
|
||
return (
|
||
<div className="space-y-6">
|
||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.6fr)_minmax(0,0.6fr)]">
|
||
<SkeletonCard />
|
||
<SkeletonCard />
|
||
</div>
|
||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||
{Array.from({ length: 4 }).map((_, index) => (
|
||
<SkeletonCard key={`recap-metric-skeleton-${index}`} />
|
||
))}
|
||
</div>
|
||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.4fr)_minmax(0,0.8fr)]">
|
||
<SkeletonCard />
|
||
<SkeletonCard />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SkeletonCard() {
|
||
return <div className="h-40 animate-pulse rounded-2xl bg-gradient-to-r from-slate-100 via-white to-slate-100" />;
|
||
}
|