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
11 changes: 9 additions & 2 deletions app/api/waitlist/manage/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { safeDomain } from "@/lib/config";
import {
filterSignupsByQuery,
filterSignupsByStatus,
parseWaitlistSort,
parseWaitlistStatus,
} from "@/lib/waitlist-filter";

Expand All @@ -20,7 +21,8 @@ async function accountId(req: NextRequest): Promise<string | null> {
return null;
}

// GET /api/waitlist/manage?dn=<domain>[&format=csv][&status=verified|pending][&q=<query>]
// GET /api/waitlist/manage?dn=<domain>[&format=csv][&status=verified|pending]
// [&q=<query>][&sort=newest|oldest|email]
// — 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 @@ -33,9 +35,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 sort = parseWaitlistSort(req.nextUrl.searchParams.get("sort"));
if (!sort) return NextResponse.json({ error: "invalid waitlist sort" }, { status: 400 });
const query = req.nextUrl.searchParams.get("q") || "";

const allSignups = await listDomainSignups(dn);
// Apply the requested order in SQL before the 1,000-row safety limit. If
// this were sorted here, "oldest" would only reorder the newest rows.
const allSignups = await listDomainSignups(dn, 1000, sort);
const signups = filterSignupsByQuery(
filterSignupsByStatus(allSignups, status),
query,
Expand All @@ -58,6 +64,7 @@ export async function GET(req: NextRequest) {
count: signups.length,
total: allSignups.length,
status,
sort,
query,
signups,
});
Expand Down
37 changes: 30 additions & 7 deletions app/dashboard/[[...tab]]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { formatStoredWebhookPayload } from "@/lib/webhook-payload";
import {
filterSignupsByQuery,
filterSignupsByStatus,
type WaitlistSort,
type WaitlistStatus,
} from "@/lib/waitlist-filter";
// Tab values double as URL slugs: /dashboard/<tab> (the default "page" tab lives at
Expand Down Expand Up @@ -779,22 +780,29 @@ 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 [sort, setSort] = useState<WaitlistSort>("newest");
const [query, setQuery] = useState("");
const loadRequest = useRef(0);

const load = async (dn: string) => {
const load = async (dn: string, order: WaitlistSort = sort) => {
const request = ++loadRequest.current;
setActive(dn); setSignups(null);
try {
const r = await fetch(`/api/waitlist/manage?dn=${encodeURIComponent(dn)}`);
const r = await fetch(`/api/waitlist/manage?dn=${encodeURIComponent(dn)}&sort=${order}`);
const d = await r.json();
if (request !== loadRequest.current) return;
if (!r.ok) throw new Error(d.error);
setSignups(d.signups || []);
} catch (e: any) { onError(e.message || "Failed to load."); setSignups([]); }
} catch (e: any) {
if (request !== loadRequest.current) return;
onError(e.message || "Failed to load."); setSignups([]);
}
};

