Skip to content

Commit b03b3b4

Browse files
feat(waitlist): add signup status filters (#80)
Co-authored-by: Anthony Ettinger <anthony@profullstack.com>
1 parent 715eaf6 commit b03b3b4

5 files changed

Lines changed: 95 additions & 11 deletions

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,8 @@ moshcoding.com/?dn=yourdomain.com
6363
plus `hashtag` and a linktree via `socials`/`links`). Every tenant page carries a
6464
**"© 2026 powered by moshcoding.com"** link.
6565
- **Waitlist**`POST /api/waitlist { email, dn? }`, stored in **libSQL / Turso**
66-
(`signups` table, unique per email+domain) via `@libsql/client`.
66+
(`signups` table, unique per email+domain) via `@libsql/client`. Domain owners
67+
can filter confirmed/pending signups and export the current view as CSV.
6768
- **Login** — "Log in with CoinPayPortal" (OAuth2 Auth Code + PKCE); email captured to a
6869
`users` table. Self-disables until the `COINPAY_*` + `SESSION_SECRET` env vars are set.
6970
- **www → apex** redirect handled in `middleware.ts` (308 to `https://moshcoding.com`).

app/api/waitlist/manage/route.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ 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";
56

67
export const runtime = "nodejs";
78
export const dynamic = "force-dynamic";
@@ -15,8 +16,9 @@ async function accountId(req: NextRequest): Promise<string | null> {
1516
return null;
1617
}
1718

