diff --git a/README.md b/README.md index eec4023..d991bd1 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,8 @@ moshcoding.com/?dn=yourdomain.com plus `hashtag` and a linktree via `socials`/`links`). Every tenant page carries a **"© 2026 powered by moshcoding.com"** link. - **Waitlist** — `POST /api/waitlist { email, dn? }`, stored in **libSQL / Turso** - (`signups` table, unique per email+domain) via `@libsql/client`. + (`signups` table, unique per email+domain) via `@libsql/client`. Domain owners + can filter confirmed/pending signups and export the current view as CSV. - **Login** — "Log in with CoinPayPortal" (OAuth2 Auth Code + PKCE); email captured to a `users` table. Self-disables until the `COINPAY_*` + `SESSION_SECRET` env vars are set. - **www → apex** redirect handled in `middleware.ts` (308 to `https://moshcoding.com`). diff --git a/app/api/waitlist/manage/route.ts b/app/api/waitlist/manage/route.ts index e241c5a..1773a7b 100644 --- a/app/api/waitlist/manage/route.ts +++ b/app/api/waitlist/manage/route.ts @@ -2,6 +2,7 @@ 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"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -15,8 +16,9 @@ async function accountId(req: NextRequest): Promise { return null; } -// GET /api/waitlist/manage?dn=[&format=csv] — the signups for one of the -// caller's parked domains. Each domain's waitlist is separate (signups.dn). +// GET /api/waitlist/manage?dn=[&format=csv][&status=verified|pending] +// — the signups for one of the caller's parked domains. Each domain's +// waitlist is separate (signups.dn). export async function GET(req: NextRequest) { const id = await accountId(req); if (!id) return NextResponse.json({ error: "Please sign in." }, { status: 401 }); @@ -25,7 +27,11 @@ export async function GET(req: NextRequest) { if (!(await ownsParkedDomain(id, dn))) { return NextResponse.json({ error: "You don't own that domain." }, { status: 403 }); } - const signups = await listDomainSignups(dn); + const status = parseWaitlistStatus(req.nextUrl.searchParams.get("status")); + if (!status) return NextResponse.json({ error: "invalid waitlist status" }, { status: 400 }); + + const allSignups = await listDomainSignups(dn); + const signups = filterSignupsByStatus(allSignups, status); if (req.nextUrl.searchParams.get("format") === "csv") { const rows = [["email", "verified", "ref", "created_at"]].concat( @@ -35,9 +41,9 @@ export async function GET(req: NextRequest) { return new NextResponse(csv, { headers: { "content-type": "text/csv; charset=utf-8", - "content-disposition": `attachment; filename="${dn}-waitlist.csv"`, + "content-disposition": `attachment; filename="${dn}-waitlist${status === "all" ? "" : `-${status}`}.csv"`, }, }); } - return NextResponse.json({ dn, count: signups.length, signups }); + return NextResponse.json({ dn, count: signups.length, total: allSignups.length, status, signups }); } diff --git a/app/dashboard/[[...tab]]/page.tsx b/app/dashboard/[[...tab]]/page.tsx index 74b30ee..fa14f59 100644 --- a/app/dashboard/[[...tab]]/page.tsx +++ b/app/dashboard/[[...tab]]/page.tsx @@ -2,6 +2,7 @@ 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 MoshpitTlds from "@/components/MoshpitTlds"; // Tab values double as URL slugs: /dashboard/ (the default "page" tab lives at @@ -757,6 +758,7 @@ function WaitlistPanel({ onError }: { onError: (m: string) => void }) { const [domains, setDomains] = useState(undefined); const [active, setActive] = useState(null); const [signups, setSignups] = useState(null); + const [status, setStatus] = useState("all"); const load = async (dn: string) => { setActive(dn); setSignups(null); @@ -781,6 +783,15 @@ function WaitlistPanel({ onError }: { onError: (m: string) => void }) { return

Waitlist

No parked domains yet — claim one on the “Domains” tab and its waitlist shows up here.

; } + const filtered = signups ? filterSignupsByStatus(signups, status) : null; + const verifiedCount = signups?.filter((signup) => signup.verified).length ?? 0; + const pendingCount = signups ? signups.length - verifiedCount : 0; + const filters: { value: WaitlistStatus; label: string; count: number }[] = [ + { value: "all", label: "All", count: signups?.length ?? 0 }, + { value: "verified", label: "Confirmed", count: verifiedCount }, + { value: "pending", label: "Pending", count: pendingCount }, + ]; + return (

Waitlist

@@ -795,14 +806,33 @@ function WaitlistPanel({ onError }: { onError: (m: string) => void }) { {active && ( <>
-

{active} — {signups ? signups.length : "…"} signups

- Export CSV +

{active} — {filtered ? filtered.length : "…"} signups

+ + {status === "all" ? "Export CSV" : "Export filtered CSV"} + +
+
+ {filters.map((filter) => ( + + ))}
    {signups === null &&
  • Loading…
  • } - {signups && signups.length === 0 &&
  • No signups yet — share your page.
  • } - {signups && signups.map((s, i) => ( -
  • + {filtered && filtered.length === 0 && ( +
  • + {signups?.length ? `No ${status === "verified" ? "confirmed" : status} signups.` : "No signups yet — share your page."} +
  • + )} + {filtered && filtered.map((s) => ( +
  • {s.email} {s.verified ? "✓ confirmed" : "pending"}{s.ref ? ` · ref:${s.ref}` : ""}
  • diff --git a/lib/waitlist-filter.ts b/lib/waitlist-filter.ts new file mode 100644 index 0000000..6efb635 --- /dev/null +++ b/lib/waitlist-filter.ts @@ -0,0 +1,20 @@ +export const WAITLIST_STATUSES = ["all", "verified", "pending"] as const; + +export type WaitlistStatus = (typeof WAITLIST_STATUSES)[number]; + +export function parseWaitlistStatus(value: string | null): WaitlistStatus | null { + if (value === null || value === "") return "all"; + return WAITLIST_STATUSES.includes(value as WaitlistStatus) + ? (value as WaitlistStatus) + : null; +} + +export function filterSignupsByStatus( + signups: T[], + status: WaitlistStatus, +): T[] { + if (status === "all") return signups; + return signups.filter((signup) => + status === "verified" ? signup.verified : !signup.verified, + ); +} diff --git a/tests/waitlist-filter.test.mjs b/tests/waitlist-filter.test.mjs new file mode 100644 index 0000000..4a156b3 --- /dev/null +++ b/tests/waitlist-filter.test.mjs @@ -0,0 +1,27 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + filterSignupsByStatus, + parseWaitlistStatus, +} from "../lib/waitlist-filter.ts"; + +test("parseWaitlistStatus accepts supported filters and defaults to all", () => { + assert.equal(parseWaitlistStatus(null), "all"); + assert.equal(parseWaitlistStatus(""), "all"); + assert.equal(parseWaitlistStatus("all"), "all"); + assert.equal(parseWaitlistStatus("verified"), "verified"); + assert.equal(parseWaitlistStatus("pending"), "pending"); + assert.equal(parseWaitlistStatus("unknown"), null); +}); + +test("filterSignupsByStatus keeps the requested verification state", () => { + const signups = [ + { email: "confirmed@example.com", verified: true }, + { email: "pending@example.com", verified: false }, + ]; + + assert.equal(filterSignupsByStatus(signups, "all"), signups); + assert.deepEqual(filterSignupsByStatus(signups, "verified"), [signups[0]]); + assert.deepEqual(filterSignupsByStatus(signups, "pending"), [signups[1]]); +});