Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 104 additions & 7 deletions apps/web/src/routes/admin/waitlists/[id]/signups.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -8,6 +8,7 @@ import {
MoreHorizontalIcon,
ArrowLeft01Icon,
ArrowRight01Icon,
ArrowDown01Icon,
UserGroupIcon,
CheckmarkCircle02Icon,
Clock01Icon,
Expand Down Expand Up @@ -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";
Expand All @@ -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<Set<string>>(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);
Expand Down Expand Up @@ -311,6 +323,7 @@ export function WaitlistSignupsPage() {
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-8"></TableHead>
<TableHead className="w-16">#</TableHead>
<TableHead>Email</TableHead>
<TableHead>Status</TableHead>
Expand All @@ -323,6 +336,7 @@ export function WaitlistSignupsPage() {
{showSignupsLoading && !signupsData ? (
Array.from({ length: 10 }).map((_, i) => (
<TableRow key={i}>
<TableCell></TableCell>
<TableCell><Skeleton className="h-4 w-8" /></TableCell>
<TableCell><Skeleton className="h-4 w-48" /></TableCell>
<TableCell><Skeleton className="h-5 w-20" /></TableCell>
Expand All @@ -333,7 +347,7 @@ export function WaitlistSignupsPage() {
))
) : signupsError ? (
<TableRow>
<TableCell colSpan={6} className="h-32">
<TableCell colSpan={7} className="h-32">
<div className="flex flex-col items-center justify-center text-center">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-destructive/10 mb-3">
<HugeiconsIcon icon={Alert02Icon} size={20} className="text-destructive" />
Expand All @@ -345,7 +359,7 @@ export function WaitlistSignupsPage() {
</TableRow>
) : !isLoading && signups.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="h-32 text-center">
<TableCell colSpan={7} className="h-32 text-center">
<div className="flex flex-col items-center justify-center text-muted-foreground/50">
<HugeiconsIcon icon={UserGroupIcon} size={32} className="mb-2" />
<p className="font-medium">No signups yet</p>
Expand All @@ -356,8 +370,27 @@ export function WaitlistSignupsPage() {
</TableCell>
</TableRow>
) : (
signups.map((signup) => (
<TableRow key={signup.id}>
signups.map((signup) => {
const isOpen = expanded.has(signup.id);
return (
<Fragment key={signup.id}>
<TableRow
className="cursor-pointer"
data-state={isOpen ? "open" : undefined}
onClick={() => toggleExpanded(signup.id)}
>
<TableCell className="pr-0">
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground"
aria-label={isOpen ? "Hide details" : "Show details"}
aria-expanded={isOpen}
onClick={(e) => { e.stopPropagation(); toggleExpanded(signup.id); }}
>
<HugeiconsIcon icon={isOpen ? ArrowDown01Icon : ArrowRight01Icon} size={14} />
</Button>
</TableCell>
<TableCell className="font-mono text-muted-foreground">
{signup.position}
</TableCell>
Expand All @@ -371,7 +404,7 @@ export function WaitlistSignupsPage() {
<TableCell className="text-muted-foreground">
{formatRelativeTime(signup.createdAt)}
</TableCell>
<TableCell>
<TableCell onClick={(e) => e.stopPropagation()}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
Expand Down Expand Up @@ -402,7 +435,16 @@ export function WaitlistSignupsPage() {
</DropdownMenu>
</TableCell>
</TableRow>
))
{isOpen && (
<TableRow className="hover:bg-transparent">
<TableCell colSpan={7} className="bg-muted/30 p-0">
<SignupDetails signup={signup} fields={waitlist?.customFields || []} />
</TableCell>
</TableRow>
)}
</Fragment>
);
})
)}
</TableBody>
</Table>
Expand Down Expand Up @@ -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 (
<div className="grid gap-6 px-4 py-4 pl-14 md:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]">
<div>
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">Custom fields</p>
{custom.length === 0 ? (
<p className="text-sm text-muted-foreground">This waitlist has no custom fields.</p>
) : (
<dl className="grid gap-x-6 gap-y-2 text-sm sm:grid-cols-[max-content_minmax(0,1fr)]">
{custom.map((f) => (
<Fragment key={f.key}>
<dt className="text-muted-foreground">{f.label}</dt>
<dd className={cn("min-w-0 whitespace-pre-wrap break-words", !f.value && "text-muted-foreground/60")}>
{f.value || "—"}
</dd>
</Fragment>
))}
</dl>
)}
</div>
<div>
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">Details</p>
<dl className="grid gap-x-6 gap-y-2 text-sm sm:grid-cols-[max-content_minmax(0,1fr)]">
{meta.filter((m) => m.value).map((m) => (
<Fragment key={m.label}>
<dt className="text-muted-foreground">{m.label}</dt>
<dd className={cn("min-w-0 break-all", m.mono && "font-mono text-xs leading-5")}>{m.value}</dd>
</Fragment>
))}
</dl>
</div>
</div>
);
}

function StatusBadge({ status }: { status: "pending" | "confirmed" | "invited" }) {
return (
<Badge
Expand Down