From 575feb7bddb86d031d8193aa7da5589e06afafed Mon Sep 17 00:00:00 2001
From: tonym
Date: Sun, 16 Aug 2026 22:28:46 -0500
Subject: [PATCH] refactor: split Finder into NameCard/Wishlist/AiControls +
useOtmSession/useWishlist hooks + availability-client (v0.4.1)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Pure code motion; Finder.tsx 754 → 347 lines. No behavior change.
Co-Authored-By: Claude Fable 5
Claude-Session: https://claude.ai/code/session_01XStQKxPfEjrTvWo83KFCxG
---
CLAUDE.md | 8 +
package.json | 2 +-
src/components/AiControls.tsx | 67 +++++
src/components/Finder.tsx | 491 +++------------------------------
src/components/NameCard.tsx | 149 ++++++++++
src/components/Wishlist.tsx | 105 +++++++
src/hooks/useOtmSession.ts | 51 ++++
src/hooks/useWishlist.ts | 107 +++++++
src/lib/availability-client.ts | 51 ++++
src/lib/version.ts | 7 +-
10 files changed, 587 insertions(+), 451 deletions(-)
create mode 100644 src/components/AiControls.tsx
create mode 100644 src/components/NameCard.tsx
create mode 100644 src/components/Wishlist.tsx
create mode 100644 src/hooks/useOtmSession.ts
create mode 100644 src/hooks/useWishlist.ts
create mode 100644 src/lib/availability-client.ts
diff --git a/CLAUDE.md b/CLAUDE.md
index d302670..24d576e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -88,6 +88,14 @@ bare unstyled button (issue #2). Tailwind is configured with `preflight: false`
scoped to `.wrap`, and the widget mounts *outside* `.wrap`, so neither side can
restyle the other. Keep it that way when adding global CSS.
+## Client layout
+
+`components/Finder.tsx` is orchestration only (inputs → generate → lookup).
+Cards are `NameCard.tsx`, the Saved panel `Wishlist.tsx`, the AI switch
+`AiControls.tsx`; session and shortlist state are `hooks/useOtmSession.ts` and
+`hooks/useWishlist.ts`; the availability result shape + batch fetch is
+`lib/availability-client.ts`. Add UI to the piece it belongs to, not to Finder.
+
## Wishlist is browser-only
`src/lib/wishlist.ts` — `localStorage` key `names.wishlist`, capped at 60. It
diff --git a/package.json b/package.json
index ab65b92..5a94d6b 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "names-poweredbyotm",
- "version": "0.4.0",
+ "version": "0.4.1",
"private": true,
"scripts": {
"dev": "next dev --port 3000",
diff --git a/src/components/AiControls.tsx b/src/components/AiControls.tsx
new file mode 100644
index 0000000..62388a6
--- /dev/null
+++ b/src/components/AiControls.tsx
@@ -0,0 +1,67 @@
+"use client";
+
+// The AI switch next to the generate button. Three states, one slot:
+// own key saved → "Use my Claude/Gemini key" (browser-direct path)
+// signed in → "AI names" (platform gateway) + sign out
+// platform wired → "Sign in with OTM for AI names →"
+// none → nothing
+// A saved key always wins over the platform path — see CLAUDE.md "AI — two paths".
+
+import type { AiSettings } from "@/lib/ai";
+import { otmSsoUrl } from "@/lib/otm";
+import type { OtmAccount } from "@/hooks/useOtmSession";
+
+const LABEL_STYLE = { display: "inline-flex", alignItems: "center", gap: 8, fontSize: 14, color: "var(--muted)" } as const;
+
+export function AiControls({
+ ai,
+ useAi,
+ onUseAi,
+ platformAi,
+ account,
+ usePlatformAi,
+ onUsePlatformAi,
+ onSignOut,
+}: {
+ ai: AiSettings | null;
+ useAi: boolean;
+ onUseAi: (v: boolean) => void;
+ platformAi: boolean;
+ account: OtmAccount | null;
+ usePlatformAi: boolean;
+ onUsePlatformAi: (v: boolean) => void;
+ onSignOut: () => void;
+}) {
+ if (ai) {
+ return (
+
+ );
+ }
+ if (platformAi && account) {
+ return (
+
+ );
+ }
+ if (platformAi) {
+ return (
+
+ Sign in with OTM for AI names →
+
+ );
+ }
+ return null;
+}
diff --git a/src/components/Finder.tsx b/src/components/Finder.tsx
index 344570d..efc26d4 100644
--- a/src/components/Finder.tsx
+++ b/src/components/Finder.tsx
@@ -1,32 +1,27 @@
"use client";
+// The finder: keywords/style/extensions in, generated names + live
+// availability out. Orchestration only — cards, the saved panel, the AI
+// switch, session and wishlist state each live in their own module.
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { generateNames, scoreName, type Idea, type Style } from "@/lib/generate";
import { DEFAULT_TLDS, TLDS, UNVERIFIABLE_TLDS } from "@/lib/tlds";
-import { DEFAULT_REGISTRAR, REGISTRARS, registerUrl } from "@/lib/registrars";
+import { DEFAULT_REGISTRAR, REGISTRARS } from "@/lib/registrars";
import { generateWithAi, loadAiSettings, type AiSettings } from "@/lib/ai";
import { generateWithPlatformAi } from "@/lib/platform-ai";
-import { otmSsoUrl } from "@/lib/otm";
-import { loadWishlist, saveWishlist, wishlistAsText, WISHLIST_MAX, type SavedName } from "@/lib/wishlist";
-
-type Availability = "available" | "taken" | "unverified-available" | "unknown";
-
-interface DomainCheck {
- domain: string;
- status: Availability;
- method: "rdap" | "dns" | "none";
- detail?: string;
-}
+import { BATCH, domainsFor, fetchAvailability, type DomainCheck } from "@/lib/availability-client";
+import { useOtmSession } from "@/hooks/useOtmSession";
+import { useWishlist } from "@/hooks/useWishlist";
+import { NameCard } from "./NameCard";
+import { WishlistPanel } from "./Wishlist";
+import { AiControls } from "./AiControls";
/** How many names to put on screen. Each one costs `selectedTlds.length`
* registry lookups, so this is the main dial on how hard we lean on the
* registries — keep it modest. */
const NAME_COUNT = 24;
-/** Domains per availability request. The route caps at 60; staying under it
- * means results paint in progressively instead of in one late lump. */
-const BATCH = 40;
-
const STYLES: Array<{ id: Style; label: string; note: string }> = [
{ id: "balanced", label: "Balanced", note: "a bit of everything" },
{ id: "real", label: "Real words", note: "northbakery, axlekit" },
@@ -47,125 +42,16 @@ export default function Finder() {
const [error, setError] = useState(null);
const [ai, setAi] = useState(null);
const [useAi, setUseAi] = useState(false);
- // Platform AI: who's signed in with OTM (null = nobody) and whether this
- // deployment has the gateway wired at all. Fetched once on mount.
- const [account, setAccount] = useState<{ email: string; name: string | null } | null>(null);
- const [platformAi, setPlatformAi] = useState(false);
const [usePlatformAi, setUsePlatformAi] = useState(true);
- const [signinNote, setSigninNote] = useState(null);
+ const { account, platformAi, signinNote, signOut } = useOtmSession();
// How many names the last run got from AI vs the built-in generator — shown
// as a one-line summary so it's obvious whether AI actually contributed
// (issue #3). null until a run completes.
const [aiCount, setAiCount] = useState(null);
// Hide names whose every checked extension is taken (issue #3). Persisted.
const [hideTaken, setHideTaken] = useState(false);
- const [saved, setSaved] = useState([]);
- const [savedLoaded, setSavedLoaded] = useState(false);
- const [rechecking, setRechecking] = useState(false);
- const [copied, setCopied] = useState(false);
-
- // Wishlist: read once on mount, written on every change after that. The
- // `savedLoaded` gate stops the first (empty) render from clobbering storage.
- useEffect(() => {
- setSaved(loadWishlist());
- setSavedLoaded(true);
- }, []);
- useEffect(() => {
- if (savedLoaded) saveWishlist(saved);
- }, [saved, savedLoaded]);
-
- // Keep saved snapshots current: whenever a lookup resolves a domain that's on
- // the shortlist, copy the fresh status in. Cheap, and it means a name saved
- // while still "checking" ends up with a real answer.
- useEffect(() => {
- setSaved((prev) => {
- let changed = false;
- const next = prev.map((e) => {
- let entry = e;
- for (const d of e.domains) {
- const c = checks[d];
- if (!c) continue;
- const old = entry.checks[d];
- if (old && old.status === c.status && old.detail === c.detail) continue;
- entry = { ...entry, checks: { ...entry.checks, [d]: { status: c.status, method: c.method, detail: c.detail } } };
- changed = true;
- }
- return entry;
- });
- return changed ? next : prev;
- });
- }, [checks]);
-
- const savedNames = useMemo(() => new Set(saved.map((e) => e.name)), [saved]);
-
- const toggleSaved = useCallback(
- (name: string, domains: string[]) => {
- setSaved((prev) => {
- if (prev.some((e) => e.name === name)) return prev.filter((e) => e.name !== name);
- if (prev.length >= WISHLIST_MAX) return prev;
- const snapshot: SavedName["checks"] = {};
- for (const d of domains) {
- const c = checks[d];
- if (c) snapshot[d] = { status: c.status, method: c.method, detail: c.detail };
- }
- return [{ name, domains, checks: snapshot, savedAt: new Date().toISOString() }, ...prev];
- });
- },
- [checks],
- );
-
- const removeSaved = (name: string) => setSaved((prev) => prev.filter((e) => e.name !== name));
-
- /** Re-query every saved domain. Independent of the main run's cancellation —
- * a shortlist re-check shouldn't die because the visitor hit "Show me more". */
- const recheckSaved = async () => {
- const domains = Array.from(new Set(saved.flatMap((e) => e.domains)));
- if (domains.length === 0) return;
- setRechecking(true);
- setError(null);
- try {
- for (let i = 0; i < domains.length; i += BATCH) {
- const slice = domains.slice(i, i + BATCH);
- const res = await fetch("/api/availability", {
- method: "POST",
- headers: { "content-type": "application/json" },
- body: JSON.stringify({ domains: slice }),
- });
- if (!res.ok) {
- const body = (await res.json().catch(() => ({}))) as { error?: string };
- setError(body.error ?? `Availability lookup failed (${res.status}).`);
- break;
- }
- const body = (await res.json()) as { results: DomainCheck[] };
- const fresh: Record = {};
- for (const r of body.results) fresh[r.domain] = r;
- setSaved((prev) =>
- prev.map((e) => {
- const nextChecks = { ...e.checks };
- for (const d of e.domains) {
- const c = fresh[d];
- if (c) nextChecks[d] = { status: c.status, method: c.method, detail: c.detail };
- }
- return { ...e, checks: nextChecks };
- }),
- );
- }
- } catch {
- setError("Could not reach the availability service.");
- } finally {
- setRechecking(false);
- }
- };
-
- const copySaved = async () => {
- try {
- await navigator.clipboard.writeText(wishlistAsText(saved));
- setCopied(true);
- window.setTimeout(() => setCopied(false), 1500);
- } catch {
- setError("Couldn't copy — your browser blocked clipboard access.");
- }
- };
+ const { saved, savedNames, toggleSaved, removeSaved, recheckSaved, copySaved, rechecking, copied } =
+ useWishlist(checks, setError);
// The saved key is read once on mount; Settings lives on its own page, so it
// can't change underneath us mid-session.
@@ -187,38 +73,6 @@ export default function Finder() {
}
};
- useEffect(() => {
- let alive = true;
- fetch("/api/auth/session", { cache: "no-store" })
- .then((r) => (r.ok ? r.json() : null))
- .then((b: { platformAi?: boolean; user?: { email: string; name: string | null } | null } | null) => {
- if (!alive || !b) return;
- setPlatformAi(Boolean(b.platformAi));
- setAccount(b.user ?? null);
- })
- .catch(() => {});
- // The SSO callback bounces failures back here as ?signin=.
- const reason = new URLSearchParams(window.location.search).get("signin");
- if (reason) {
- setSigninNote(
- reason === "link_already_used"
- ? "That sign-in link was already used — try again."
- : reason === "not_enabled"
- ? "Sign-in isn't enabled on this deployment."
- : "Sign-in didn't complete — try again.",
- );
- window.history.replaceState(null, "", window.location.pathname);
- }
- return () => {
- alive = false;
- };
- }, []);
-
- const signOut = async () => {
- await fetch("/api/auth/signout", { method: "POST" }).catch(() => {});
- setAccount(null);
- };
-
// Abandon in-flight lookups when a new search starts, so a slow batch from
// the previous query can't paint stale statuses over the new results.
const runId = useRef(0);
@@ -232,22 +86,6 @@ export default function Finder() {
[keywords],
);
- /** Every domain a set of ideas implies, in display order. */
- const domainsFor = useCallback(
- (list: Idea[], selected: string[]): string[] => {
- const out: string[] = [];
- for (const idea of list) {
- if (idea.hackTld && idea.hackStem) {
- out.push(`${idea.hackStem}.${idea.hackTld}`);
- continue;
- }
- for (const tld of selected) out.push(`${idea.name}.${tld}`);
- }
- return out;
- },
- [],
- );
-
const lookup = useCallback(async (domains: string[], myRun: number) => {
setPending(new Set(domains));
@@ -255,28 +93,16 @@ export default function Finder() {
if (runId.current !== myRun) return; // superseded by a newer search
const slice = domains.slice(i, i + BATCH);
try {
- const res = await fetch("/api/availability", {
- method: "POST",
- headers: { "content-type": "application/json" },
- body: JSON.stringify({ domains: slice }),
- });
+ const results = await fetchAvailability(slice);
if (runId.current !== myRun) return;
-
- if (!res.ok) {
- const body = (await res.json().catch(() => ({}))) as { error?: string };
- setError(body.error ?? `Availability lookup failed (${res.status}).`);
- setPending(new Set());
- return;
- }
- const body = (await res.json()) as { results: DomainCheck[] };
setChecks((prev) => {
const next = { ...prev };
- for (const r of body.results) next[r.domain] = r;
+ for (const r of results) next[r.domain] = r;
return next;
});
- } catch {
+ } catch (err) {
if (runId.current !== myRun) return;
- setError("Could not reach the availability service.");
+ setError(err instanceof Error ? err.message : "Could not reach the availability service.");
setPending(new Set());
return;
}
@@ -333,7 +159,7 @@ export default function Finder() {
setBusy(false);
await lookup(domainsFor(list, tlds), myRun);
},
- [account, ai, domainsFor, lookup, parsedKeywords, platformAi, style, tlds, useAi, usePlatformAi],
+ [account, ai, lookup, parsedKeywords, platformAi, style, tlds, useAi, usePlatformAi],
);
const onGenerate = () => {
@@ -350,13 +176,10 @@ export default function Finder() {
/** A name is "all taken" once every domain we checked for it resolved taken.
* Still-checking or unknown names stay visible — hiding is for certainty. */
- const isAllTaken = useCallback(
- (idea: Idea): boolean => {
- const ds = domainsFor([idea], tlds);
- return ds.length > 0 && ds.every((d) => checks[d]?.status === "taken");
- },
- [checks, domainsFor, tlds],
- );
+ const isAllTaken = (idea: Idea): boolean => {
+ const ds = domainsFor([idea], tlds);
+ return ds.length > 0 && ds.every((d) => checks[d]?.status === "taken");
+ };
const visibleIdeas = hideTaken ? ideas.filter((i) => !isAllTaken(i)) : ideas;
const hiddenCount = ideas.length - visibleIdeas.length;
@@ -438,39 +261,16 @@ export default function Finder() {
- {ai ? (
-
- ) : platformAi && account ? (
-
- ) : platformAi ? (
-
- Sign in with OTM for AI names →
-
- ) : null}
+
{tlds.length === 0 ? (
@@ -492,32 +292,15 @@ export default function Finder() {
) : null}
- {saved.length > 0 ? (
-
-
-
- Saved {saved.length}
-
-
-
-
-
-
-
- Kept in this browser only. Statuses are from the last time each name was checked
- — press Re-check before you commit.
-
-
- {saved.map((entry) => (
- removeSaved(entry.name)} />
- ))}
-
-
- ) : null}
+
{ideas.length === 0 ? (
@@ -562,193 +345,3 @@ export default function Finder() {
>
);
}
-
-function NameCard({
- idea,
- tlds,
- checks,
- pending,
- registrar,
- saved,
- onToggleSave,
- hideTaken,
-}: {
- idea: Idea;
- tlds: string[];
- checks: Record;
- pending: Set;
- registrar: string;
- saved: boolean;
- onToggleSave: (name: string, domains: string[]) => void;
- hideTaken: boolean;
-}) {
- const isHack = Boolean(idea.hackTld && idea.hackStem);
- const domains = isHack ? [`${idea.hackStem}.${idea.hackTld}`] : tlds.map((t) => `${idea.name}.${t}`);
- const saveKey = isHack ? domains[0] : idea.name;
-
- // Dim the whole card only when every extension we checked came back taken.
- const settled = domains.map((d) => checks[d]).filter(Boolean);
- const allTaken = settled.length === domains.length && settled.every((c) => c.status === "taken");
-
- return (
-
-
-
- {isHack ? (
- <>
- {idea.hackStem}
- .{idea.hackTld}
- >
- ) : (
- idea.name
- )}
-
-
-
-
- {domains.map((domain) => {
- const check = checks[domain];
- const isPending = pending.has(domain) && !check;
- const status: Availability | "checking" = isPending ? "checking" : (check?.status ?? "unknown");
- const free = status === "available" || status === "unverified-available";
- if (hideTaken && status === "taken") return null;
-
- return (
-
-
- {statusIcon(status)} {isHack ? domain : `.${domain.slice(idea.name.length + 1)}`}{" "}
- {statusLabel(status)}
-
- {free ? (
-
- Register →
-
- ) : null}
-
- );
- })}
-
-
- {idea.strategy === "ai" ? AI : null} {strategyLabel(idea.strategy)}
-
-
- );
-}
-
-function SavedCard({
- entry,
- registrar,
- onRemove,
-}: {
- entry: SavedName;
- registrar: string;
- onRemove: () => void;
-}) {
- const isHack = entry.domains.length === 1 && entry.domains[0] === entry.name;
- const settled = entry.domains.map((d) => entry.checks[d]).filter(Boolean);
- const allTaken = settled.length === entry.domains.length && settled.every((c) => c.status === "taken");
- return (
-
-
- {entry.domains.map((domain) => {
- const check = entry.checks[domain];
- const status: Availability = check?.status ?? "unknown";
- const free = status === "available" || status === "unverified-available";
- return (
-
-
- {statusIcon(status)} {isHack ? domain : `.${domain.slice(entry.name.length + 1)}`}{" "}
- {statusLabel(status)}
-
- {free ? (
-
- Register →
-
- ) : null}
-
- );
- })}
-
- );
-}
-
-function statusIcon(status: Availability | "checking"): string {
- switch (status) {
- case "available":
- return "●";
- case "unverified-available":
- return "◐";
- case "taken":
- return "○";
- case "checking":
- return "…";
- default:
- return "?";
- }
-}
-
-function statusLabel(status: Availability | "checking"): string {
- switch (status) {
- case "available":
- return "free";
- case "unverified-available":
- return "probably free";
- case "taken":
- return "taken";
- case "checking":
- return "checking";
- default:
- return "unclear";
- }
-}
-
-function strategyLabel(strategy: Idea["strategy"]): string {
- switch (strategy) {
- case "compound":
- return "compound";
- case "suffix-word":
- return "compound";
- case "action":
- return "call to action";
- case "blend":
- return "blend";
- case "coined":
- return "coined";
- case "clipped":
- return "clipped";
- case "root":
- return "classical root";
- case "hack":
- return "domain hack";
- case "ai":
- return "generated";
- default:
- return "";
- }
-}
diff --git a/src/components/NameCard.tsx b/src/components/NameCard.tsx
new file mode 100644
index 0000000..7d9e934
--- /dev/null
+++ b/src/components/NameCard.tsx
@@ -0,0 +1,149 @@
+"use client";
+
+// One name → its per-extension availability rows, register links, save star.
+// Pure display: everything it shows arrives in props.
+
+import type { Idea } from "@/lib/generate";
+import { registerUrl } from "@/lib/registrars";
+import type { Availability, DomainCheck } from "@/lib/availability-client";
+
+export function NameCard({
+ idea,
+ tlds,
+ checks,
+ pending,
+ registrar,
+ saved,
+ onToggleSave,
+ hideTaken,
+}: {
+ idea: Idea;
+ tlds: string[];
+ checks: Record;
+ pending: Set;
+ registrar: string;
+ saved: boolean;
+ onToggleSave: (name: string, domains: string[]) => void;
+ hideTaken: boolean;
+}) {
+ const isHack = Boolean(idea.hackTld && idea.hackStem);
+ const domains = isHack ? [`${idea.hackStem}.${idea.hackTld}`] : tlds.map((t) => `${idea.name}.${t}`);
+ const saveKey = isHack ? domains[0] : idea.name;
+
+ // Dim the whole card only when every extension we checked came back taken.
+ const settled = domains.map((d) => checks[d]).filter(Boolean);
+ const allTaken = settled.length === domains.length && settled.every((c) => c.status === "taken");
+
+ return (
+
+
+
+ {isHack ? (
+ <>
+ {idea.hackStem}
+ .{idea.hackTld}
+ >
+ ) : (
+ idea.name
+ )}
+
+
+
+
+ {domains.map((domain) => {
+ const check = checks[domain];
+ const isPending = pending.has(domain) && !check;
+ const status: Availability | "checking" = isPending ? "checking" : (check?.status ?? "unknown");
+ const free = status === "available" || status === "unverified-available";
+ if (hideTaken && status === "taken") return null;
+
+ return (
+
+
+ {statusIcon(status)} {isHack ? domain : `.${domain.slice(idea.name.length + 1)}`}{" "}
+ {statusLabel(status)}
+
+ {free ? (
+
+ Register →
+
+ ) : null}
+
+ );
+ })}
+
+
+ {idea.strategy === "ai" ? AI : null} {strategyLabel(idea.strategy)}
+
+
+ );
+}
+
+export function statusIcon(status: Availability | "checking"): string {
+ switch (status) {
+ case "available":
+ return "●";
+ case "unverified-available":
+ return "◐";
+ case "taken":
+ return "○";
+ case "checking":
+ return "…";
+ default:
+ return "?";
+ }
+}
+
+export function statusLabel(status: Availability | "checking"): string {
+ switch (status) {
+ case "available":
+ return "free";
+ case "unverified-available":
+ return "probably free";
+ case "taken":
+ return "taken";
+ case "checking":
+ return "checking";
+ default:
+ return "unclear";
+ }
+}
+
+export function strategyLabel(strategy: Idea["strategy"]): string {
+ switch (strategy) {
+ case "compound":
+ return "compound";
+ case "suffix-word":
+ return "compound";
+ case "action":
+ return "call to action";
+ case "blend":
+ return "blend";
+ case "coined":
+ return "coined";
+ case "clipped":
+ return "clipped";
+ case "root":
+ return "classical root";
+ case "hack":
+ return "domain hack";
+ case "ai":
+ return "generated";
+ default:
+ return "";
+ }
+}
diff --git a/src/components/Wishlist.tsx b/src/components/Wishlist.tsx
new file mode 100644
index 0000000..2dd467c
--- /dev/null
+++ b/src/components/Wishlist.tsx
@@ -0,0 +1,105 @@
+"use client";
+
+// The Saved panel: shortlist cards with their last-seen statuses, Re-check and
+// Copy list. State comes from hooks/useWishlist.
+
+import type { SavedName } from "@/lib/wishlist";
+import { registerUrl } from "@/lib/registrars";
+import type { Availability } from "@/lib/availability-client";
+import { statusIcon, statusLabel } from "./NameCard";
+
+export function WishlistPanel({
+ saved,
+ registrar,
+ rechecking,
+ copied,
+ onRecheck,
+ onCopy,
+ onRemove,
+}: {
+ saved: SavedName[];
+ registrar: string;
+ rechecking: boolean;
+ copied: boolean;
+ onRecheck: () => void;
+ onCopy: () => void;
+ onRemove: (name: string) => void;
+}) {
+ if (saved.length === 0) return null;
+ return (
+
+
+
+ Saved {saved.length}
+
+
+
+
+
+
+
+ Kept in this browser only. Statuses are from the last time each name was checked — press
+ Re-check before you commit.
+
+
+ {saved.map((entry) => (
+ onRemove(entry.name)} />
+ ))}
+
+
+ );
+}
+
+function SavedCard({
+ entry,
+ registrar,
+ onRemove,
+}: {
+ entry: SavedName;
+ registrar: string;
+ onRemove: () => void;
+}) {
+ const isHack = entry.domains.length === 1 && entry.domains[0] === entry.name;
+ const settled = entry.domains.map((d) => entry.checks[d]).filter(Boolean);
+ const allTaken = settled.length === entry.domains.length && settled.every((c) => c.status === "taken");
+ return (
+
+
+ {entry.domains.map((domain) => {
+ const check = entry.checks[domain];
+ const status: Availability = check?.status ?? "unknown";
+ const free = status === "available" || status === "unverified-available";
+ return (
+
+
+ {statusIcon(status)} {isHack ? domain : `.${domain.slice(entry.name.length + 1)}`}{" "}
+ {statusLabel(status)}
+
+ {free ? (
+
+ Register →
+
+ ) : null}
+
+ );
+ })}
+
+ );
+}
+
diff --git a/src/hooks/useOtmSession.ts b/src/hooks/useOtmSession.ts
new file mode 100644
index 0000000..2ac45d1
--- /dev/null
+++ b/src/hooks/useOtmSession.ts
@@ -0,0 +1,51 @@
+"use client";
+
+// Who's signed in with OTM (null = nobody), whether this deployment has the
+// platform AI gateway wired at all, and the note the SSO callback bounced back
+// with (?signin=) if the last sign-in attempt failed.
+
+import { useEffect, useState } from "react";
+
+export interface OtmAccount {
+ email: string;
+ name: string | null;
+}
+
+export function useOtmSession() {
+ const [account, setAccount] = useState(null);
+ const [platformAi, setPlatformAi] = useState(false);
+ const [signinNote, setSigninNote] = useState(null);
+
+ useEffect(() => {
+ let alive = true;
+ fetch("/api/auth/session", { cache: "no-store" })
+ .then((r) => (r.ok ? r.json() : null))
+ .then((b: { platformAi?: boolean; user?: OtmAccount | null } | null) => {
+ if (!alive || !b) return;
+ setPlatformAi(Boolean(b.platformAi));
+ setAccount(b.user ?? null);
+ })
+ .catch(() => {});
+ const reason = new URLSearchParams(window.location.search).get("signin");
+ if (reason) {
+ setSigninNote(
+ reason === "link_already_used"
+ ? "That sign-in link was already used — try again."
+ : reason === "not_enabled"
+ ? "Sign-in isn't enabled on this deployment."
+ : "Sign-in didn't complete — try again.",
+ );
+ window.history.replaceState(null, "", window.location.pathname);
+ }
+ return () => {
+ alive = false;
+ };
+ }, []);
+
+ const signOut = async () => {
+ await fetch("/api/auth/signout", { method: "POST" }).catch(() => {});
+ setAccount(null);
+ };
+
+ return { account, platformAi, signinNote, signOut };
+}
diff --git a/src/hooks/useWishlist.ts b/src/hooks/useWishlist.ts
new file mode 100644
index 0000000..81eb4cf
--- /dev/null
+++ b/src/hooks/useWishlist.ts
@@ -0,0 +1,107 @@
+"use client";
+
+// The shortlist's state: localStorage-backed list, snapshot sync from live
+// lookups, save/remove, re-check and copy. UI lives in components/Wishlist.tsx.
+
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { BATCH, fetchAvailability, type DomainCheck } from "@/lib/availability-client";
+import { loadWishlist, saveWishlist, wishlistAsText, WISHLIST_MAX, type SavedName } from "@/lib/wishlist";
+
+function snapshotOf(c: DomainCheck): SavedName["checks"][string] {
+ return { status: c.status, method: c.method, detail: c.detail };
+}
+
+export function useWishlist(checks: Record, onError: (msg: string | null) => void) {
+ const [saved, setSaved] = useState([]);
+ const [loaded, setLoaded] = useState(false);
+ const [rechecking, setRechecking] = useState(false);
+ const [copied, setCopied] = useState(false);
+
+ // Read once on mount, write on every change after that. The `loaded` gate
+ // stops the first (empty) render from clobbering storage.
+ useEffect(() => {
+ setSaved(loadWishlist());
+ setLoaded(true);
+ }, []);
+ useEffect(() => {
+ if (loaded) saveWishlist(saved);
+ }, [saved, loaded]);
+
+ // Keep snapshots current: whenever a lookup resolves a domain that's on the
+ // shortlist, copy the fresh status in — a name saved while still "checking"
+ // ends up with a real answer.
+ useEffect(() => {
+ setSaved((prev) => {
+ let changed = false;
+ const next = prev.map((e) => {
+ let entry = e;
+ for (const d of e.domains) {
+ const c = checks[d];
+ if (!c) continue;
+ const old = entry.checks[d];
+ if (old && old.status === c.status && old.detail === c.detail) continue;
+ entry = { ...entry, checks: { ...entry.checks, [d]: snapshotOf(c) } };
+ changed = true;
+ }
+ return entry;
+ });
+ return changed ? next : prev;
+ });
+ }, [checks]);
+
+ const savedNames = useMemo(() => new Set(saved.map((e) => e.name)), [saved]);
+
+ const toggleSaved = useCallback(
+ (name: string, domains: string[]) => {
+ setSaved((prev) => {
+ if (prev.some((e) => e.name === name)) return prev.filter((e) => e.name !== name);
+ if (prev.length >= WISHLIST_MAX) return prev;
+ const snapshot: SavedName["checks"] = {};
+ for (const d of domains) if (checks[d]) snapshot[d] = snapshotOf(checks[d]);
+ return [{ name, domains, checks: snapshot, savedAt: new Date().toISOString() }, ...prev];
+ });
+ },
+ [checks],
+ );
+
+ const removeSaved = useCallback((name: string) => setSaved((prev) => prev.filter((e) => e.name !== name)), []);
+
+ /** Re-query every saved domain. Independent of the finder's run cancellation —
+ * a shortlist re-check shouldn't die because the visitor hit "Show me more". */
+ const recheckSaved = async () => {
+ const domains = Array.from(new Set(saved.flatMap((e) => e.domains)));
+ if (domains.length === 0) return;
+ setRechecking(true);
+ onError(null);
+ try {
+ for (let i = 0; i < domains.length; i += BATCH) {
+ const results = await fetchAvailability(domains.slice(i, i + BATCH));
+ const fresh: Record = {};
+ for (const r of results) fresh[r.domain] = r;
+ setSaved((prev) =>
+ prev.map((e) => {
+ const nextChecks = { ...e.checks };
+ for (const d of e.domains) if (fresh[d]) nextChecks[d] = snapshotOf(fresh[d]);
+ return { ...e, checks: nextChecks };
+ }),
+ );
+ }
+ } catch (err) {
+ onError(err instanceof Error ? err.message : "Could not reach the availability service.");
+ } finally {
+ setRechecking(false);
+ }
+ };
+
+ const copySaved = async () => {
+ try {
+ await navigator.clipboard.writeText(wishlistAsText(saved));
+ setCopied(true);
+ window.setTimeout(() => setCopied(false), 1500);
+ } catch {
+ onError("Couldn't copy — your browser blocked clipboard access.");
+ }
+ };
+
+ return { saved, savedNames, toggleSaved, removeSaved, recheckSaved, copySaved, rechecking, copied };
+}
diff --git a/src/lib/availability-client.ts b/src/lib/availability-client.ts
new file mode 100644
index 0000000..3c8fb5b
--- /dev/null
+++ b/src/lib/availability-client.ts
@@ -0,0 +1,51 @@
+// Browser-side helpers for the availability route: the result shape, the
+// batch size, and the two small pure functions both the finder and the
+// wishlist need. Server logic lives in app/api/availability + lib/rdap.ts.
+
+import type { Idea } from "@/lib/generate";
+
+export type Availability = "available" | "taken" | "unverified-available" | "unknown";
+
+export interface DomainCheck {
+ domain: string;
+ status: Availability;
+ method: "rdap" | "dns" | "none";
+ detail?: string;
+}
+
+/** Domains per availability request. The route caps at 60; staying under it
+ * means results paint in progressively instead of in one late lump. */
+export const BATCH = 40;
+
+/** Every domain a set of ideas implies, in display order. */
+export function domainsFor(list: Idea[], selected: string[]): string[] {
+ const out: string[] = [];
+ for (const idea of list) {
+ if (idea.hackTld && idea.hackStem) {
+ out.push(`${idea.hackStem}.${idea.hackTld}`);
+ continue;
+ }
+ for (const tld of selected) out.push(`${idea.name}.${tld}`);
+ }
+ return out;
+}
+
+/** One batch against /api/availability. Throws with a message fit to show. */
+export async function fetchAvailability(domains: string[]): Promise {
+ let res: Response;
+ try {
+ res = await fetch("/api/availability", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ domains }),
+ });
+ } catch {
+ throw new Error("Could not reach the availability service.");
+ }
+ if (!res.ok) {
+ const body = (await res.json().catch(() => ({}))) as { error?: string };
+ throw new Error(body.error ?? `Availability lookup failed (${res.status}).`);
+ }
+ const body = (await res.json()) as { results: DomainCheck[] };
+ return body.results;
+}
diff --git a/src/lib/version.ts b/src/lib/version.ts
index 8cbd9e8..d44cb3e 100644
--- a/src/lib/version.ts
+++ b/src/lib/version.ts
@@ -2,7 +2,7 @@
// /changelog. Bump VERSION and prepend an entry when shipping a user-facing
// change. Same convention as rpo.
-export const VERSION = "0.4.0";
+export const VERSION = "0.4.1";
export interface ChangelogEntry {
version: string;
@@ -11,6 +11,11 @@ export interface ChangelogEntry {
}
export const CHANGELOG: ChangelogEntry[] = [
+ {
+ version: "0.4.1",
+ date: "2026-08-16",
+ changes: ["Internal: split the finder into focused components/hooks. No visible change."],
+ },
{
version: "0.4.0",
date: "2026-08-16",