Skip to content

Commit 1ff0b68

Browse files
feat(waitlist): search signups by email or referral (#89)
1 parent 195a5d2 commit 1ff0b68

4 files changed

Lines changed: 83 additions & 12 deletions

File tree

app/api/waitlist/manage/route.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@ import { NextRequest, NextResponse } from "next/server";
22
import { readSession, authConfigured, SESSION_COOKIE } from "@/lib/session";
33
import { findOrCreateAccountByEmail, ownsParkedDomain, listDomainSignups } from "@/lib/db";
44
import { safeDomain } from "@/lib/config";
5-
import { filterSignupsByStatus, parseWaitlistStatus } from "@/lib/waitlist-filter";
5+
import {
6+
filterSignupsByQuery,
7+
filterSignupsByStatus,
8+
parseWaitlistStatus,
9+
} from "@/lib/waitlist-filter";
610

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

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

3338
const allSignups = await listDomainSignups(dn);
34-
const signups = filterSignupsByStatus(allSignups, status);
39+
const signups = filterSignupsByQuery(
40+
filterSignupsByStatus(allSignups, status),
41+
query,
42+
);
3543

3644
if (req.nextUrl.searchParams.get("format") === "csv") {
3745
const rows = [["email", "verified", "ref", "created_at"]].concat(
@@ -45,5 +53,12 @@ export async function GET(req: NextRequest) {
4553
},
4654
});
4755
}
48-
return NextResponse.json({ dn, count: signups.length, total: allSignups.length, status, signups });
56+
return NextResponse.json({
57+
dn,
58+
count: signups.length,
59+
total: allSignups.length,
60+
status,
61+
query,
62+
signups,
63+
});
4964
}

app/dashboard/[[...tab]]/page.tsx

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@
22
import { useEffect, useState, useCallback, useRef } from "react";
33
import { useParams } from "next/navigation";
44
import { copyText } from "@/lib/clipboard";
5-
import { filterSignupsByStatus, type WaitlistStatus } from "@/lib/waitlist-filter";
5+
import {
6+
filterSignupsByQuery,
7+
filterSignupsByStatus,
8+
type WaitlistStatus,
9+
} from "@/lib/waitlist-filter";
610
import MoshpitTlds from "@/components/MoshpitTlds";
711

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

