Skip to content
Merged
Show file tree
Hide file tree
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
23 changes: 19 additions & 4 deletions app/api/waitlist/manage/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ import { NextRequest, NextResponse } from "next/server";
import { readSession, authConfigured, SESSION_COOKIE } from "@/lib/session";
import { findOrCreateAccountByEmail, ownsParkedDomain, listDomainSignups } from "@/lib/db";
import { safeDomain } from "@/lib/config";
import { filterSignupsByStatus, parseWaitlistStatus } from "@/lib/waitlist-filter";
import {
filterSignupsByQuery,
filterSignupsByStatus,
parseWaitlistStatus,
} from "@/lib/waitlist-filter";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";
Expand All @@ -16,7 +20,7 @@ async function accountId(req: NextRequest): Promise<string | null> {
return null;
}

// GET /api/waitlist/manage?dn=<domain>[&format=csv][&status=verified|pending]
// GET /api/waitlist/manage?dn=<domain>[&format=csv][&status=verified|pending][&q=<query>]
// — the signups for one of the caller's parked domains. Each domain's
// waitlist is separate (signups.dn).
export async function GET(req: NextRequest) {
Expand All @@ -29,9 +33,13 @@ export async function GET(req: NextRequest) {
}
const status = parseWaitlistStatus(req.nextUrl.searchParams.get("status"));
if (!status) return NextResponse.json({ error: "invalid waitlist status" }, { status: 400 });
const query = req.nextUrl.searchParams.get("q") || "";

const allSignups = await listDomainSignups(dn);
const signups = filterSignupsByStatus(allSignups, status);
const signups = filterSignupsByQuery(
filterSignupsByStatus(allSignups, status),
query,
);

if (req.nextUrl.searchParams.get("format") === "csv") {
const rows = [["email", "verified", "ref", "created_at"]].concat(
Expand All @@ -45,5 +53,12 @@ export async function GET(req: NextRequest) {
},
});
}
return NextResponse.json({ dn, count: signups.length, total: allSignups.length, status, signups });
return NextResponse.json({
dn,
count: signups.length,
total: allSignups.length,
status,
query,
signups,
});
}
44 changes: 36 additions & 8 deletions app/dashboard/[[...tab]]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@
import { useEffect, useState, useCallback, useRef } from "react";
import { useParams } from "next/navigation";
import { copyText } from "@/lib/clipboard";
import { filterSignupsByStatus, type WaitlistStatus } from "@/lib/waitlist-filter";
import {
filterSignupsByQuery,
filterSignupsByStatus,
type WaitlistStatus,
} from "@/lib/waitlist-filter";
import MoshpitTlds from "@/components/MoshpitTlds";

