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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down
16 changes: 11 additions & 5 deletions app/api/waitlist/manage/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -15,8 +16,9 @@ async function accountId(req: NextRequest): Promise<string | null> {
return null;
}

// GET /api/waitlist/manage?dn=<domain>[&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=<domain>[&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 });
Expand All @@ -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(
Expand All @@ -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 });
}
40 changes: 35 additions & 5 deletions app/dashboard/[[...tab]]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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/<tab> (the default "page" tab lives at
Expand Down Expand Up @@ -757,6 +758,7 @@ function WaitlistPanel({ onError }: { onError: (m: string) => void }) {
const [domains, setDomains] = useState<any[] | undefined>(undefined);
const [active, setActive] = useState<string | null>(null);
const [signups, setSignups] = useState<any[] | null>(null);
const [status, setStatus] = useState<WaitlistStatus>("all");

const load = async (dn: string) => {
setActive(dn); setSignups(null);
Expand All @@ -781,6 +783,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 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 (
<section className="card2">
<h2>Waitlist</h2>
Expand All @@ -795,14 +806,33 @@ function WaitlistPanel({ onError }: { onError: (m: string) => void }) {
{active && (
<>
<div className="row" style={{ justifyContent: "space-between", alignItems: "center" }}>
<h3 className="ed-h">{active} — {signups ? signups.length : "…"} signups</h3>
<a className="btn2 ghost" href={`/api/waitlist/manage?dn=${encodeURIComponent(active)}&format=csv`}>Export CSV</a>
<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>
</div>
<div className="tabs" role="group" aria-label="Filter waitlist signups">
{filters.map((filter) => (
<button
key={filter.value}
type="button"
className={`tab${status === filter.value ? " on" : ""}`}
aria-pressed={status === filter.value}
onClick={() => setStatus(filter.value)}
>
{filter.label} <span className="muted">({filter.count})</span>
</button>
))}
</div>
<ul className="list">
{signups === null && <li className="muted">Loading…</li>}
{signups && signups.length === 0 && <li className="muted">No signups yet — share your page.</li>}
{signups && signups.map((s, i) => (
<li key={i}>
{filtered && filtered.length === 0 && (
<li className="muted">
{signups?.length ? `No ${status === "verified" ? "confirmed" : status} signups.` : "No signups yet — share your page."}
</li>
)}
{filtered && filtered.map((s) => (
<li key={s.email}>
<span>{s.email}</span>
<span className="muted">{s.verified ? "✓ confirmed" : "pending"}{s.ref ? ` · ref:${s.ref}` : ""}</span>
</li>
Expand Down
20 changes: 20 additions & 0 deletions lib/waitlist-filter.ts
Original file line number Diff line number Diff line change
@@ -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<T extends { verified: boolean }>(
signups: T[],
status: WaitlistStatus,
): T[] {
if (status === "all") return signups;
return signups.filter((signup) =>
status === "verified" ? signup.verified : !signup.verified,
);
}
27 changes: 27 additions & 0 deletions tests/waitlist-filter.test.mjs
Original file line number Diff line number Diff line change
@@ -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]]);
});
Loading