1569 lines
62 KiB
TypeScript
1569 lines
62 KiB
TypeScript
// @ts-nocheck
|
||
import React from 'react';
|
||
import { useNavigate, useParams, useSearchParams, useLocation } from 'react-router-dom';
|
||
import { useTranslation } from 'react-i18next';
|
||
import {
|
||
AlertTriangle,
|
||
ArrowLeft,
|
||
Bell,
|
||
Camera,
|
||
CheckCircle2,
|
||
Circle,
|
||
Clock3,
|
||
Loader2,
|
||
MessageSquare,
|
||
Printer,
|
||
QrCode,
|
||
PlugZap,
|
||
RefreshCw,
|
||
Smile,
|
||
Sparkles,
|
||
ShoppingCart,
|
||
Users,
|
||
} 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 { AdminLayout } from '../components/AdminLayout';
|
||
import {
|
||
EventToolkit,
|
||
EventToolkitTask,
|
||
TenantEmotion,
|
||
TenantEvent,
|
||
TenantPhoto,
|
||
EventStats,
|
||
getEvent,
|
||
getEventStats,
|
||
getEventToolkit,
|
||
toggleEvent,
|
||
submitTenantFeedback,
|
||
updatePhotoVisibility,
|
||
createEventAddonCheckout,
|
||
featurePhoto,
|
||
unfeaturePhoto,
|
||
getEmotions,
|
||
} from '../api';
|
||
import { buildLimitWarnings } from '../lib/limitWarnings';
|
||
import { getApiErrorMessage } from '../lib/apiError';
|
||
import { isAuthError } from '../auth/tokens';
|
||
import {
|
||
ADMIN_EVENTS_PATH,
|
||
ADMIN_EVENT_EDIT_PATH,
|
||
ADMIN_EVENT_INVITES_PATH,
|
||
ADMIN_EVENT_MEMBERS_PATH,
|
||
ADMIN_EVENT_PHOTOBOOTH_PATH,
|
||
ADMIN_EVENT_PHOTOS_PATH,
|
||
ADMIN_EVENT_TASKS_PATH,
|
||
buildEngagementTabPath,
|
||
} from '../constants';
|
||
import {
|
||
SectionCard,
|
||
SectionHeader,
|
||
ActionGrid,
|
||
TenantHeroCard,
|
||
} from '../components/tenant';
|
||
import { AddonsPicker } from '../components/Addons/AddonsPicker';
|
||
import { AddonSummaryList } from '../components/Addons/AddonSummaryList';
|
||
import { EventAddonCatalogItem, getAddonCatalog } from '../api';
|
||
import { GuestBroadcastCard } from '../components/GuestBroadcastCard';
|
||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||
import { filterEmotionsByEventType } from '../lib/emotions';
|
||
import { buildEventTabs } from '../lib/eventTabs';
|
||
|
||
type EventDetailPageProps = {
|
||
mode?: 'detail' | 'toolkit';
|
||
};
|
||
|
||
type ToolkitState = {
|
||
data: EventToolkit | null;
|
||
loading: boolean;
|
||
error: string | null;
|
||
};
|
||
|
||
type WorkspaceState = {
|
||
event: TenantEvent | null;
|
||
stats: EventStats | null;
|
||
loading: boolean;
|
||
busy: boolean;
|
||
error: string | null;
|
||
};
|
||
|
||
export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProps) {
|
||
const { slug: slugParam } = useParams<{ slug?: string }>();
|
||
const [searchParams] = useSearchParams();
|
||
const navigate = useNavigate();
|
||
const location = useLocation();
|
||
const { t } = useTranslation('management');
|
||
const { t: tCommon } = useTranslation('common');
|
||
|
||
const slug = slugParam ?? null;
|
||
|
||
const [state, setState] = React.useState<WorkspaceState>({
|
||
event: null,
|
||
stats: null,
|
||
loading: true,
|
||
busy: false,
|
||
error: null,
|
||
});
|
||
|
||
const [toolkit, setToolkit] = React.useState<ToolkitState>({ data: null, loading: true, error: null });
|
||
const [addonBusyId, setAddonBusyId] = React.useState<string | null>(null);
|
||
const [addonRefreshCount, setAddonRefreshCount] = React.useState(0);
|
||
const [addonsCatalog, setAddonsCatalog] = React.useState<EventAddonCatalogItem[]>([]);
|
||
const [emotions, setEmotions] = React.useState<TenantEmotion[]>([]);
|
||
|
||
const load = React.useCallback(async () => {
|
||
if (!slug) {
|
||
setState({ event: null, stats: null, loading: false, busy: false, error: t('events.errors.missingSlug', 'Kein Event ausgewählt.') });
|
||
setToolkit({ data: null, loading: false, error: null });
|
||
return;
|
||
}
|
||
|
||
setState((prev) => ({ ...prev, loading: true, error: null }));
|
||
setToolkit((prev) => ({ ...prev, loading: true, error: null }));
|
||
|
||
try {
|
||
const [eventData, statsData, addonOptions] = await Promise.all([getEvent(slug), getEventStats(slug), getAddonCatalog()]);
|
||
setState((prev) => ({ ...prev, event: eventData, stats: statsData, loading: false }));
|
||
setAddonsCatalog(addonOptions);
|
||
} catch (error) {
|
||
if (!isAuthError(error)) {
|
||
setState((prev) => ({
|
||
...prev,
|
||
error: getApiErrorMessage(error, t('events.errors.loadFailed', 'Event konnte nicht geladen werden.')),
|
||
loading: false,
|
||
}));
|
||
}
|
||
}
|
||
|
||
try {
|
||
const toolkitData = await getEventToolkit(slug);
|
||
setToolkit({ data: toolkitData, loading: false, error: null });
|
||
} catch (error) {
|
||
if (!isAuthError(error)) {
|
||
setToolkit({
|
||
data: null,
|
||
loading: false,
|
||
error: getApiErrorMessage(error, t('toolkit.errors.loadFailed', 'Toolkit-Daten konnten nicht geladen werden.')),
|
||
});
|
||
}
|
||
}
|
||
}, [slug, t]);
|
||
|
||
React.useEffect(() => {
|
||
void load();
|
||
}, [load]);
|
||
|
||
React.useEffect(() => {
|
||
let cancelled = false;
|
||
(async () => {
|
||
try {
|
||
const list = await getEmotions();
|
||
if (!cancelled) {
|
||
setEmotions(list);
|
||
}
|
||
} catch (error) {
|
||
if (!isAuthError(error)) {
|
||
console.warn('Failed to load emotions for event detail', error);
|
||
}
|
||
}
|
||
})();
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, []);
|
||
|
||
async function handleToggle(): Promise<void> {
|
||
if (!slug) {
|
||
return;
|
||
}
|
||
|
||
setState((prev) => ({ ...prev, busy: true, error: null }));
|
||
|
||
try {
|
||
const updated = await toggleEvent(slug);
|
||
setState((prev) => ({
|
||
...prev,
|
||
busy: false,
|
||
event: updated,
|
||
stats: prev.stats
|
||
? {
|
||
...prev.stats,
|
||
status: updated.status,
|
||
is_active: Boolean(updated.is_active),
|
||
}
|
||
: prev.stats,
|
||
}));
|
||
} catch (error) {
|
||
if (!isAuthError(error)) {
|
||
setState((prev) => ({
|
||
...prev,
|
||
busy: false,
|
||
error: getApiErrorMessage(error, t('events.errors.toggleFailed', 'Status konnte nicht angepasst werden.')),
|
||
}));
|
||
} else {
|
||
setState((prev) => ({ ...prev, busy: false }));
|
||
}
|
||
}
|
||
}
|
||
|
||
const { event, stats, loading, busy, error } = state;
|
||
const toolkitData = toolkit.data;
|
||
|
||
const eventName = event ? resolveName(event.name) : t('events.placeholders.untitled', 'Unbenanntes Event');
|
||
const subtitle = mode === 'toolkit'
|
||
? t('events.workspace.toolkitSubtitle', 'Moderation, Aufgaben und Einladungen für deinen Eventtag bündeln.')
|
||
: t('events.workspace.detailSubtitle', 'Behalte Status, Aufgaben und Einladungen deines Events im Blick.');
|
||
|
||
const limitWarnings = React.useMemo(
|
||
() => (event?.limits ? buildLimitWarnings(event.limits, (key, options) => tCommon(`limits.${key}`, options)) : []),
|
||
[event?.limits, tCommon],
|
||
);
|
||
const [dismissedWarnings, setDismissedWarnings] = React.useState<Set<string>>(new Set());
|
||
|
||
React.useEffect(() => {
|
||
const slug = event?.slug;
|
||
if (!slug || typeof window === 'undefined') {
|
||
setDismissedWarnings(new Set());
|
||
return;
|
||
}
|
||
try {
|
||
const raw = window.localStorage.getItem(`tenant-admin:dismissed-limit-warnings:${slug}`);
|
||
if (!raw) {
|
||
setDismissedWarnings(new Set());
|
||
return;
|
||
}
|
||
const parsed = JSON.parse(raw) as string[];
|
||
setDismissedWarnings(new Set(parsed));
|
||
} catch {
|
||
setDismissedWarnings(new Set());
|
||
}
|
||
}, [event?.slug]);
|
||
|
||
const visibleWarnings = React.useMemo(
|
||
() => limitWarnings.filter((warning) => !dismissedWarnings.has(warning.id)),
|
||
[limitWarnings, dismissedWarnings],
|
||
);
|
||
|
||
const dismissWarning = React.useCallback(
|
||
(id: string) => {
|
||
const slug = event?.slug;
|
||
setDismissedWarnings((prev) => {
|
||
const next = new Set(prev);
|
||
next.add(id);
|
||
if (slug && typeof window !== 'undefined') {
|
||
window.localStorage.setItem(
|
||
`tenant-admin:dismissed-limit-warnings:${slug}`,
|
||
JSON.stringify(Array.from(next)),
|
||
);
|
||
}
|
||
return next;
|
||
});
|
||
},
|
||
[event?.slug],
|
||
);
|
||
|
||
const eventTabs = React.useMemo(() => {
|
||
if (!event) {
|
||
return [];
|
||
}
|
||
const translateMenu = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||
return buildEventTabs(event, translateMenu, {
|
||
photos: toolkitData?.photos?.pending?.length ?? event.photo_count ?? 0,
|
||
tasks: toolkitData?.tasks?.summary.total ?? event.tasks_count ?? 0,
|
||
invites: toolkitData?.invites?.summary.active ?? event.active_invites_count ?? event.total_invites_count ?? 0,
|
||
});
|
||
}, [event, toolkitData?.photos?.pending?.length, toolkitData?.tasks?.summary.total, toolkitData?.invites?.summary.active, t]);
|
||
|
||
const isRecapRoute = React.useMemo(
|
||
() => location.pathname.endsWith('/recap'),
|
||
[location.pathname],
|
||
);
|
||
|
||
const shownWarningToasts = React.useRef<Set<string>>(new Set());
|
||
//const [addonBusyId, setAddonBusyId] = React.useState<string | null>(null);
|
||
|
||
const handleAddonPurchase = React.useCallback(
|
||
async (scope: 'photos' | 'guests' | 'gallery', addonKeyOverride?: string) => {
|
||
if (!slug) return;
|
||
|
||
const defaultAddons: Record<typeof scope, string> = {
|
||
photos: 'extra_photos_500',
|
||
guests: 'extra_guests_100',
|
||
gallery: 'extend_gallery_30d',
|
||
};
|
||
|
||
const addonKey = addonKeyOverride ?? defaultAddons[scope];
|
||
setAddonBusyId(scope);
|
||
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) {
|
||
toast(getApiErrorMessage(err, t('events.errors.checkoutFailed', 'Add-on Checkout fehlgeschlagen.')));
|
||
} finally {
|
||
setAddonBusyId(null);
|
||
}
|
||
},
|
||
[slug, t],
|
||
);
|
||
|
||
React.useEffect(() => {
|
||
limitWarnings.forEach((warning) => {
|
||
const id = `${warning.id}-${warning.message}`;
|
||
if (shownWarningToasts.current.has(id)) {
|
||
return;
|
||
}
|
||
|
||
shownWarningToasts.current.add(id);
|
||
toast(warning.message, {
|
||
icon: warning.tone === 'danger' ? '🚨' : '⚠️',
|
||
id,
|
||
});
|
||
});
|
||
}, [limitWarnings]);
|
||
|
||
React.useEffect(() => {
|
||
const success = searchParams.get('addon_success');
|
||
if (success && slug) {
|
||
toast(t('events.success.addonApplied', 'Add-on angewendet. Limits aktualisieren sich in Kürze.'));
|
||
void load();
|
||
setAddonRefreshCount(3);
|
||
const params = new URLSearchParams(window.location.search);
|
||
params.delete('addon_success');
|
||
const search = params.toString();
|
||
navigate(search ? `${window.location.pathname}?${search}` : window.location.pathname, { replace: true });
|
||
}
|
||
}, [searchParams, slug, load, navigate, t]);
|
||
|
||
React.useEffect(() => {
|
||
if (addonRefreshCount <= 0) {
|
||
return;
|
||
}
|
||
const timer = setTimeout(() => {
|
||
void load();
|
||
setAddonRefreshCount((count) => count - 1);
|
||
}, 8000);
|
||
return () => clearTimeout(timer);
|
||
}, [addonRefreshCount, load]);
|
||
|
||
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.')}
|
||
>
|
||
<SectionCard>
|
||
<p className="text-sm text-slate-600 dark:text-slate-300">
|
||
{t('events.errors.notFoundBody', 'Ohne gültige Kennung können wir keine Daten laden. Kehre zur Eventliste zurück und wähle dort ein Event aus.')}
|
||
</p>
|
||
</SectionCard>
|
||
</AdminLayout>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<AdminLayout
|
||
title={eventName}
|
||
subtitle={subtitle}
|
||
tabs={eventTabs}
|
||
currentTabKey={isRecapRoute ? 'recap' : 'overview'}
|
||
>
|
||
{error && (
|
||
<Alert variant="destructive">
|
||
<AlertTitle>{t('events.alerts.failedTitle', 'Aktion fehlgeschlagen')}</AlertTitle>
|
||
<AlertDescription>{error}</AlertDescription>
|
||
</Alert>
|
||
)}
|
||
|
||
{visibleWarnings.length > 0 && (
|
||
<div className="mb-6 space-y-2">
|
||
{visibleWarnings.map((warning) => (
|
||
<Alert
|
||
key={warning.id}
|
||
variant={warning.tone === 'danger' ? 'destructive' : 'default'}
|
||
className={warning.tone === 'warning' ? 'border-amber-400/40 bg-amber-50 text-amber-900' : undefined}
|
||
>
|
||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||
<AlertDescription className="flex items-center gap-2 text-sm">
|
||
<AlertTriangle className="h-4 w-4" />
|
||
{warning.message}
|
||
</AlertDescription>
|
||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
||
{(['photos', 'guests', 'gallery'] as const).includes(warning.scope) ? (
|
||
<>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => { void handleAddonPurchase(warning.scope as 'photos' | 'guests' | 'gallery'); }}
|
||
disabled={addonBusyId === warning.scope}
|
||
className="justify-start"
|
||
>
|
||
<ShoppingCart className="mr-2 h-4 w-4" />
|
||
{warning.scope === 'photos'
|
||
? t('events.actions.buyMorePhotos', 'Mehr Fotos freischalten')
|
||
: warning.scope === 'guests'
|
||
? t('events.actions.buyMoreGuests', 'Mehr Gäste freischalten')
|
||
: t('events.actions.extendGallery', 'Galerie verlängern')}
|
||
</Button>
|
||
{addonsCatalog.length > 0 ? (
|
||
<AddonsPicker
|
||
addons={addonsCatalog}
|
||
scope={warning.scope as 'photos' | 'guests' | 'gallery'}
|
||
onCheckout={(key) => { void handleAddonPurchase(warning.scope as 'photos' | 'guests' | 'gallery', key); }}
|
||
busy={addonBusyId === warning.scope}
|
||
t={(key, fallback) => t(key, fallback)}
|
||
/>
|
||
) : null}
|
||
</>
|
||
) : null}
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => dismissWarning(warning.id)}
|
||
className="justify-start text-slate-600 hover:text-slate-900"
|
||
>
|
||
{tCommon('actions.dismiss', 'Hinweis ausblenden')}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</Alert>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{toolkit.error && (
|
||
<Alert variant="default">
|
||
<AlertTitle>{toolkit.error}</AlertTitle>
|
||
</Alert>
|
||
)}
|
||
|
||
{loading ? (
|
||
<WorkspaceSkeleton />
|
||
) : event ? (
|
||
<div className="space-y-6">
|
||
<EventHeroCardSection
|
||
event={event}
|
||
stats={stats}
|
||
onRefresh={() => { void load(); }}
|
||
loading={state.busy}
|
||
navigate={navigate}
|
||
/>
|
||
|
||
<Tabs defaultValue={isRecapRoute ? 'recap' : 'overview'} className="space-y-6">
|
||
<TabsList className="grid gap-2 rounded-2xl bg-slate-100/80 p-1 dark:bg-white/5 sm:grid-cols-3">
|
||
<TabsTrigger value="overview">{t('events.workspace.tabs.overview', 'Überblick')}</TabsTrigger>
|
||
<TabsTrigger value="setup">{t('events.workspace.tabs.setup', 'Vorbereitung')}</TabsTrigger>
|
||
<TabsTrigger value="recap">{t('events.workspace.tabs.recap', 'Nachbereitung')}</TabsTrigger>
|
||
</TabsList>
|
||
|
||
<TabsContent value="overview" className="space-y-6">
|
||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.6fr)_minmax(0,0.6fr)]">
|
||
<StatusCard event={event} stats={stats} busy={busy} onToggle={handleToggle} />
|
||
<QuickActionsCard slug={event.slug} busy={busy} onToggle={handleToggle} navigate={navigate} />
|
||
</div>
|
||
<MetricsGrid metrics={toolkitData?.metrics} stats={stats} />
|
||
</TabsContent>
|
||
|
||
<TabsContent value="setup" className="space-y-6">
|
||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.4fr)_minmax(0,0.8fr)]">
|
||
<TaskOverviewCard tasks={toolkitData?.tasks} navigateToTasks={() => navigate(ADMIN_EVENT_TASKS_PATH(event.slug))} />
|
||
<InviteSummary
|
||
invites={toolkitData?.invites}
|
||
navigateToInvites={() => navigate(`${ADMIN_EVENT_INVITES_PATH(event.slug)}?tab=layout`)}
|
||
/>
|
||
</div>
|
||
|
||
<BrandingMissionCard
|
||
event={event}
|
||
invites={toolkitData?.invites}
|
||
emotions={emotions}
|
||
onOpenBranding={() => navigate(`${ADMIN_EVENT_INVITES_PATH(event.slug)}?tab=layout`)}
|
||
onOpenCollections={() => navigate(buildEngagementTabPath('collections'))}
|
||
onOpenTasks={() => navigate(ADMIN_EVENT_TASKS_PATH(event.slug))}
|
||
onOpenEmotions={() => navigate(buildEngagementTabPath('emotions'))}
|
||
/>
|
||
|
||
{event.addons?.length ? (
|
||
<SectionCard>
|
||
<SectionHeader
|
||
title={t('events.sections.addons.title', 'Add-ons & Upgrades')}
|
||
description={t('events.sections.addons.description', 'Zusätzliche Kontingente für dieses Event.')}
|
||
/>
|
||
<AddonSummaryList addons={event.addons} t={(key, fallback) => t(key, fallback)} />
|
||
</SectionCard>
|
||
) : null}
|
||
</TabsContent>
|
||
|
||
<TabsContent value="recap" className="space-y-6">
|
||
<GalleryShareCard
|
||
invites={toolkitData?.invites}
|
||
onManageInvites={() => navigate(`${ADMIN_EVENT_INVITES_PATH(event.slug)}?tab=layout`)}
|
||
/>
|
||
{event.limits?.gallery ? (
|
||
<GalleryStatusCard gallery={event.limits.gallery} />
|
||
) : null}
|
||
<FeedbackCard slug={event.slug} />
|
||
</TabsContent>
|
||
</Tabs>
|
||
</div>
|
||
) : (
|
||
<SectionCard>
|
||
<p className="text-sm text-slate-600 dark:text-slate-300">
|
||
{t('events.errors.notFoundBody', 'Ohne gültige Kennung können wir keine Daten laden. Kehre zur Eventliste zurück und wähle dort ein Event aus.')}
|
||
</p>
|
||
</SectionCard>
|
||
)}
|
||
</AdminLayout>
|
||
);
|
||
}
|
||
|
||
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 EventHeroCardSection({ event, stats, onRefresh, loading, navigate }: {
|
||
event: TenantEvent;
|
||
stats: EventStats | null;
|
||
onRefresh: () => void;
|
||
loading: boolean;
|
||
navigate: ReturnType<typeof useNavigate>;
|
||
}) {
|
||
const { t } = useTranslation('management');
|
||
const statusLabel = getStatusLabel(event, t);
|
||
const supporting = [
|
||
t('events.workspace.hero.status', { defaultValue: 'Status: {{status}}', status: statusLabel }),
|
||
t('events.workspace.hero.date', { defaultValue: 'Eventdatum: {{date}}', date: formatDate(event.event_date) }),
|
||
t('events.workspace.hero.metrics', {
|
||
defaultValue: 'Uploads gesamt: {{count}} · Likes: {{likes}}',
|
||
count: stats?.uploads_total ?? stats?.total ?? 0,
|
||
likes: stats?.likes_total ?? stats?.likes ?? 0,
|
||
}),
|
||
];
|
||
|
||
const aside = (
|
||
<div className="space-y-3 text-sm text-slate-700 dark:text-slate-200">
|
||
<InfoRow
|
||
icon={<Sparkles className="h-4 w-4 text-pink-500" />}
|
||
label={t('events.workspace.fields.status', 'Status')}
|
||
value={statusLabel}
|
||
/>
|
||
<InfoRow
|
||
icon={<CalendarIcon />}
|
||
label={t('events.workspace.fields.date', 'Eventdatum')}
|
||
value={formatDate(event.event_date)}
|
||
/>
|
||
<InfoRow
|
||
icon={<Users className="h-4 w-4 text-sky-500" />}
|
||
label={t('events.workspace.fields.active', 'Aktiv für Gäste')}
|
||
value={event.is_active ? t('events.workspace.activeYes', 'Ja') : t('events.workspace.activeNo', 'Nein')}
|
||
/>
|
||
</div>
|
||
);
|
||
|
||
return (
|
||
<TenantHeroCard
|
||
badge={t('events.workspace.hero.badge', 'Event')}
|
||
title={resolveName(event.name)}
|
||
description={t('events.workspace.hero.description', 'Konzentriere dich auf Aufgaben, Moderation und Einladungen für dieses Event.')}
|
||
supporting={supporting}
|
||
primaryAction={(
|
||
<Button variant="outline" onClick={() => navigate(ADMIN_EVENTS_PATH)} 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>
|
||
)}
|
||
secondaryAction={(
|
||
<Button variant="outline" onClick={() => navigate(ADMIN_EVENT_EDIT_PATH(event.slug))} 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>
|
||
)}
|
||
aside={aside}
|
||
>
|
||
<div className="flex flex-wrap gap-2">
|
||
<Button
|
||
variant="outline"
|
||
onClick={onRefresh}
|
||
disabled={loading}
|
||
className="rounded-full border-slate-200"
|
||
>
|
||
{loading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <RefreshCw className="mr-2 h-4 w-4" />}
|
||
{t('events.actions.refresh', 'Aktualisieren')}
|
||
</Button>
|
||
</div>
|
||
</TenantHeroCard>
|
||
);
|
||
}
|
||
|
||
function StatusCard({ event, stats, busy, onToggle }: { event: TenantEvent; stats: EventStats | null; busy: boolean; onToggle: () => void }) {
|
||
const { t } = useTranslation('management');
|
||
|
||
const statusLabel = getStatusLabel(event, t);
|
||
|
||
return (
|
||
<SectionCard className="space-y-4">
|
||
<SectionHeader
|
||
eyebrow={t('events.workspace.sections.statusBadge', 'Status')}
|
||
title={t('events.workspace.sections.statusTitle', 'Eventstatus & Sichtbarkeit')}
|
||
description={t('events.workspace.sections.statusSubtitle', 'Aktiviere dein Event für Gäste oder verstecke es vorübergehend.')}
|
||
/>
|
||
<div className="space-y-4 text-sm text-slate-700 dark:text-slate-300">
|
||
<InfoRow icon={<Sparkles className="h-4 w-4 text-pink-500" />} label={t('events.workspace.fields.status', 'Status')} value={statusLabel} />
|
||
<InfoRow icon={<Circle className="h-4 w-4 text-amber-500" />} label={t('events.workspace.fields.active', 'Aktiv für Gäste')} value={event.is_active ? t('events.workspace.activeYes', 'Ja') : t('events.workspace.activeNo', 'Nein')} />
|
||
<InfoRow icon={<CalendarIcon />} label={t('events.workspace.fields.date', 'Eventdatum')} value={formatDate(event.event_date)} />
|
||
<InfoRow icon={<Smile className="h-4 w-4 text-rose-500" />} label={t('events.workspace.fields.eventType', 'Event-Typ')} value={resolveEventType(event)} />
|
||
|
||
{stats && (
|
||
<div className="rounded-xl border border-pink-100 bg-pink-50/60 p-4 text-xs text-pink-900">
|
||
<p className="font-semibold text-pink-700">{t('events.workspace.fields.insights', 'Letzte Aktivität')}</p>
|
||
<p>
|
||
{t('events.workspace.fields.uploadsTotal', {
|
||
defaultValue: '{{count}} Uploads gesamt',
|
||
count: stats.uploads_total ?? stats.total ?? 0,
|
||
})}
|
||
{' · '}
|
||
{t('events.workspace.fields.uploadsToday', {
|
||
defaultValue: '{{count}} Uploads (24h)',
|
||
count: stats.uploads_24h ?? stats.recent_uploads ?? 0,
|
||
})}
|
||
</p>
|
||
<p>
|
||
{t('events.workspace.fields.likesTotal', {
|
||
defaultValue: '{{count}} Likes vergeben',
|
||
count: stats.likes_total ?? stats.likes ?? 0,
|
||
})}
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex flex-wrap gap-2 pt-2">
|
||
<Button onClick={onToggle} disabled={busy} className="bg-gradient-to-r from-pink-500 via-rose-500 to-purple-500 text-white shadow-md shadow-rose-200/60">
|
||
{busy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : event.is_active ? <Circle className="mr-2 h-4 w-4" /> : <CheckCircle2 className="mr-2 h-4 w-4" />}
|
||
{event.is_active ? t('events.workspace.actions.pause', 'Event pausieren') : t('events.workspace.actions.activate', 'Event aktivieren')}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</SectionCard>
|
||
);
|
||
}
|
||
|
||
function QuickActionsCard({ slug, busy, onToggle, navigate }: { slug: string; busy: boolean; onToggle: () => void | Promise<void>; navigate: ReturnType<typeof useNavigate> }) {
|
||
const { t } = useTranslation('management');
|
||
|
||
const gridItems = [
|
||
{
|
||
key: 'photos',
|
||
icon: <Camera className="h-4 w-4" />,
|
||
label: t('events.quickActions.moderate', 'Fotos moderieren'),
|
||
description: t('events.quickActions.moderateDesc', 'Prüfe Uploads und veröffentliche Highlights.'),
|
||
onClick: () => navigate(ADMIN_EVENT_PHOTOS_PATH(slug)),
|
||
},
|
||
{
|
||
key: 'tasks',
|
||
icon: <Sparkles className="h-4 w-4" />,
|
||
label: t('events.quickActions.tasks', 'Aufgaben bearbeiten'),
|
||
description: t('events.quickActions.tasksDesc', 'Passe Story-Prompts und Moderation an.'),
|
||
onClick: () => navigate(ADMIN_EVENT_TASKS_PATH(slug)),
|
||
},
|
||
{
|
||
key: 'invites',
|
||
icon: <QrCode className="h-4 w-4" />,
|
||
label: t('events.quickActions.invites', 'Layouts & QR verwalten'),
|
||
description: t('events.quickActions.invitesDesc', 'Aktualisiere QR-Kits und Einladungslayouts.'),
|
||
onClick: () => navigate(`${ADMIN_EVENT_INVITES_PATH(slug)}?tab=layout`),
|
||
},
|
||
{
|
||
key: 'roles',
|
||
icon: <Users className="h-4 w-4" />,
|
||
label: t('events.quickActions.roles', 'Team & Rollen anpassen'),
|
||
description: t('events.quickActions.rolesDesc', 'Verwalte Moderatoren und Co-Leads.'),
|
||
onClick: () => navigate(ADMIN_EVENT_MEMBERS_PATH(slug)),
|
||
},
|
||
{
|
||
key: 'photobooth',
|
||
icon: <PlugZap className="h-4 w-4" />,
|
||
label: t('events.quickActions.photobooth', 'Photobooth anbinden'),
|
||
description: t('events.quickActions.photoboothDesc', 'FTP-Link aktivieren und Zugangsdaten kopieren.'),
|
||
onClick: () => navigate(ADMIN_EVENT_PHOTOBOOTH_PATH(slug)),
|
||
},
|
||
{
|
||
key: 'print',
|
||
icon: <Printer className="h-4 w-4" />,
|
||
label: t('events.quickActions.print', 'Layouts als PDF drucken'),
|
||
description: t('events.quickActions.printDesc', 'Exportiere QR-Sets für den Druck.'),
|
||
onClick: () => navigate(`${ADMIN_EVENT_INVITES_PATH(slug)}?tab=export`),
|
||
},
|
||
];
|
||
|
||
return (
|
||
<SectionCard className="space-y-4">
|
||
<SectionHeader
|
||
eyebrow={t('events.quickActions.badge', 'Schnellaktionen')}
|
||
title={t('events.quickActions.title', 'Schnellaktionen')}
|
||
description={t('events.quickActions.subtitle', 'Nutze die wichtigsten Schritte vor und während deines Events.')}
|
||
/>
|
||
<ActionGrid items={gridItems} columns={1} />
|
||
<div className="flex flex-wrap gap-2">
|
||
<Button onClick={() => { void onToggle(); }} disabled={busy} variant="outline" className="rounded-full border-rose-200 text-rose-600 hover:bg-rose-50">
|
||
{busy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <CheckCircle2 className="mr-2 h-4 w-4" />}
|
||
{t('events.quickActions.toggle', 'Status ändern')}
|
||
</Button>
|
||
</div>
|
||
</SectionCard>
|
||
);
|
||
}
|
||
|
||
function MetricsGrid({ metrics, stats }: { metrics: EventToolkit['metrics'] | null | undefined; stats: EventStats | null }) {
|
||
const { t } = useTranslation('management');
|
||
|
||
const cards = [
|
||
{
|
||
icon: <Camera className="h-5 w-5 text-emerald-500" />,
|
||
label: t('events.metrics.uploadsTotal', 'Uploads gesamt'),
|
||
value: metrics?.uploads_total ?? stats?.uploads_total ?? 0,
|
||
},
|
||
{
|
||
icon: <Camera className="h-5 w-5 text-sky-500" />,
|
||
label: t('events.metrics.uploads24h', 'Uploads (24h)'),
|
||
value: metrics?.uploads_24h ?? stats?.uploads_24h ?? 0,
|
||
},
|
||
{
|
||
icon: <AlertTriangle className="h-5 w-5 text-amber-500" />,
|
||
label: t('events.metrics.pending', 'Fotos in Moderation'),
|
||
value: metrics?.pending_photos ?? stats?.pending_photos ?? 0,
|
||
},
|
||
{
|
||
icon: <QrCode className="h-5 w-5 text-indigo-500" />,
|
||
label: t('events.metrics.activeInvites', 'Aktive Einladungen'),
|
||
value: metrics?.active_invites ?? 0,
|
||
},
|
||
];
|
||
|
||
return (
|
||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||
{cards.map((card) => (
|
||
<SectionCard key={card.label} className="p-4">
|
||
<div className="flex items-center gap-4">
|
||
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-slate-100 dark:bg-white/10">
|
||
{card.icon}
|
||
</span>
|
||
<div>
|
||
<p className="text-xs uppercase tracking-wide text-slate-500 dark:text-slate-400">{card.label}</p>
|
||
<p className="text-2xl font-semibold text-slate-900 dark:text-white">{card.value}</p>
|
||
</div>
|
||
</div>
|
||
</SectionCard>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function InviteSummary({ invites, navigateToInvites }: { invites: EventToolkit['invites'] | undefined; navigateToInvites: () => void }) {
|
||
const { t } = useTranslation('management');
|
||
|
||
return (
|
||
<SectionCard className="space-y-3">
|
||
<SectionHeader
|
||
eyebrow={t('events.invites.badge', 'Einladungen')}
|
||
title={t('events.invites.title', 'QR-Einladungen')}
|
||
description={t('events.invites.subtitle', 'Behält aktive Einladungen und Layouts im Blick.')}
|
||
/>
|
||
<div className="space-y-3 text-sm text-slate-700 dark:text-slate-300">
|
||
<div className="flex gap-2 text-sm text-slate-900">
|
||
<Badge variant="outline" className="border-amber-200 text-amber-700 dark:border-amber-500/30 dark:text-amber-200">
|
||
{t('events.invites.activeCount', { defaultValue: '{{count}} aktiv', count: invites?.summary.active ?? 0 })}
|
||
</Badge>
|
||
<Badge variant="outline" className="border-amber-200 text-amber-700 dark:border-amber-500/30 dark:text-amber-200">
|
||
{t('events.invites.totalCount', { defaultValue: '{{count}} gesamt', count: invites?.summary.total ?? 0 })}
|
||
</Badge>
|
||
</div>
|
||
|
||
{invites?.items?.length ? (
|
||
<ul className="space-y-2 text-xs">
|
||
{invites.items.slice(0, 3).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-[11px] text-amber-700">{invite.url}</p>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
) : (
|
||
<p className="text-xs text-slate-500">{t('events.invites.empty', 'Noch keine Einladungen erstellt.')}</p>
|
||
)}
|
||
|
||
<Button variant="outline" onClick={navigateToInvites} className="border-amber-200 text-amber-700 hover:bg-amber-50">
|
||
<QrCode className="mr-2 h-4 w-4" /> {t('events.invites.manage', 'Layouts & Einladungen verwalten')}
|
||
</Button>
|
||
</div>
|
||
</SectionCard>
|
||
);
|
||
}
|
||
|
||
function TaskOverviewCard({ tasks, navigateToTasks }: { tasks: EventToolkit['tasks'] | undefined; navigateToTasks: () => void }) {
|
||
const { t } = useTranslation('management');
|
||
|
||
return (
|
||
<SectionCard className="space-y-3">
|
||
<SectionHeader
|
||
eyebrow={t('events.tasks.badge', 'Aufgaben')}
|
||
title={t('events.tasks.title', 'Aktive Aufgaben')}
|
||
description={t('events.tasks.subtitle', 'Motiviere Gäste mit klaren Aufgaben & Highlights.')}
|
||
endSlot={(
|
||
<Badge variant="outline" className="border-pink-200 text-pink-600 dark:border-pink-500/30 dark:text-pink-200">
|
||
{t('events.tasks.summary', {
|
||
defaultValue: '{{completed}} von {{total}} erledigt',
|
||
completed: tasks?.summary.completed ?? 0,
|
||
total: tasks?.summary.total ?? 0,
|
||
})}
|
||
</Badge>
|
||
)}
|
||
/>
|
||
<div className="space-y-3 text-sm text-slate-700 dark:text-slate-300">
|
||
{tasks?.items?.length ? (
|
||
<div className="space-y-2">
|
||
{tasks.items.slice(0, 4).map((task) => (
|
||
<TaskRow key={task.id} task={task} />
|
||
))}
|
||
</div>
|
||
) : (
|
||
<p className="text-xs text-slate-500">{t('events.tasks.empty', 'Noch keine Aufgaben zugewiesen.')}</p>
|
||
)}
|
||
|
||
<Button variant="outline" onClick={navigateToTasks} className="border-pink-200 text-pink-600 hover:bg-pink-50">
|
||
<Sparkles className="mr-2 h-4 w-4" /> {t('events.tasks.manage', 'Aufgabenbereich öffnen')}
|
||
</Button>
|
||
</div>
|
||
</SectionCard>
|
||
);
|
||
}
|
||
|
||
function TaskRow({ task }: { task: EventToolkitTask }) {
|
||
return (
|
||
<div className="flex items-start justify-between rounded-lg border border-pink-100 bg-white/80 px-3 py-2 text-xs text-slate-600">
|
||
<div className="space-y-1">
|
||
<p className="text-sm font-semibold text-slate-900">{task.title}</p>
|
||
{task.description ? <p>{task.description}</p> : null}
|
||
</div>
|
||
<Badge variant={task.is_completed ? 'default' : 'outline'} className={task.is_completed ? 'bg-emerald-500/20 text-emerald-600' : 'border-pink-200 text-pink-600'}>
|
||
{task.is_completed ? 'Erledigt' : 'Offen'}
|
||
</Badge>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function BrandingMissionCard({
|
||
event,
|
||
invites,
|
||
emotions,
|
||
onOpenBranding,
|
||
onOpenCollections,
|
||
onOpenTasks,
|
||
onOpenEmotions,
|
||
}: {
|
||
event: TenantEvent;
|
||
invites?: EventToolkit['invites'];
|
||
emotions?: TenantEmotion[];
|
||
onOpenBranding: () => void;
|
||
onOpenCollections: () => void;
|
||
onOpenTasks: () => void;
|
||
onOpenEmotions: () => void;
|
||
}) {
|
||
const { t } = useTranslation('management');
|
||
|
||
const palette = extractBrandingPalette(event.settings);
|
||
const activeInvites = invites?.summary.active ?? 0;
|
||
const eventTypeId = event.event_type_id ?? event.event_type?.id ?? null;
|
||
const spotlightEmotions = React.useMemo(
|
||
() => filterEmotionsByEventType(emotions ?? [], eventTypeId).slice(0, 4),
|
||
[emotions, eventTypeId],
|
||
);
|
||
|
||
return (
|
||
<SectionCard className="space-y-4">
|
||
<SectionHeader
|
||
eyebrow={t('events.branding.badge', 'Branding & Story')}
|
||
title={t('events.branding.title', 'Branding & Mission Packs')}
|
||
description={t('events.branding.subtitle', 'Stimme Farben, Schriftarten und Aufgabenpakete aufeinander ab.')}
|
||
/>
|
||
<div className="grid gap-4 md:grid-cols-2">
|
||
<div className="rounded-2xl border border-indigo-100 bg-indigo-50/80 p-4 text-sm text-indigo-900 shadow-inner shadow-indigo-100 dark:border-indigo-300/40 dark:bg-indigo-500/10 dark:text-indigo-100">
|
||
<p className="text-xs uppercase tracking-[0.3em]">{t('events.branding.brandingTitle', 'Branding')}</p>
|
||
<p className="mt-1 text-base font-semibold">{palette.font ?? t('events.branding.brandingFallback', 'Aktuelle Auswahl')}</p>
|
||
<p className="text-xs text-indigo-900/70 dark:text-indigo-100/80">
|
||
{t('events.branding.brandingCopy', 'Passe Farben & Schriftarten im Layout-Editor an.')}
|
||
</p>
|
||
<div className="mt-3 flex gap-2">
|
||
{(palette.colors.length ? palette.colors : ['#f472b6', '#fef3c7', '#312e81']).map((color) => (
|
||
<span
|
||
key={color}
|
||
className="h-10 w-10 rounded-xl border border-white/70 shadow"
|
||
style={{ backgroundColor: color }}
|
||
/>
|
||
))}
|
||
</div>
|
||
<Button size="sm" variant="secondary" className="mt-4 rounded-full bg-white/80 text-indigo-900 hover:bg-white" onClick={onOpenBranding}>
|
||
{t('events.branding.brandingCta', 'Branding anpassen')}
|
||
</Button>
|
||
</div>
|
||
<div className="rounded-2xl border border-rose-100 bg-rose-50/80 p-4 text-sm text-rose-900 shadow-inner shadow-rose-100 dark:border-rose-300/40 dark:bg-rose-500/10 dark:text-rose-100">
|
||
<p className="text-xs uppercase tracking-[0.3em]">{t('events.branding.collectionsTitle', 'Mission Packs')}</p>
|
||
<p className="mt-1 text-base font-semibold">
|
||
{event.event_type?.name ?? t('events.branding.collectionsFallback', 'Empfohlene Story')}
|
||
</p>
|
||
<p className="text-xs text-rose-900/70 dark:text-rose-100/80">
|
||
{t('events.branding.collectionsCopy', 'Importiere passende Kollektionen oder aktiviere Emotionen im Aufgabenbereich.')}
|
||
</p>
|
||
<div className="mt-3 flex flex-wrap gap-2 text-xs">
|
||
<Badge variant="outline" className="border-rose-200 text-rose-700 dark:border-rose-300/40 dark:text-rose-100">
|
||
{t('events.branding.collectionsActive', { defaultValue: '{{count}} aktive Links', count: activeInvites })}
|
||
</Badge>
|
||
<Badge variant="outline" className="border-rose-200 text-rose-700 dark:border-rose-300/40 dark:text-rose-100">
|
||
{t('events.branding.tasksCount', {
|
||
defaultValue: '{{count}} Aufgaben',
|
||
count: Number(event.tasks_count ?? 0),
|
||
})}
|
||
</Badge>
|
||
</div>
|
||
<div className="mt-4 rounded-xl border border-rose-100/80 bg-white/70 p-3 text-xs text-rose-900/80">
|
||
<p className="text-[10px] uppercase tracking-[0.3em] text-rose-400">
|
||
{t('events.branding.emotionsTitle', 'Emotionen')}
|
||
</p>
|
||
{spotlightEmotions.length ? (
|
||
<div className="mt-2 flex flex-wrap gap-2">
|
||
{spotlightEmotions.map((emotion) => (
|
||
<span
|
||
key={emotion.id}
|
||
className="flex items-center gap-1 rounded-full px-3 py-1 text-xs font-semibold shadow-sm"
|
||
style={{
|
||
backgroundColor: `${emotion.color ?? '#fecdd3'}33`,
|
||
color: emotion.color ?? '#be123c',
|
||
}}
|
||
>
|
||
{emotion.icon ? <span>{emotion.icon}</span> : null}
|
||
{emotion.name}
|
||
</span>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<p className="mt-2 text-xs text-rose-900/70">
|
||
{t('events.branding.emotionsEmpty', 'Aktiviere Emotionen, um Aufgaben zu kategorisieren.')}
|
||
</p>
|
||
)}
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
className="mt-3 h-8 px-0 text-rose-700 hover:bg-rose-100/70"
|
||
onClick={onOpenEmotions}
|
||
>
|
||
{t('events.branding.emotionsCta', 'Emotionen verwalten')}
|
||
</Button>
|
||
</div>
|
||
<div className="mt-4 flex flex-wrap gap-2">
|
||
<Button size="sm" variant="outline" className="border-rose-200 text-rose-700 hover:bg-rose-100" onClick={onOpenTasks}>
|
||
{t('events.branding.collectionsManage', 'Aufgaben bearbeiten')}
|
||
</Button>
|
||
<Button size="sm" variant="ghost" className="text-rose-700 hover:bg-rose-100/80" onClick={onOpenCollections}>
|
||
{t('events.branding.collectionsImport', 'Mission Pack importieren')}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</SectionCard>
|
||
);
|
||
}
|
||
|
||
function GalleryShareCard({
|
||
invites,
|
||
onManageInvites,
|
||
}: {
|
||
invites?: EventToolkit['invites'];
|
||
onManageInvites: () => void;
|
||
}) {
|
||
const { t } = useTranslation('management');
|
||
const primaryInvite = React.useMemo(
|
||
() => invites?.items?.find((invite) => invite.is_active) ?? invites?.items?.[0] ?? null,
|
||
[invites?.items],
|
||
);
|
||
|
||
const handleCopy = React.useCallback(async () => {
|
||
if (!primaryInvite?.url) {
|
||
return;
|
||
}
|
||
try {
|
||
await navigator.clipboard.writeText(primaryInvite.url);
|
||
toast.success(t('events.galleryShare.copied', 'Link kopiert'));
|
||
} catch (err) {
|
||
console.error(err);
|
||
toast.error(t('events.galleryShare.copyFailed', 'Konnte Link nicht kopieren'));
|
||
}
|
||
}, [primaryInvite, t]);
|
||
|
||
if (!primaryInvite) {
|
||
return (
|
||
<SectionCard className="space-y-3">
|
||
<SectionHeader
|
||
eyebrow={t('events.galleryShare.badge', 'Galerie')}
|
||
title={t('events.galleryShare.title', 'Galerie teilen')}
|
||
description={t('events.galleryShare.emptyDescription', 'Erstelle einen Einladungslink, um Fotos zu teilen.')}
|
||
/>
|
||
<Button onClick={onManageInvites} className="w-full rounded-full bg-brand-rose text-white shadow-md shadow-rose-300/40">
|
||
{t('events.galleryShare.createInvite', 'Einladung erstellen')}
|
||
</Button>
|
||
</SectionCard>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<SectionCard className="space-y-3">
|
||
<SectionHeader
|
||
eyebrow={t('events.galleryShare.badge', 'Galerie')}
|
||
title={t('events.galleryShare.title', 'Galerie-Link & QR')}
|
||
description={t('events.galleryShare.description', 'Teile den Link nach dem Event oder lade QR-Karten herunter.')}
|
||
/>
|
||
<div className="rounded-2xl border border-slate-200 bg-white/90 p-4 text-sm text-slate-700 dark:border-white/10 dark:bg-white/5 dark:text-slate-200">
|
||
<p className="text-xs uppercase tracking-[0.3em] text-slate-500 dark:text-slate-400">
|
||
{primaryInvite.label ?? t('events.galleryShare.linkLabel', 'Standard-Link')}
|
||
</p>
|
||
<p className="mt-2 truncate text-base font-semibold text-slate-900 dark:text-white">{primaryInvite.url}</p>
|
||
<div className="mt-3 flex flex-wrap gap-2 text-xs">
|
||
<Badge variant="outline" className="border-slate-200 text-slate-600 dark:border-white/20 dark:text-white">
|
||
{t('events.galleryShare.scans', { defaultValue: '{{count}} Aufrufe', count: primaryInvite.usage_count })}
|
||
</Badge>
|
||
{typeof primaryInvite.usage_limit === 'number' && (
|
||
<Badge variant="outline" className="border-slate-200 text-slate-600 dark:border-white/20 dark:text-white">
|
||
{t('events.galleryShare.limit', { defaultValue: 'Limit {{count}}', count: primaryInvite.usage_limit })}
|
||
</Badge>
|
||
)}
|
||
</div>
|
||
<div className="mt-4 flex flex-wrap gap-2">
|
||
<Button size="sm" className="rounded-full bg-brand-rose px-4 text-white shadow-rose-400/40" onClick={handleCopy}>
|
||
{t('events.galleryShare.copy', 'Link kopieren')}
|
||
</Button>
|
||
<Button size="sm" variant="outline" onClick={onManageInvites}>
|
||
{t('events.galleryShare.manage', 'Layouts & QR öffnen')}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</SectionCard>
|
||
);
|
||
}
|
||
|
||
function GalleryStatusCard({ gallery }: { gallery: GallerySummary }) {
|
||
const { t } = useTranslation('management');
|
||
|
||
const stateLabel =
|
||
gallery.state === 'expired'
|
||
? t('events.galleryStatus.stateExpired', 'Galerie abgelaufen')
|
||
: gallery.state === 'warning'
|
||
? t('events.galleryStatus.stateWarning', 'Galerie läuft bald ab')
|
||
: t('events.galleryStatus.stateOk', 'Galerie aktiv');
|
||
|
||
const expiresLabel =
|
||
gallery.expires_at && gallery.state !== 'unlimited'
|
||
? formatDate(gallery.expires_at)
|
||
: t('events.galleryStatus.noExpiry', 'Kein Ablaufdatum gesetzt');
|
||
|
||
const daysRemaining =
|
||
typeof gallery.days_remaining === 'number' && gallery.days_remaining >= 0
|
||
? gallery.days_remaining
|
||
: null;
|
||
|
||
return (
|
||
<SectionCard className="space-y-3">
|
||
<SectionHeader
|
||
eyebrow={t('events.galleryStatus.badge', 'Laufzeit')}
|
||
title={t('events.galleryStatus.title', 'Galerie-Laufzeit & Verfügbarkeit')}
|
||
description={t('events.galleryStatus.subtitle', 'Halte im Blick, wie lange Gäste noch auf die Galerie zugreifen können.')}
|
||
/>
|
||
<div className="flex flex-col gap-3 rounded-2xl border border-slate-200 bg-white/90 p-4 text-sm text-slate-700 dark:border-white/10 dark:bg-white/5 dark:text-slate-200 md:flex-row md:items-center md:justify-between">
|
||
<div className="space-y-1">
|
||
<p className="text-xs uppercase tracking-[0.3em] text-slate-500 dark:text-slate-400">
|
||
{t('events.galleryStatus.stateLabel', 'Status')}
|
||
</p>
|
||
<p className="text-sm font-semibold text-slate-900 dark:text-white">{stateLabel}</p>
|
||
<p className="text-xs text-slate-600 dark:text-slate-300">
|
||
{t('events.galleryStatus.expiresAt', {
|
||
defaultValue: 'Ablaufdatum: {{date}}',
|
||
date: expiresLabel,
|
||
})}
|
||
</p>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<p className="text-xs uppercase tracking-[0.3em] text-slate-500 dark:text-slate-400">
|
||
{t('events.galleryStatus.daysLabel', 'Verbleibende Tage')}
|
||
</p>
|
||
<p className="text-2xl font-semibold text-slate-900 dark:text-white">
|
||
{daysRemaining !== null ? daysRemaining : '—'}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</SectionCard>
|
||
);
|
||
}
|
||
|
||
|
||
function extractBrandingPalette(
|
||
settings: TenantEvent['settings'],
|
||
): { colors: string[]; font?: string } {
|
||
const colors: string[] = [];
|
||
let font: string | undefined;
|
||
|
||
if (settings && typeof settings === 'object') {
|
||
const brandingSource =
|
||
(settings as Record<string, unknown>).branding && typeof (settings as Record<string, unknown>).branding === 'object'
|
||
? (settings as Record<string, unknown>).branding
|
||
: settings;
|
||
|
||
const candidateKeys = ['primary_color', 'secondary_color', 'accent_color', 'background_color', 'color'];
|
||
candidateKeys.forEach((key) => {
|
||
const value = (brandingSource as Record<string, unknown>)[key];
|
||
if (typeof value === 'string' && value.trim()) {
|
||
colors.push(value);
|
||
}
|
||
});
|
||
|
||
const fontKeys = ['font_family', 'font', 'heading_font'];
|
||
fontKeys.some((key) => {
|
||
const value = (brandingSource as Record<string, unknown>)[key];
|
||
if (typeof value === 'string' && value.trim()) {
|
||
font = value;
|
||
return true;
|
||
}
|
||
return false;
|
||
});
|
||
}
|
||
|
||
return { colors, font };
|
||
}
|
||
|
||
// Pending photos summary moved to the dedicated Live/Photos view.
|
||
|
||
function RecentUploadsCard({ slug, photos }: { slug: string; photos: TenantPhoto[] }) {
|
||
const { t } = useTranslation('management');
|
||
const [entries, setEntries] = React.useState(photos);
|
||
const [updatingId, setUpdatingId] = React.useState<number | null>(null);
|
||
|
||
React.useEffect(() => {
|
||
setEntries(photos);
|
||
}, [photos]);
|
||
|
||
const handleVisibility = async (photo: TenantPhoto, visible: boolean) => {
|
||
setUpdatingId(photo.id);
|
||
try {
|
||
const updated = await updatePhotoVisibility(slug, photo.id, visible);
|
||
setEntries((prev) => prev.map((item) => (item.id === photo.id ? updated : item)));
|
||
} catch (err) {
|
||
toast.error(
|
||
isAuthError(err)
|
||
? t('events.photos.errorAuth', 'Session abgelaufen. Bitte erneut anmelden.')
|
||
: t('events.photos.errorVisibility', 'Sichtbarkeit konnte nicht geändert werden.'),
|
||
);
|
||
} finally {
|
||
setUpdatingId(null);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<SectionCard className="space-y-3">
|
||
<SectionHeader
|
||
eyebrow={t('events.photos.recentBadge', 'Uploads')}
|
||
title={t('events.photos.recentTitle', 'Neueste Uploads')}
|
||
description={t('events.photos.recentSubtitle', 'Halte Ausschau nach Highlight-Momenten der Gäste.')}
|
||
/>
|
||
<div className="space-y-2 text-sm text-slate-700 dark:text-slate-300">
|
||
{entries.length ? (
|
||
<div className="grid gap-3 sm:grid-cols-2">
|
||
{entries.slice(0, 6).map((photo) => {
|
||
const hidden = photo.status === 'hidden';
|
||
return (
|
||
<div key={photo.id} className="rounded-xl border border-slate-200 bg-white/90 p-2">
|
||
<div className="relative overflow-hidden rounded-lg">
|
||
<img
|
||
src={photo.thumbnail_url ?? photo.url ?? undefined}
|
||
alt={photo.caption ?? 'Foto'}
|
||
className={`h-28 w-full object-cover ${hidden ? 'opacity-60' : ''}`}
|
||
/>
|
||
{photo.is_featured ? (
|
||
<span className="absolute left-2 top-2 rounded-full bg-pink-500/90 px-2 py-0.5 text-[10px] font-semibold text-white">
|
||
Highlight
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
<div className="mt-2 flex flex-wrap gap-2 text-[11px] text-slate-500">
|
||
<Badge variant="outline">♥ {photo.likes_count}</Badge>
|
||
<Badge variant="outline">{photo.uploader_name ?? 'Gast'}</Badge>
|
||
</div>
|
||
<div className="mt-2 flex flex-wrap gap-2 text-xs">
|
||
<Button size="sm" variant="outline" disabled={updatingId === photo.id} onClick={() => handleVisibility(photo, hidden)}>
|
||
{hidden ? t('events.photos.show', 'Einblenden') : t('events.photos.hide', 'Verstecken')}
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant={photo.is_featured ? 'secondary' : 'outline'}
|
||
disabled={updatingId === photo.id}
|
||
onClick={() => handleFeature(photo, !photo.is_featured)}
|
||
>
|
||
{photo.is_featured ? t('events.photos.unfeature', 'Highlight entfernen') : t('events.photos.feature', 'Highlight')}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
) : (
|
||
<p className="text-xs text-slate-500">{t('events.photos.recentEmpty', 'Noch keine neuen Uploads.')}</p>
|
||
)}
|
||
</div>
|
||
</SectionCard>
|
||
);
|
||
}
|
||
|
||
function FeedbackCard({ slug }: { slug: string }) {
|
||
const { t } = useTranslation('management');
|
||
const [sentiment, setSentiment] = React.useState<'positive' | 'neutral' | 'negative' | null>(null);
|
||
const [message, setMessage] = React.useState('');
|
||
const [busy, setBusy] = React.useState(false);
|
||
const [submitted, setSubmitted] = React.useState(false);
|
||
const [error, setError] = React.useState<string | undefined>(undefined);
|
||
|
||
const copy = {
|
||
positive: t('events.feedback.positive', 'Super Lauf!'),
|
||
neutral: t('events.feedback.neutral', 'Läuft'),
|
||
negative: t('events.feedback.negative', 'Braucht Support'),
|
||
};
|
||
|
||
return (
|
||
<SectionCard className="space-y-4">
|
||
<SectionHeader
|
||
eyebrow={t('events.feedback.badge', 'Feedback')}
|
||
title={t('events.feedback.title', 'Wie läuft dein Event?')}
|
||
description={t('events.feedback.subtitle', 'Feedback hilft uns, neue Features zu priorisieren.')}
|
||
/>
|
||
<div className="space-y-4">
|
||
{error && (
|
||
<Alert variant="destructive">
|
||
<AlertTitle>{t('events.feedback.errorTitle', 'Feedback konnte nicht gesendet werden.')}</AlertTitle>
|
||
<AlertDescription>{error}</AlertDescription>
|
||
</Alert>
|
||
)}
|
||
|
||
<div className="flex flex-wrap gap-2">
|
||
{(Object.keys(copy) as Array<'positive' | 'neutral' | 'negative'>).map((key) => (
|
||
<Button
|
||
key={key}
|
||
type="button"
|
||
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>
|
||
|
||
<textarea
|
||
value={message}
|
||
onChange={(event) => setMessage(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-md border border-slate-200 bg-white p-3 text-sm text-slate-700 outline-none focus:border-slate-400 focus:ring-1 focus:ring-slate-300"
|
||
/>
|
||
|
||
<Button
|
||
type="button"
|
||
className="bg-slate-900 text-white hover:bg-slate-800"
|
||
disabled={busy || submitted}
|
||
onClick={async () => {
|
||
if (busy || submitted) return;
|
||
setBusy(true);
|
||
setError(undefined);
|
||
|
||
try {
|
||
await submitTenantFeedback({
|
||
category: 'event_workspace',
|
||
event_slug: slug,
|
||
sentiment: sentiment ?? undefined,
|
||
message: message.trim() ? message.trim() : undefined,
|
||
});
|
||
setSubmitted(true);
|
||
} catch (err) {
|
||
setError(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 {
|
||
setBusy(false);
|
||
}
|
||
}}
|
||
>
|
||
{busy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <MessageSquare className="mr-2 h-4 w-4" />} {submitted ? t('events.feedback.submitted', 'Danke!') : t('events.feedback.submit', 'Feedback senden')}
|
||
</Button>
|
||
</div>
|
||
</SectionCard>
|
||
);
|
||
}
|
||
|
||
function GuestNotificationStatsCard({ notifications }: { notifications?: EventToolkit['notifications'] }) {
|
||
const { t } = useTranslation('management');
|
||
|
||
if (!notifications || notifications.summary.total === 0) {
|
||
return (
|
||
<div className="flex h-full flex-col items-center justify-center rounded-2xl border border-dashed border-slate-200 bg-slate-50 px-4 py-6 text-center text-sm text-slate-500">
|
||
<MessageSquare className="mb-3 h-6 w-6 text-slate-400" aria-hidden />
|
||
<p>{t('events.notifications.statsEmpty', 'Noch keine Nachrichten versendet – starte mit einem Broadcast.')}</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const { summary, recent } = notifications;
|
||
const topTypes = Object.entries(summary.by_type ?? {})
|
||
.sort(([, a], [, b]) => (b ?? 0) - (a ?? 0))
|
||
.slice(0, 3);
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<div className="grid gap-3 sm:grid-cols-3">
|
||
<StatPill
|
||
icon={<Bell className="h-4 w-4" />}
|
||
label={t('events.notifications.statsTotal', 'Gesendete Nachrichten')}
|
||
value={summary.total}
|
||
/>
|
||
<StatPill
|
||
icon={<MessageSquare className="h-4 w-4" />}
|
||
label={t('events.notifications.statsBroadcasts', 'Broadcasts')}
|
||
value={summary.broadcasts.total}
|
||
sublabel={summary.broadcasts.last_title ?? t('events.notifications.statsBroadcastsEmpty', 'Noch kein Broadcast')}
|
||
/>
|
||
<StatPill
|
||
icon={<Clock3 className="h-4 w-4" />}
|
||
label={t('events.notifications.statsLastSent', 'Letzte Sendung')}
|
||
value={summary.last_sent_at ? formatRelativeDateTime(summary.last_sent_at) : t('events.notifications.never', 'Noch nie')}
|
||
/>
|
||
</div>
|
||
|
||
<div className="rounded-2xl border border-slate-200 bg-white/80 p-4">
|
||
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||
{t('events.notifications.topTypes', 'Beliebteste Typen')}
|
||
</p>
|
||
{topTypes.length > 0 ? (
|
||
<div className="mt-3 flex flex-wrap gap-2">
|
||
{topTypes.map(([type, count]) => (
|
||
<Badge key={type} variant="secondary" className="gap-2">
|
||
{getNotificationTypeLabel(type, t)}
|
||
<span className="rounded-full bg-slate-200 px-2 text-xs font-semibold text-slate-700">{count as number}</span>
|
||
</Badge>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<p className="mt-2 text-sm text-slate-500">{t('events.notifications.topTypesEmpty', 'Noch keine Verteilung verfügbar.')}</p>
|
||
)}
|
||
</div>
|
||
|
||
<div className="rounded-2xl border border-slate-200 bg-white/80 p-4">
|
||
<div className="flex items-center justify-between">
|
||
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||
{t('events.notifications.recent', 'Letzte Nachrichten')}
|
||
</p>
|
||
<span className="text-xs text-slate-400">{recent.length} {t('events.notifications.recentCount', 'Einträge')}</span>
|
||
</div>
|
||
{recent.length === 0 ? (
|
||
<p className="mt-3 text-sm text-slate-500">{t('events.notifications.recentEmpty', 'Noch keine Historie vorhanden.')}</p>
|
||
) : (
|
||
<ul className="mt-3 space-y-3">
|
||
{recent.map((item) => (
|
||
<li key={item.id} className="rounded-xl border border-slate-100 bg-white/70 px-3 py-2">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div>
|
||
<p className="text-sm font-semibold text-slate-900">{item.title || t('events.notifications.untitled', 'Ohne Titel')}</p>
|
||
<p className="text-xs text-slate-500">
|
||
{formatRelativeDateTime(item.created_at)} · {getAudienceLabel(item.audience_scope, t)}
|
||
</p>
|
||
</div>
|
||
<Badge variant="outline" className="text-xs capitalize">
|
||
{getNotificationTypeLabel(item.type, t)}
|
||
</Badge>
|
||
</div>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function StatPill({ icon, label, value, sublabel }: { icon: React.ReactNode; label: string; value: string | number; sublabel?: string | null }) {
|
||
return (
|
||
<div className="rounded-2xl border border-slate-200 bg-white/80 p-4">
|
||
<div className="flex items-center gap-2 text-slate-500">
|
||
<span className="rounded-full bg-slate-100 p-2 text-slate-600">{icon}</span>
|
||
<p className="text-xs font-semibold uppercase tracking-wide">{label}</p>
|
||
</div>
|
||
<p className="mt-2 text-2xl font-semibold text-slate-900">{value}</p>
|
||
{sublabel && <p className="text-xs text-slate-500">{sublabel}</p>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function formatRelativeDateTime(value?: string | null): string {
|
||
if (!value) {
|
||
return '—';
|
||
}
|
||
|
||
const date = new Date(value);
|
||
if (Number.isNaN(date.getTime())) {
|
||
return '—';
|
||
}
|
||
|
||
return date.toLocaleString(undefined, {
|
||
day: '2-digit',
|
||
month: '2-digit',
|
||
year: 'numeric',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
});
|
||
}
|
||
|
||
function getNotificationTypeLabel(type: string, t: ReturnType<typeof useTranslation>['t']): string {
|
||
switch (type) {
|
||
case 'broadcast':
|
||
return t('events.notifications.types.broadcast', 'Broadcast');
|
||
case 'upload_alert':
|
||
return t('events.notifications.types.upload', 'Upload-Status');
|
||
case 'support_tip':
|
||
return t('events.notifications.types.support', 'Support-Tipp');
|
||
case 'feedback_request':
|
||
return t('events.notifications.types.feedback', 'Feedback');
|
||
case 'achievement_major':
|
||
return t('events.notifications.types.achievement', 'Achievement');
|
||
case 'photo_activity':
|
||
return t('events.notifications.types.activity', 'Aktivität');
|
||
default:
|
||
return t('events.notifications.types.generic', 'System');
|
||
}
|
||
}
|
||
|
||
function getAudienceLabel(scope: string, t: ReturnType<typeof useTranslation>['t']): string {
|
||
if (scope === 'guest') {
|
||
return t('events.notifications.audienceGuest', 'Gezielte Gäste');
|
||
}
|
||
|
||
return t('events.notifications.audienceAll', 'Alle Gäste');
|
||
}
|
||
|
||
function InfoRow({ icon, label, value }: { icon: React.ReactNode; label: string; value: React.ReactNode }) {
|
||
return (
|
||
<div className="flex items-start gap-3 rounded-lg border border-slate-100 bg-white/70 px-3 py-2 text-sm text-slate-700">
|
||
<span className="mt-0.5 flex h-8 w-8 items-center justify-center rounded-full bg-slate-100 text-slate-600">{icon}</span>
|
||
<div className="space-y-1">
|
||
<p className="text-xs uppercase tracking-wide text-slate-500">{label}</p>
|
||
<p className="text-sm font-semibold text-slate-900">{value || '—'}</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function getStatusLabel(event: TenantEvent, t: ReturnType<typeof useTranslation>['t']): string {
|
||
if (event.status === 'published') {
|
||
return t('events.status.published', 'Veröffentlicht');
|
||
}
|
||
if (event.status === 'archived') {
|
||
return t('events.status.archived', 'Archiviert');
|
||
}
|
||
return t('events.status.draft', 'Entwurf');
|
||
}
|
||
|
||
function formatDate(value: string | null | undefined): string {
|
||
if (!value) return '—';
|
||
const date = new Date(value);
|
||
if (Number.isNaN(date.getTime())) {
|
||
return '—';
|
||
}
|
||
return date.toLocaleDateString(undefined, { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||
}
|
||
|
||
function resolveEventType(event: TenantEvent): string {
|
||
if (event.event_type?.name) {
|
||
if (typeof event.event_type.name === 'string') {
|
||
return event.event_type.name;
|
||
}
|
||
const translations = event.event_type.name as Record<string, string>;
|
||
return translations.de ?? translations.en ?? Object.values(translations)[0] ?? '—';
|
||
}
|
||
return '—';
|
||
}
|
||
|
||
function AlertList({ alerts }: { alerts: string[] }) {
|
||
if (!alerts.length) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-2">
|
||
{alerts.map((alert, index) => (
|
||
<Alert key={`workspace-alert-${index}`} variant="default">
|
||
<AlertTitle>{alert}</AlertTitle>
|
||
</Alert>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function CalendarIcon() {
|
||
return (
|
||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 text-slate-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
|
||
<line x1="16" y1="2" x2="16" y2="6" />
|
||
<line x1="8" y1="2" x2="8" y2="6" />
|
||
<line x1="3" y1="10" x2="21" y2="10" />
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
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={`metric-skeleton-${index}`} />
|
||
))}
|
||
</div>
|
||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.4fr)_minmax(0,0.8fr)]">
|
||
<SkeletonCard />
|
||
<SkeletonCard />
|
||
</div>
|
||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.4fr)_minmax(0,0.8fr)]">
|
||
<SkeletonCard />
|
||
<SkeletonCard />
|
||
</div>
|
||
<SkeletonCard />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SkeletonCard() {
|
||
return <div className="h-40 animate-pulse rounded-2xl bg-gradient-to-r from-slate-100 via-white to-slate-100" />;
|
||
}
|