763768
const load = async (dn: string) => {
764769
setActive(dn); setSignups(null);
@@ -783,11 +788,15 @@ function WaitlistPanel({ onError }: { onError: (m: string) => void }) {
783788
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>;
784789
}
785790

786-
const filtered = signups ? filterSignupsByStatus(signups, status) : null;
787-
const verifiedCount = signups?.filter((signup) => signup.verified).length ?? 0;
788-
const pendingCount = signups ? signups.length - verifiedCount : 0;
791+
const matchingSignups = signups ? filterSignupsByQuery(signups, query) : null;
792+
const filtered = matchingSignups
793+
? filterSignupsByStatus(matchingSignups, status)
794+
: null;
795+
const hasFilters = status !== "all" || query.trim() !== "";
796+
const verifiedCount = matchingSignups?.filter((signup) => signup.verified).length ?? 0;
797+
const pendingCount = matchingSignups ? matchingSignups.length - verifiedCount : 0;
789798
const filters: { value: WaitlistStatus; label: string; count: number }[] = [
790-
{ value: "all", label: "All", count: signups?.length ?? 0 },
799+
{ value: "all", label: "All", count: matchingSignups?.length ?? 0 },
791800
{ value: "verified", label: "Confirmed", count: verifiedCount },
792801
{ value: "pending", label: "Pending", count: pendingCount },
793802
];
@@ -807,10 +816,25 @@ function WaitlistPanel({ onError }: { onError: (m: string) => void }) {
807816
<>
808817
<div className="row" style={{ justifyContent: "space-between", alignItems: "center" }}>
809818
<h3 className="ed-h">{active}{filtered ? filtered.length : "…"} signups</h3>
810-
<a className="btn2 ghost" href={`/api/waitlist/manage?dn=${encodeURIComponent(active)}&format=csv&status=${status}`}>
811-
{status === "all" ? "Export CSV" : "Export filtered CSV"}
819+
<a className="btn2 ghost" href={`/api/waitlist/manage?dn=${encodeURIComponent(active)}&format=csv&status=${status}&q=${encodeURIComponent(query)}`}>
820+
{hasFilters ? "Export filtered CSV" : "Export CSV"}
812821
</a>
813822
</div>
823+
<div className="row" style={{ alignItems: "center" }}>
824+
<input
825+
className="inp"
826+
type="search"
827+
value={query}
828+
onChange={(event) => setQuery(event.target.value)}
829+
placeholder="Search by email or referral code"
830+
aria-label="Search waitlist signups"
831+
/>
832+
{query && (
833+
<button className="btn2 ghost" type="button" onClick={() => setQuery("")}>
834+
Clear
835+
</button>
836+
)}
837+
</div>
814838
<div className="tabs" role="group" aria-label="Filter waitlist signups">
815839
{filters.map((filter) => (
816840
<button
@@ -828,7 +852,11 @@ function WaitlistPanel({ onError }: { onError: (m: string) => void }) {
828852
{signups === null && <li className="muted">Loading…</li>}
829853
{filtered && filtered.length === 0 && (
830854
<li className="muted">
831-
{signups?.length ? `No ${status === "verified" ? "confirmed" : status} signups.` : "No signups yet — share your page."}
855+
{signups?.length
856+
? hasFilters
857+
? "No signups match the current filters."
858+
: "No signups found."
859+
: "No signups yet — share your page."}
832860
</li>
833861
)}
834862
{filtered && filtered.map((s) => (

lib/waitlist-filter.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,17 @@ export function filterSignupsByStatus<T extends { verified: boolean }>(
1818
status === "verified" ? signup.verified : !signup.verified,
1919
);
2020
}
21+
22+
export function filterSignupsByQuery<T extends { email: string; ref?: string | null }>(
23+
signups: T[],
24+
query: string,
25+
): T[] {
26+
const normalizedQuery = query.trim().toLowerCase();
27+
if (!normalizedQuery) return signups;
28+
29+
return signups.filter(
30+
(signup) =>
31+
signup.email.toLowerCase().includes(normalizedQuery) ||
32+
signup.ref?.toLowerCase().includes(normalizedQuery),
33+
);
34+
}

tests/waitlist-filter.test.mjs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
22
import test from "node:test";
33

44
import {
5+
filterSignupsByQuery,
56
filterSignupsByStatus,
67
parseWaitlistStatus,
78
} from "../lib/waitlist-filter.ts";
@@ -25,3 +26,16 @@ test("filterSignupsByStatus keeps the requested verification state", () => {
2526
assert.deepEqual(filterSignupsByStatus(signups, "verified"), [signups[0]]);
2627
assert.deepEqual(filterSignupsByStatus(signups, "pending"), [signups[1]]);
2728
});
29+
30+
test("filterSignupsByQuery matches email and referral code", () => {
31+
const signups = [
32+
{ email: "Alice@Example.com", ref: "FRIEND-42" },
33+
{ email: "bob@example.com", ref: null },
34+
];
35+
36+
assert.deepEqual(filterSignupsByQuery(signups, " alice "), [signups[0]]);
37+
assert.deepEqual(filterSignupsByQuery(signups, "friend-42"), [signups[0]]);
38+
assert.deepEqual(filterSignupsByQuery(signups, "BOB@"), [signups[1]]);
39+
assert.deepEqual(filterSignupsByQuery(signups, "missing"), []);
40+
assert.equal(filterSignupsByQuery(signups, " "), signups);
41+
});

0 commit comments

Comments
 (0)