18-
// GET /api/waitlist/manage?dn=<domain>[&format=csv] — the signups for one of the
19-
// caller's parked domains. Each domain's waitlist is separate (signups.dn).
19+
// GET /api/waitlist/manage?dn=<domain>[&format=csv][&status=verified|pending]
20+
// — the signups for one of the caller's parked domains. Each domain's
21+
// waitlist is separate (signups.dn).
2022
export async function GET(req: NextRequest) {
2123
const id = await accountId(req);
2224
if (!id) return NextResponse.json({ error: "Please sign in." }, { status: 401 });
@@ -25,7 +27,11 @@ export async function GET(req: NextRequest) {
2527
if (!(await ownsParkedDomain(id, dn))) {
2628
return NextResponse.json({ error: "You don't own that domain." }, { status: 403 });
2729
}
28-
const signups = await listDomainSignups(dn);
30+
const status = parseWaitlistStatus(req.nextUrl.searchParams.get("status"));
31+
if (!status) return NextResponse.json({ error: "invalid waitlist status" }, { status: 400 });
32+
33+
const allSignups = await listDomainSignups(dn);
34+
const signups = filterSignupsByStatus(allSignups, status);
2935

3036
if (req.nextUrl.searchParams.get("format") === "csv") {
3137
const rows = [["email", "verified", "ref", "created_at"]].concat(
@@ -35,9 +41,9 @@ export async function GET(req: NextRequest) {
3541
return new NextResponse(csv, {
3642
headers: {
3743
"content-type": "text/csv; charset=utf-8",
38-
"content-disposition": `attachment; filename="${dn}-waitlist.csv"`,
44+
"content-disposition": `attachment; filename="${dn}-waitlist${status === "all" ? "" : `-${status}`}.csv"`,
3945
},
4046
});
4147
}
42-
return NextResponse.json({ dn, count: signups.length, signups });
48+
return NextResponse.json({ dn, count: signups.length, total: allSignups.length, status, signups });
4349
}

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

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
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";
56
import MoshpitTlds from "@/components/MoshpitTlds";
67

78
// Tab values double as URL slugs: /dashboard/<tab> (the default "page" tab lives at
@@ -757,6 +758,7 @@ function WaitlistPanel({ onError }: { onError: (m: string) => void }) {
757758
const [domains, setDomains] = useState<any[] | undefined>(undefined);
758759
const [active, setActive] = useState<string | null>(null);
759760
const [signups, setSignups] = useState<any[] | null>(null);
761+
const [status, setStatus] = useState<WaitlistStatus>("all");
760762

761763
const load = async (dn: string) => {
762764
setActive(dn); setSignups(null);
@@ -781,6 +783,15 @@ function WaitlistPanel({ onError }: { onError: (m: string) => void }) {
781783
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>;
782784
}
783785

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;
789+
const filters: { value: WaitlistStatus; label: string; count: number }[] = [
790+
{ value: "all", label: "All", count: signups?.length ?? 0 },
791+
{ value: "verified", label: "Confirmed", count: verifiedCount },
792+
{ value: "pending", label: "Pending", count: pendingCount },
793+
];
794+
784795
return (
785796
<section className="card2">
786797
<h2>Waitlist</h2>
@@ -795,14 +806,33 @@ function WaitlistPanel({ onError }: { onError: (m: string) => void }) {
795806
{active && (
796807
<>
797808
<div className="row" style={{ justifyContent: "space-between", alignItems: "center" }}>
798-
<h3 className="ed-h">{active}{signups ? signups.length : "…"} signups</h3>
799-
<a className="btn2 ghost" href={`/api/waitlist/manage?dn=${encodeURIComponent(active)}&format=csv`}>Export CSV</a>
809+
<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"}
812+
</a>
813+
</div>
814+
<div className="tabs" role="group" aria-label="Filter waitlist signups">
815+
{filters.map((filter) => (
816+
<button
817+
key={filter.value}
818+
type="button"
819+
className={`tab${status === filter.value ? " on" : ""}`}
820+
aria-pressed={status === filter.value}
821+
onClick={() => setStatus(filter.value)}
822+
>
823+
{filter.label} <span className="muted">({filter.count})</span>
824+
</button>
825+
))}
800826
</div>
801827
<ul className="list">
802828
{signups === null && <li className="muted">Loading…</li>}
803-
{signups && signups.length === 0 && <li className="muted">No signups yet — share your page.</li>}
804-
{signups && signups.map((s, i) => (
805-
<li key={i}>
829+
{filtered && filtered.length === 0 && (
830+
<li className="muted">
831+
{signups?.length ? `No ${status === "verified" ? "confirmed" : status} signups.` : "No signups yet — share your page."}
832+
</li>
833+
)}
834+
{filtered && filtered.map((s) => (
835+
<li key={s.email}>
806836
<span>{s.email}</span>
807837
<span className="muted">{s.verified ? "✓ confirmed" : "pending"}{s.ref ? ` · ref:${s.ref}` : ""}</span>
808838
</li>

lib/waitlist-filter.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
export const WAITLIST_STATUSES = ["all", "verified", "pending"] as const;
2+
3+
export type WaitlistStatus = (typeof WAITLIST_STATUSES)[number];
4+
5+
export function parseWaitlistStatus(value: string | null): WaitlistStatus | null {
6+
if (value === null || value === "") return "all";
7+
return WAITLIST_STATUSES.includes(value as WaitlistStatus)
8+
? (value as WaitlistStatus)
9+
: null;
10+
}
11+
12+
export function filterSignupsByStatus<T extends { verified: boolean }>(
13+
signups: T[],
14+
status: WaitlistStatus,
15+
): T[] {
16+
if (status === "all") return signups;
17+
return signups.filter((signup) =>
18+
status === "verified" ? signup.verified : !signup.verified,
19+
);
20+
}

tests/waitlist-filter.test.mjs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import assert from "node:assert/strict";
2+
import test from "node:test";
3+
4+
import {
5+
filterSignupsByStatus,
6+
parseWaitlistStatus,
7+
} from "../lib/waitlist-filter.ts";
8+
9+
test("parseWaitlistStatus accepts supported filters and defaults to all", () => {
10+
assert.equal(parseWaitlistStatus(null), "all");
11+
assert.equal(parseWaitlistStatus(""), "all");
12+
assert.equal(parseWaitlistStatus("all"), "all");
13+
assert.equal(parseWaitlistStatus("verified"), "verified");
14+
assert.equal(parseWaitlistStatus("pending"), "pending");
15+
assert.equal(parseWaitlistStatus("unknown"), null);
16+
});
17+
18+
test("filterSignupsByStatus keeps the requested verification state", () => {
19+
const signups = [
20+
{ email: "confirmed@example.com", verified: true },
21+
{ email: "pending@example.com", verified: false },
22+
];
23+
24+
assert.equal(filterSignupsByStatus(signups, "all"), signups);
25+
assert.deepEqual(filterSignupsByStatus(signups, "verified"), [signups[0]]);
26+
assert.deepEqual(filterSignupsByStatus(signups, "pending"), [signups[1]]);
27+
});

0 commit comments

Comments
 (0)