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.name}
- -
- {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.name}
+ +
+ {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",