// Tab values double as URL slugs: /dashboard/<tab> (the default "page" tab lives at
Expand Down Expand Up @@ -759,6 +763,7 @@ function WaitlistPanel({ onError }: { onError: (m: string) => void }) {
const [active, setActive] = useState<string | null>(null);
const [signups, setSignups] = useState<any[] | null>(null);
const [status, setStatus] = useState<WaitlistStatus>("all");
const [query, setQuery] = useState("");

const load = async (dn: string) => {
setActive(dn); setSignups(null);
Expand All @@ -783,11 +788,15 @@ function WaitlistPanel({ onError }: { onError: (m: string) => void }) {
return <section className="card2"><h2>Waitlist</h2><p className="sub">No parked domains yet — claim one on the “Domains” tab and its waitlist shows up here.</p></section>;
}

const filtered = signups ? filterSignupsByStatus(signups, status) : null;
const verifiedCount = signups?.filter((signup) => signup.verified).length ?? 0;
const pendingCount = signups ? signups.length - verifiedCount : 0;
const matchingSignups = signups ? filterSignupsByQuery(signups, query) : null;
const filtered = matchingSignups
? filterSignupsByStatus(matchingSignups, status)
: null;
const hasFilters = status !== "all" || query.trim() !== "";
const verifiedCount = matchingSignups?.filter((signup) => signup.verified).length ?? 0;
const pendingCount = matchingSignups ? matchingSignups.length - verifiedCount : 0;
const filters: { value: WaitlistStatus; label: string; count: number }[] = [
{ value: "all", label: "All", count: signups?.length ?? 0 },
{ value: "all", label: "All", count: matchingSignups?.length ?? 0 },
{ value: "verified", label: "Confirmed", count: verifiedCount },
{ value: "pending", label: "Pending", count: pendingCount },
];
Expand All @@ -807,10 +816,25 @@ function WaitlistPanel({ onError }: { onError: (m: string) => void }) {
<>
<div className="row" style={{ justifyContent: "space-between", alignItems: "center" }}>
<h3 className="ed-h">{active} — {filtered ? filtered.length : "…"} signups</h3>
<a className="btn2 ghost" href={`/api/waitlist/manage?dn=${encodeURIComponent(active)}&format=csv&status=${status}`}>
{status === "all" ? "Export CSV" : "Export filtered CSV"}
<a className="btn2 ghost" href={`/api/waitlist/manage?dn=${encodeURIComponent(active)}&format=csv&status=${status}&q=${encodeURIComponent(query)}`}>
{hasFilters ? "Export filtered CSV" : "Export CSV"}
</a>
</div>
<div className="row" style={{ alignItems: "center" }}>
<input
className="inp"
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search by email or referral code"
aria-label="Search waitlist signups"
/>
{query && (
<button className="btn2 ghost" type="button" onClick={() => setQuery("")}>
Clear
</button>
)}
</div>
<div className="tabs" role="group" aria-label="Filter waitlist signups">
{filters.map((filter) => (
<button
Expand All @@ -828,7 +852,11 @@ function WaitlistPanel({ onError }: { onError: (m: string) => void }) {
{signups === null && <li className="muted">Loading…</li>}
{filtered && filtered.length === 0 && (
<li className="muted">
{signups?.length ? `No ${status === "verified" ? "confirmed" : status} signups.` : "No signups yet — share your page."}
{signups?.length
? hasFilters
? "No signups match the current filters."
: "No signups found."
: "No signups yet — share your page."}
</li>
)}
{filtered && filtered.map((s) => (
Expand Down
14 changes: 14 additions & 0 deletions lib/waitlist-filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,17 @@ export function filterSignupsByStatus<T extends { verified: boolean }>(
status === "verified" ? signup.verified : !signup.verified,
);
}

export function filterSignupsByQuery<T extends { email: string; ref?: string | null }>(
signups: T[],
query: string,
): T[] {
const normalizedQuery = query.trim().toLowerCase();
if (!normalizedQuery) return signups;

return signups.filter(
(signup) =>
signup.email.toLowerCase().includes(normalizedQuery) ||
signup.ref?.toLowerCase().includes(normalizedQuery),
);
}
14 changes: 14 additions & 0 deletions tests/waitlist-filter.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import test from "node:test";

import {
filterSignupsByQuery,
filterSignupsByStatus,
parseWaitlistStatus,
} from "../lib/waitlist-filter.ts";
Expand All @@ -25,3 +26,16 @@ test("filterSignupsByStatus keeps the requested verification state", () => {
assert.deepEqual(filterSignupsByStatus(signups, "verified"), [signups[0]]);
assert.deepEqual(filterSignupsByStatus(signups, "pending"), [signups[1]]);
});

test("filterSignupsByQuery matches email and referral code", () => {
const signups = [
{ email: "Alice@Example.com", ref: "FRIEND-42" },
{ email: "bob@example.com", ref: null },
];

assert.deepEqual(filterSignupsByQuery(signups, " alice "), [signups[0]]);
assert.deepEqual(filterSignupsByQuery(signups, "friend-42"), [signups[0]]);
assert.deepEqual(filterSignupsByQuery(signups, "BOB@"), [signups[1]]);
assert.deepEqual(filterSignupsByQuery(signups, "missing"), []);
assert.equal(filterSignupsByQuery(signups, " "), signups);
});
Loading