376 lines
14 KiB
TypeScript
376 lines
14 KiB
TypeScript
import React, { useCallback, useEffect, useState } from "react";
|
|
import { Copy, Download, Plus, Tag, X } from "lucide-react";
|
|
import { useAuth } from "../../contexts/AuthContext";
|
|
import { badgeBatchesApi, type BadgeBatchRecord } from "../../../services/api/badgeBatches";
|
|
|
|
function formatRequestedAt(createdAt?: string): string {
|
|
if (!createdAt) return "";
|
|
const d = new Date(createdAt);
|
|
if (Number.isNaN(d.getTime())) return "";
|
|
return d.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
|
|
}
|
|
|
|
function statusLabel(status: BadgeBatchRecord["status"]): string {
|
|
if (status === "ready") return "Ready";
|
|
if (status === "processing") return "Processing";
|
|
if (status === "failed") return "Failed";
|
|
return "Pending";
|
|
}
|
|
|
|
export function EventBadgesPanel({
|
|
eventId,
|
|
eventName,
|
|
onClose,
|
|
onAddBadge,
|
|
onDuplicateBadge,
|
|
}: {
|
|
eventId: string;
|
|
eventName?: string;
|
|
onClose: () => void;
|
|
onAddBadge: () => void;
|
|
onDuplicateBadge: (batchId: string) => void;
|
|
}) {
|
|
const { user } = useAuth();
|
|
const canManage = Boolean(user && ["super_admin", "admin", "moderator"].includes(user.role));
|
|
const [badges, setBadges] = useState<BadgeBatchRecord[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState("");
|
|
const [downloadingId, setDownloadingId] = useState<string | null>(null);
|
|
|
|
const loadBadges = useCallback(async () => {
|
|
setLoading(true);
|
|
setError("");
|
|
try {
|
|
const res = await badgeBatchesApi.list({ event: eventId, limit: 100 });
|
|
if (res.error) {
|
|
setError(typeof res.error === "string" ? res.error : "Failed to load badges.");
|
|
setBadges([]);
|
|
} else {
|
|
setBadges(res.data?.batches ?? []);
|
|
}
|
|
} catch {
|
|
setError("Failed to load badges.");
|
|
setBadges([]);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [eventId]);
|
|
|
|
useEffect(() => {
|
|
void loadBadges();
|
|
}, [loadBadges]);
|
|
|
|
return (
|
|
<>
|
|
<div
|
|
onClick={onClose}
|
|
role="presentation"
|
|
style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.5)", zIndex: 200 }}
|
|
/>
|
|
<div
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="event-badges-title"
|
|
style={{
|
|
position: "fixed",
|
|
top: 0,
|
|
right: 0,
|
|
height: "100%",
|
|
width: "min(720px, 100vw)",
|
|
background: "var(--card)",
|
|
zIndex: 201,
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
boxShadow: "-4px 0 40px rgba(0,0,0,0.2)",
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "flex-start",
|
|
justifyContent: "space-between",
|
|
gap: 12,
|
|
padding: "16px 18px",
|
|
borderBottom: "1px solid var(--border)",
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
<div style={{ minWidth: 0 }}>
|
|
<p
|
|
style={{
|
|
fontSize: "var(--text-xs)",
|
|
fontFamily: "var(--font-noto-sans)",
|
|
color: "var(--muted)",
|
|
margin: "0 0 4px",
|
|
}}
|
|
>
|
|
Tag & Badge Configuration
|
|
</p>
|
|
<h2
|
|
id="event-badges-title"
|
|
style={{
|
|
fontFamily: "var(--font-inter)",
|
|
fontSize: "var(--text-lg)",
|
|
fontWeight: "var(--font-weight-medium)",
|
|
color: "var(--foreground)",
|
|
margin: 0,
|
|
lineHeight: 1.3,
|
|
}}
|
|
>
|
|
{eventName ? `Badges · ${eventName}` : "Badges"}
|
|
</h2>
|
|
</div>
|
|
<div style={{ display: "flex", gap: 8, flexShrink: 0 }}>
|
|
{canManage ? (
|
|
<button
|
|
type="button"
|
|
onClick={onAddBadge}
|
|
style={{
|
|
display: "inline-flex",
|
|
alignItems: "center",
|
|
gap: 6,
|
|
padding: "8px 12px",
|
|
borderRadius: "var(--radius-button)",
|
|
border: "none",
|
|
background: "var(--primary)",
|
|
color: "var(--primary-foreground)",
|
|
fontSize: "var(--text-xs)",
|
|
fontFamily: "var(--font-noto-sans)",
|
|
fontWeight: "var(--font-weight-medium)",
|
|
cursor: "pointer",
|
|
}}
|
|
>
|
|
<Plus size={14} /> Add Badge
|
|
</button>
|
|
) : null}
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
aria-label="Close"
|
|
style={{
|
|
width: 36,
|
|
height: 36,
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
borderRadius: "var(--radius-button)",
|
|
border: "1px solid var(--border)",
|
|
background: "var(--card)",
|
|
color: "var(--muted)",
|
|
cursor: "pointer",
|
|
}}
|
|
>
|
|
<X size={18} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div style={{ flex: 1, overflow: "auto", padding: "16px 18px" }}>
|
|
{loading ? (
|
|
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>
|
|
Loading badges…
|
|
</p>
|
|
) : null}
|
|
{error ? (
|
|
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--destructive)" }}>
|
|
{error}
|
|
</p>
|
|
) : null}
|
|
{!loading && !error && badges.length === 0 ? (
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
textAlign: "center",
|
|
padding: "48px 24px",
|
|
gap: 12,
|
|
}}
|
|
>
|
|
<Tag size={36} style={{ color: "var(--border)" }} />
|
|
<p
|
|
style={{
|
|
fontFamily: "var(--font-inter)",
|
|
fontSize: "var(--text-base)",
|
|
fontWeight: "var(--font-weight-medium)",
|
|
color: "var(--foreground)",
|
|
margin: 0,
|
|
}}
|
|
>
|
|
No badges yet
|
|
</p>
|
|
<p
|
|
style={{
|
|
fontSize: "var(--text-sm)",
|
|
fontFamily: "var(--font-noto-sans)",
|
|
color: "var(--muted)",
|
|
margin: 0,
|
|
maxWidth: 320,
|
|
lineHeight: 1.5,
|
|
}}
|
|
>
|
|
Create a badge export to design attendee and speaker badges for this event.
|
|
</p>
|
|
{canManage ? (
|
|
<button
|
|
type="button"
|
|
onClick={onAddBadge}
|
|
style={{
|
|
display: "inline-flex",
|
|
alignItems: "center",
|
|
gap: 6,
|
|
marginTop: 8,
|
|
padding: "8px 14px",
|
|
borderRadius: "var(--radius-button)",
|
|
border: "none",
|
|
background: "var(--primary)",
|
|
color: "var(--primary-foreground)",
|
|
fontSize: "var(--text-sm)",
|
|
fontFamily: "var(--font-noto-sans)",
|
|
fontWeight: "var(--font-weight-medium)",
|
|
cursor: "pointer",
|
|
}}
|
|
>
|
|
<Plus size={14} /> Add Badge
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
{!loading && !error && badges.length > 0 ? (
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
|
{badges.map((b) => (
|
|
<div
|
|
key={b._id}
|
|
style={{
|
|
background: "var(--card)",
|
|
border: "1px solid var(--border)",
|
|
borderRadius: "var(--radius-card)",
|
|
padding: "14px 16px",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: 8,
|
|
}}
|
|
>
|
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
|
<Tag size={18} style={{ color: "var(--muted)", flexShrink: 0 }} />
|
|
<p
|
|
style={{
|
|
fontFamily: "var(--font-noto-sans)",
|
|
fontSize: "var(--text-sm)",
|
|
fontWeight: "var(--font-weight-medium)",
|
|
color: "var(--foreground)",
|
|
margin: 0,
|
|
}}
|
|
>
|
|
{b.name}
|
|
</p>
|
|
</div>
|
|
<span style={{ fontSize: "11px", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>
|
|
Status: <strong style={{ color: "var(--foreground)" }}>{statusLabel(b.status)}</strong>
|
|
{b.itemCount != null ? ` · ${b.itemCount} badge${b.itemCount !== 1 ? "s" : ""}` : ""}
|
|
</span>
|
|
{b.lastError && b.status === "failed" ? (
|
|
<span style={{ fontSize: "10px", color: "var(--destructive)" }}>{b.lastError}</span>
|
|
) : null}
|
|
<div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
|
|
{canManage ? (
|
|
<button
|
|
type="button"
|
|
title="Duplicate — copy settings and edit"
|
|
onClick={() => onDuplicateBadge(b._id)}
|
|
style={{
|
|
display: "inline-flex",
|
|
alignItems: "center",
|
|
gap: 6,
|
|
padding: "6px 12px",
|
|
borderRadius: "var(--radius-button)",
|
|
border: "1px solid var(--border)",
|
|
background: "var(--card)",
|
|
color: "var(--foreground)",
|
|
fontSize: "var(--text-xs)",
|
|
fontFamily: "var(--font-noto-sans)",
|
|
fontWeight: "var(--font-weight-medium)",
|
|
cursor: "pointer",
|
|
}}
|
|
>
|
|
<Copy size={13} /> Duplicate
|
|
</button>
|
|
) : null}
|
|
{b.status === "ready" && b.zipUrl ? (
|
|
<button
|
|
type="button"
|
|
disabled={downloadingId === b._id}
|
|
onClick={async () => {
|
|
setDownloadingId(b._id);
|
|
setError("");
|
|
try {
|
|
const org =
|
|
typeof b.organization === "string"
|
|
? b.organization
|
|
: undefined;
|
|
await badgeBatchesApi.downloadZip(b, org);
|
|
} catch (e) {
|
|
setError(
|
|
e instanceof Error ? e.message : "Download failed.",
|
|
);
|
|
} finally {
|
|
setDownloadingId(null);
|
|
}
|
|
}}
|
|
style={{
|
|
display: "inline-flex",
|
|
alignItems: "center",
|
|
gap: 6,
|
|
padding: "6px 12px",
|
|
borderRadius: "var(--radius-button)",
|
|
background: "var(--primary)",
|
|
color: "var(--primary-foreground)",
|
|
border: "none",
|
|
fontSize: "var(--text-xs)",
|
|
fontFamily: "var(--font-noto-sans)",
|
|
fontWeight: "var(--font-weight-medium)",
|
|
cursor: downloadingId === b._id ? "wait" : "pointer",
|
|
opacity: downloadingId === b._id ? 0.7 : 1,
|
|
}}
|
|
>
|
|
<Download size={13} />{" "}
|
|
{downloadingId === b._id ? "Downloading…" : "Download zip"}
|
|
</button>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
disabled
|
|
style={{
|
|
display: "inline-flex",
|
|
alignItems: "center",
|
|
gap: 6,
|
|
padding: "6px 12px",
|
|
borderRadius: "var(--radius-button)",
|
|
background: "var(--secondary)",
|
|
color: "var(--muted)",
|
|
border: "1px solid var(--border)",
|
|
fontSize: "var(--text-xs)",
|
|
fontFamily: "var(--font-noto-sans)",
|
|
cursor: "not-allowed",
|
|
}}
|
|
>
|
|
<Download size={13} /> Download zip
|
|
</button>
|
|
)}
|
|
</div>
|
|
{b.createdAt ? (
|
|
<span style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>
|
|
Requested {formatRequestedAt(b.createdAt)}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|