diff --git a/client/src/components/admin/LumaGuestsSection.tsx b/client/src/components/admin/LumaGuestsSection.tsx new file mode 100644 index 0000000..9e2bad6 --- /dev/null +++ b/client/src/components/admin/LumaGuestsSection.tsx @@ -0,0 +1,268 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Loader2, RefreshCw, Trash2, Plus, Check, AlertTriangle } from "lucide-react"; +import { useToast } from "@/hooks/use-toast"; +import { api, type ApiProgram, type ApiProgramSignup, type AdminAuthArg, ApiError } from "@/lib/api"; + +interface Props { + program: ApiProgram; + signAuthHeader: () => Promise; + /** Reflect saved event id / sync state back into the parent program state. */ + onProgramChange?: (patch: Partial) => void; +} + +const relativeTime = (iso?: string | null) => { + if (!iso) return "NEVER"; + const ms = Date.now() - new Date(iso).getTime(); + if (!Number.isFinite(ms) || ms < 0) return "JUST NOW"; + const mins = Math.floor(ms / 60000); + if (mins < 1) return "JUST NOW"; + if (mins < 60) return `${mins} MIN AGO`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs} HR AGO`; + return `${Math.floor(hrs / 24)} DAY AGO`; +}; + +const errMessage = (e: unknown) => + e instanceof ApiError ? e.message : (e as Error)?.message || "Unknown error"; + +// Admin guest panel for Luma-gated programs. Replaces the CSV uploader: the +// checked-in list is pulled from the Luma API on demand, with a manual single +// email add as the event-day fallback. +export function LumaGuestsSection({ program, signAuthHeader, onProgramChange }: Props) { + const { toast } = useToast(); + const slug = program.slug; + + const [eventId, setEventId] = useState(program.lumaEventId ?? ""); + const [savingEventId, setSavingEventId] = useState(false); + const [syncing, setSyncing] = useState(false); + + const [lumaCount, setLumaCount] = useState(null); + const [manual, setManual] = useState([]); + const [loading, setLoading] = useState(true); + + const [newEmail, setNewEmail] = useState(""); + const [newName, setNewName] = useState(""); + const [adding, setAdding] = useState(false); + const [busyId, setBusyId] = useState(null); + + const load = useCallback(() => { + let active = true; + setLoading(true); + (async () => { + try { + const auth = await signAuthHeader(); + const r = await api.listProgramSignups(slug, auth); + if (!active) return; + setLumaCount(r.data.filter((s) => s.source === "luma_api").length); + setManual(r.data.filter((s) => s.source === "manual")); + } catch (e) { + if (active) toast({ title: "Couldn't load guests", description: errMessage(e), variant: "destructive" }); + } finally { + if (active) setLoading(false); + } + })(); + return () => { + active = false; + }; + }, [slug, signAuthHeader, toast]); + + useEffect(() => load(), [load]); + + const hasEventId = Boolean(program.lumaEventId); + const eventIdDirty = eventId.trim() !== (program.lumaEventId ?? ""); + + const status = program.lastGuestSyncStatus; + const statusBad = typeof status === "string" && (status.startsWith("error") || status === "truncated" || status === "empty_guard"); + + const saveEventId = async () => { + setSavingEventId(true); + try { + const auth = await signAuthHeader(); + const r = await api.updateProgram(slug, { lumaEventId: eventId.trim() || null }, auth); + onProgramChange?.({ lumaEventId: r.data.lumaEventId ?? null }); + toast({ title: "Luma event id saved" }); + } catch (e) { + toast({ title: "Couldn't save event id", description: errMessage(e), variant: "destructive" }); + } finally { + setSavingEventId(false); + } + }; + + const syncNow = async () => { + setSyncing(true); + try { + const auth = await signAuthHeader(); + const r = await api.syncProgramGuests(slug, auth); + setLumaCount(r.data.checkedInCount); + onProgramChange?.({ lastGuestSyncAt: r.data.syncedAt, lastGuestSyncStatus: r.data.syncStatus }); + if (r.data.syncStatus === "ok") { + toast({ title: `Synced ${r.data.checkedInCount} checked-in guests` }); + } else { + toast({ + title: "Sync returned a warning", + description: `Status: ${r.data.syncStatus}. Last good list kept.`, + variant: "destructive", + }); + } + } catch (e) { + toast({ title: "Sync failed", description: errMessage(e), variant: "destructive" }); + } finally { + setSyncing(false); + } + }; + + const addManual = async () => { + const email = newEmail.trim(); + if (!email) return; + setAdding(true); + try { + const auth = await signAuthHeader(); + const r = await api.addProgramSignup(slug, { email, name: newName.trim() || null }, auth); + setManual((prev) => [r.data, ...prev.filter((m) => m.id !== r.data.id)]); + setNewEmail(""); + setNewName(""); + toast({ title: `Added ${r.data.email}` }); + } catch (e) { + toast({ title: "Couldn't add email", description: errMessage(e), variant: "destructive" }); + } finally { + setAdding(false); + } + }; + + const removeManual = async (id: string) => { + setBusyId(id); + try { + const auth = await signAuthHeader(); + await api.deleteProgramSignup(slug, id, auth); + setManual((prev) => prev.filter((m) => m.id !== id)); + toast({ title: "Removed" }); + } catch (e) { + toast({ title: "Couldn't remove", description: errMessage(e), variant: "destructive" }); + } finally { + setBusyId(null); + } + }; + + const countLabel = useMemo(() => (lumaCount === null ? "…" : String(lumaCount)), [lumaCount]); + + return ( +
+
+
+ ·LUMA-APPROVED GUESTS + + {countLabel} CHECKED-IN + + + ·SYNCED {relativeTime(program.lastGuestSyncAt)} + {statusBad ? ` (${status})` : ""} + +
+ LUMA API +
+ + {/* Luma event id */} +
+ +
+ setEventId(e.target.value)} + placeholder="evt-XXXXXXXXXXXX" + className="flex-1 min-w-[220px] bg-panel-deep border border-hairline px-2 py-1.5 font-mono text-[12px] text-display placeholder:text-label-dim" + /> + + +
+

+ THE CHECKED-IN GUEST LIST IS PULLED FROM LUMA. SUBMISSIONS ALSO TRIGGER A SYNC AUTOMATICALLY. + {!hasEventId ? " SET AN EVENT ID TO ACTIVATE THE GATE." : ""} +

+
+ + {/* Manual fallback add */} +
+
+ + ·MANUAL ADD (FALLBACK) +
+
+ setNewEmail(e.target.value)} + type="email" + placeholder="email@checked-in.com" + className="flex-1 min-w-[200px] bg-panel-deep border border-hairline px-2 py-1.5 font-mono text-[12px] text-display placeholder:text-label-dim" + /> + setNewName(e.target.value)} + placeholder="name (optional)" + className="w-[160px] bg-panel-deep border border-hairline px-2 py-1.5 font-mono text-[12px] text-display placeholder:text-label-dim" + /> + +
+

+ USE FOR ATTENDEES LUMA CAN'T COVER (CHECKED IN ON ANOTHER DEVICE, REGISTERED WITH A DIFFERENT EMAIL). +

+
+ + {/* Manual entries list (Luma-synced rows are not listed to minimize stored PII on screen) */} + {loading ? ( +
+ LOADING… +
+ ) : manual.length === 0 ? ( +

·NO MANUAL ENTRIES.

+ ) : ( +
+
·MANUAL ENTRIES ({manual.length})
+ {manual.map((m) => ( +
+
+
{m.email}
+ {m.name ?
{m.name}
: null} +
+ +
+ ))} +
+ )} +
+ ); +} diff --git a/client/src/components/program/SubmitProjectModal.tsx b/client/src/components/program/SubmitProjectModal.tsx index c31d744..0ebeb53 100644 --- a/client/src/components/program/SubmitProjectModal.tsx +++ b/client/src/components/program/SubmitProjectModal.tsx @@ -343,6 +343,11 @@ export function SubmitProjectModal({ className="hidden" /> + {submitting && ( +

+ CHECKING YOU AGAINST THE LUMA GUEST LIST. THIS CAN TAKE A FEW SECONDS. +

+ )}