useEffect(() => {
fetch("/api/account").then((r) => r.json()).then((d) => {
setDomains(d.parkedDomains || []);
if (d.parkedDomains?.[0]) load(d.parkedDomains[0].domain);
if (d.parkedDomains?.[0]) load(d.parkedDomains[0].domain, "newest");
}).catch(() => setDomains([]));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
Expand All @@ -808,7 +816,7 @@ function WaitlistPanel({ onError }: { onError: (m: string) => void }) {
const filtered = matchingSignups
? filterSignupsByStatus(matchingSignups, status)
: null;
const hasFilters = status !== "all" || query.trim() !== "";
const hasFilters = status !== "all" || query.trim() !== "" || sort !== "newest";
const verifiedCount = matchingSignups?.filter((signup) => signup.verified).length ?? 0;
const pendingCount = matchingSignups ? matchingSignups.length - verifiedCount : 0;
const filters: { value: WaitlistStatus; label: string; count: number }[] = [
Expand All @@ -823,7 +831,7 @@ function WaitlistPanel({ onError }: { onError: (m: string) => void }) {
<p className="sub">Each parked domain keeps its own waitlist.</p>
<div className="tabs" style={{ flexWrap: "wrap" }}>
{domains.map((d) => (
<button key={d.domain} className={`tab${active === d.domain ? " on" : ""}`} onClick={() => load(d.domain)}>
<button key={d.domain} className={`tab${active === d.domain ? " on" : ""}`} onClick={() => load(d.domain, sort)}>
{d.domain} <span className="muted">({d.count})</span>
</button>
))}
Expand All @@ -832,7 +840,7 @@ 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}&q=${encodeURIComponent(query)}`}>
<a className="btn2 ghost" href={`/api/waitlist/manage?dn=${encodeURIComponent(active)}&format=csv&status=${status}&q=${encodeURIComponent(query)}&sort=${sort}`}>
{hasFilters ? "Export filtered CSV" : "Export CSV"}
</a>
</div>
Expand All @@ -850,6 +858,21 @@ function WaitlistPanel({ onError }: { onError: (m: string) => void }) {
Clear
</button>
)}
<select
className="inp"
style={{ maxWidth: 180 }}
value={sort}
onChange={(event) => {
const nextSort = event.target.value as WaitlistSort;
setSort(nextSort);
if (active) void load(active, nextSort);
}}
aria-label="Sort waitlist signups"
>
<option value="newest">Newest first</option>
<option value="oldest">Oldest first</option>
<option value="email">Email A-Z</option>
</select>
</div>
<div className="tabs" role="group" aria-label="Filter waitlist signups">
{filters.map((filter) => (
Expand Down
14 changes: 12 additions & 2 deletions lib/db.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { createClient, type Client } from "@libsql/client";
import { randomBytes } from "node:crypto";
import type { WaitlistSort } from "./waitlist-filter";

let _db: Client | undefined;

Expand Down Expand Up @@ -1055,10 +1056,19 @@
}

/** The waitlist for one domain (that the caller owns). */
export async function listDomainSignups(domain: string, limit = 1000): Promise<{ email: string; verified: boolean; ref: string | null; created_at: string }[]> {
export async function listDomainSignups(
domain: string,
limit = 1000,
sort: WaitlistSort = "newest",
): Promise<{ email: string; verified: boolean; ref: string | null; created_at: string }[]> {
await ensureSchema();
const orderBy: Record<WaitlistSort, string> = {
newest: "created_at DESC, email COLLATE NOCASE ASC, email ASC",
oldest: "created_at ASC, email COLLATE NOCASE ASC, email ASC",
email: "email COLLATE NOCASE ASC, email ASC, created_at DESC",
};
const res = await db().execute({
sql: `SELECT email, verified_at, ref, created_at FROM signups WHERE dn = ? ORDER BY created_at DESC LIMIT ?`,
sql: `SELECT email, verified_at, ref, created_at FROM signups WHERE dn = ? ORDER BY ${orderBy[sort]} LIMIT ?`,
args: [domain.trim().toLowerCase(), limit],
});
return res.rows.map((r) => ({
Expand Down
9 changes: 9 additions & 0 deletions lib/waitlist-filter.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
export const WAITLIST_STATUSES = ["all", "verified", "pending"] as const;
export const WAITLIST_SORTS = ["newest", "oldest", "email"] as const;

export type WaitlistStatus = (typeof WAITLIST_STATUSES)[number];
export type WaitlistSort = (typeof WAITLIST_SORTS)[number];

export function parseWaitlistStatus(value: string | null): WaitlistStatus | null {
if (value === null || value === "") return "all";
Expand All @@ -9,6 +11,13 @@ export function parseWaitlistStatus(value: string | null): WaitlistStatus | null
: null;
}

export function parseWaitlistSort(value: string | null): WaitlistSort | null {
if (value === null || value === "") return "newest";
return WAITLIST_SORTS.includes(value as WaitlistSort)
? (value as WaitlistSort)
: null;
}

export function filterSignupsByStatus<T extends { verified: boolean }>(
signups: T[],
status: WaitlistStatus,
Expand Down
10 changes: 10 additions & 0 deletions tests/waitlist-filter.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import test from "node:test";
import {
filterSignupsByQuery,
filterSignupsByStatus,
parseWaitlistSort,
parseWaitlistStatus,
} from "../lib/waitlist-filter.ts";

Expand All @@ -16,6 +17,15 @@ test("parseWaitlistStatus accepts supported filters and defaults to all", () =>
assert.equal(parseWaitlistStatus("unknown"), null);
});

test("parseWaitlistSort accepts supported orders and defaults to newest", () => {
assert.equal(parseWaitlistSort(null), "newest");
assert.equal(parseWaitlistSort(""), "newest");
assert.equal(parseWaitlistSort("newest"), "newest");
assert.equal(parseWaitlistSort("oldest"), "oldest");
assert.equal(parseWaitlistSort("email"), "email");
assert.equal(parseWaitlistSort("random"), null);
});

test("filterSignupsByStatus keeps the requested verification state", () => {
const signups = [
{ email: "confirmed@example.com", verified: true },
Expand Down
38 changes: 38 additions & 0 deletions tests/waitlist-sort-db.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import assert from "node:assert/strict";
import test from "node:test";

process.env.TURSO_DATABASE_URL = "file::memory:";

const { db, ensureSchema, listDomainSignups } = await import("../lib/db.ts");

await ensureSchema();

await db().batch([
{
sql: "INSERT INTO signups (email, dn, created_at) VALUES (?, ?, ?)",
args: ["middle@example.com", "sort.test", "2026-02-01 00:00:00"],
},
{
sql: "INSERT INTO signups (email, dn, created_at) VALUES (?, ?, ?)",
args: ["zulu@example.com", "sort.test", "2026-01-01 00:00:00"],
},
{
sql: "INSERT INTO signups (email, dn, created_at) VALUES (?, ?, ?)",
args: ["Alpha@example.com", "sort.test", "2026-03-01 00:00:00"],
},
]);

test("waitlist sorting happens before the row limit", async () => {
assert.deepEqual(
(await listDomainSignups("sort.test", 2, "newest")).map((signup) => signup.email),
["Alpha@example.com", "middle@example.com"],
);
assert.deepEqual(
(await listDomainSignups("sort.test", 2, "oldest")).map((signup) => signup.email),
["zulu@example.com", "middle@example.com"],
);
assert.deepEqual(
(await listDomainSignups("sort.test", 2, "email")).map((signup) => signup.email),
["Alpha@example.com", "middle@example.com"],
);
});
Loading