diff --git a/apps/web/src/routes/admin/waitlists/[id]/signups.tsx b/apps/web/src/routes/admin/waitlists/[id]/signups.tsx index 71cbbf1..2a5522d 100644 --- a/apps/web/src/routes/admin/waitlists/[id]/signups.tsx +++ b/apps/web/src/routes/admin/waitlists/[id]/signups.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { Fragment, useState } from "react"; import { useParams } from "react-router-dom"; import { toast } from "sonner"; import { HugeiconsIcon } from "@hugeicons/react"; @@ -8,6 +8,7 @@ import { MoreHorizontalIcon, ArrowLeft01Icon, ArrowRight01Icon, + ArrowDown01Icon, UserGroupIcon, CheckmarkCircle02Icon, Clock01Icon, @@ -63,6 +64,8 @@ import { getExportUrl, ApiError, type SignupsParams, + type Signup, + type CustomField, } from "@/lib/queries"; import { useDelayedLoading } from "@/lib/hooks"; import { cn, formatNumber, formatRelativeTime } from "@/lib/utils"; @@ -76,6 +79,15 @@ export function WaitlistSignupsPage() { }); const [searchInput, setSearchInput] = useState(""); const [showCharts, setShowCharts] = useState(true); + // Rows expanded to show custom fields + metadata. Keyed by signup id so paging keeps them. + const [expanded, setExpanded] = useState>(new Set()); + const toggleExpanded = (signupId: string) => + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(signupId)) next.delete(signupId); + else next.add(signupId); + return next; + }); const { data: waitlistData } = useWaitlist(id!); const { data: signupsData, isLoading, isError: signupsError, error: signupsErrorData } = useSignups(id!, params); @@ -311,6 +323,7 @@ export function WaitlistSignupsPage() { + # Email Status @@ -323,6 +336,7 @@ export function WaitlistSignupsPage() { {showSignupsLoading && !signupsData ? ( Array.from({ length: 10 }).map((_, i) => ( + @@ -333,7 +347,7 @@ export function WaitlistSignupsPage() { )) ) : signupsError ? ( - +
@@ -345,7 +359,7 @@ export function WaitlistSignupsPage() { ) : !isLoading && signups.length === 0 ? ( - +

No signups yet

@@ -356,8 +370,27 @@ export function WaitlistSignupsPage() { ) : ( - signups.map((signup) => ( - + signups.map((signup) => { + const isOpen = expanded.has(signup.id); + return ( + + toggleExpanded(signup.id)} + > + + + {signup.position} @@ -371,7 +404,7 @@ export function WaitlistSignupsPage() { {formatRelativeTime(signup.createdAt)} - + e.stopPropagation()}>
@@ -444,6 +486,61 @@ export function WaitlistSignupsPage() { ); } +// Everything the table has no room for: the waitlist's custom fields (in the order they were +// defined, with their labels), then the submission metadata. +function SignupDetails({ signup, fields }: { signup: Signup; fields: CustomField[] }) { + const data = signup.customData || {}; + const known = fields.map((f) => ({ key: f.key, label: f.label, value: data[f.key] })); + const knownKeys = new Set(fields.map((f) => f.key)); + // Values submitted under a key that is no longer in the schema still deserve to be seen. + const extra = Object.entries(data) + .filter(([k]) => !knownKeys.has(k)) + .map(([key, value]) => ({ key, label: key, value })); + const custom = [...known, ...extra]; + + const meta: { label: string; value: string | null | undefined; mono?: boolean }[] = [ + { label: "Signed up", value: new Date(signup.createdAt).toLocaleString() }, + { label: "Confirmed", value: signup.confirmedAt ? new Date(signup.confirmedAt).toLocaleString() : "Not confirmed" }, + { label: "Unsubscribed", value: signup.unsubscribedAt ? new Date(signup.unsubscribedAt).toLocaleString() : null }, + { label: "Source", value: signup.referralSource || "direct" }, + { label: "IP address", value: signup.ipAddress, mono: true }, + { label: "User agent", value: signup.userAgent, mono: true }, + ]; + + return ( +
+
+

Custom fields

+ {custom.length === 0 ? ( +

This waitlist has no custom fields.

+ ) : ( +
+ {custom.map((f) => ( + +
{f.label}
+
+ {f.value || "—"} +
+
+ ))} +
+ )} +
+
+

Details

+
+ {meta.filter((m) => m.value).map((m) => ( + +
{m.label}
+
{m.value}
+
+ ))} +
+
+
+ ); +} + function StatusBadge({ status }: { status: "pending" | "confirmed" | "invited" }) { return (