Initial release: business-name finder with live domain availability

Free, no-account tool for naming a business and finding a domain you can
actually register.

Availability uses no paid API and no key. IANA's RDAP bootstrap maps ~1,200
TLDs to their authoritative registry servers (404 = free, 200 = taken); the
handful with no RDAP at all (.io, .co, .me, .sh, .gg, .us) fall back to a DNS
NS lookup and are reported as "probably free" rather than confirmed, because a
registered-but-undelegated domain is indistinguishable that way.

Name generation is pure, deterministic, and client-side — eight strategies over
a curated word bank, ranked by a scorer that does the real quality work. Three
of its guards (prefix-only hint matching, -er-only syncopation, truncation
rejection) exist because of specific bad output and should not be relaxed.

Optional AI suggestions use the visitor's OWN Anthropic/Gemini key from
localStorage, called browser-direct, so this stays free to run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JChWdFJPCRxMBxErb8kUVK
This commit is contained in:
2026-08-10 13:40:26 -05:00
commit 78460d35c9
29 changed files with 3644 additions and 0 deletions

425
src/components/Finder.tsx Normal file
View File

@@ -0,0 +1,425 @@
"use client";
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 { generateWithAi, loadAiSettings, type AiSettings } from "@/lib/ai";
type Availability = "available" | "taken" | "unverified-available" | "unknown";
interface DomainCheck {
domain: string;
status: Availability;
method: "rdap" | "dns" | "none";
detail?: string;
}
/** 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" },
{ id: "coined", label: "Coined", note: "novault, tensora" },
{ id: "short", label: "Short", note: "brevity first" },
];
export default function Finder() {
const [keywords, setKeywords] = useState("");
const [style, setStyle] = useState<Style>("balanced");
const [tlds, setTlds] = useState<string[]>(DEFAULT_TLDS);
const [registrar, setRegistrar] = useState(DEFAULT_REGISTRAR);
const [ideas, setIdeas] = useState<Idea[]>([]);
const [checks, setChecks] = useState<Record<string, DomainCheck>>({});
const [pending, setPending] = useState<Set<string>>(new Set());
const [seed, setSeed] = useState(1);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [ai, setAi] = useState<AiSettings | null>(null);
const [useAi, setUseAi] = useState(false);
// The saved key is read once on mount; Settings lives on its own page, so it
// can't change underneath us mid-session.
useEffect(() => {
setAi(loadAiSettings());
}, []);
// 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);
const parsedKeywords = useMemo(
() =>
keywords
.split(/[,\s]+/)
.map((k) => k.trim().toLowerCase())
.filter(Boolean),
[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));
for (let i = 0; i < domains.length; i += BATCH) {
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 }),
});
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;
return next;
});
} catch {
if (runId.current !== myRun) return;
setError("Could not reach the availability service.");
setPending(new Set());
return;
}
setPending((prev) => {
const next = new Set(prev);
for (const d of slice) next.delete(d);
return next;
});
}
}, []);
const run = useCallback(
async (nextSeed: number) => {
const myRun = ++runId.current;
setBusy(true);
setError(null);
setChecks({});
let list = generateNames({
keywords: parsedKeywords,
style,
count: NAME_COUNT,
seed: nextSeed,
});
// The visitor's own AI key, if they saved one and asked us to use it.
// A failure here is never fatal — the built-in list is already computed.
if (useAi && ai) {
try {
const aiNames = await generateWithAi(ai, parsedKeywords, style, 16);
if (runId.current !== myRun) return;
const seen = new Set(list.map((i) => i.name));
const extra: Idea[] = aiNames
.filter((n) => !seen.has(n))
.map((name) => ({ name, strategy: "ai" as const, score: scoreName(name, parsedKeywords) }));
list = [...extra, ...list].slice(0, NAME_COUNT + extra.length);
} catch (err) {
setError(
`AI names unavailable (${err instanceof Error ? err.message : "unknown error"}). Showing built-in suggestions.`,
);
}
}
if (runId.current !== myRun) return;
setIdeas(list);
setBusy(false);
await lookup(domainsFor(list, tlds), myRun);
},
[ai, domainsFor, lookup, parsedKeywords, style, tlds, useAi],
);
const onGenerate = () => {
const next = seed + 1;
setSeed(next);
void run(next);
};
const toggleTld = (tld: string) => {
setTlds((prev) => (prev.includes(tld) ? prev.filter((t) => t !== tld) : [...prev, tld]));
};
const showsUnverifiable = tlds.some((t) => UNVERIFIABLE_TLDS.includes(t));
return (
<>
<div className="field">
<label htmlFor="keywords">What&rsquo;s the business?</label>
<input
id="keywords"
type="text"
value={keywords}
placeholder="auto repair, brake specialist — or just: bakery"
onChange={(e) => setKeywords(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") onGenerate();
}}
/>
<p className="hint">
A few words is plenty. Leave it empty and you&rsquo;ll get an abstract, category-free
shortlist instead.
</p>
</div>
<div className="field">
<label>Style</label>
<div className="chips">
{STYLES.map((s) => (
<button
key={s.id}
type="button"
className="chip"
aria-pressed={style === s.id}
onClick={() => setStyle(s.id)}
>
{s.label}
<span className="chip-note">{s.note}</span>
</button>
))}
</div>
</div>
<div className="field">
<label>Extensions to check</label>
<div className="chips">
{TLDS.map((t) => (
<button
key={t.tld}
type="button"
className="chip"
aria-pressed={tlds.includes(t.tld)}
title={t.note}
onClick={() => toggleTld(t.tld)}
>
.{t.tld}
</button>
))}
</div>
</div>
<div className="controls">
<button className="primary" onClick={onGenerate} disabled={busy || tlds.length === 0}>
{busy ? "Thinking…" : ideas.length ? "Show me more" : "Find names"}
</button>
<label
style={{ display: "inline-flex", alignItems: "center", gap: 8, fontSize: 14, color: "var(--muted)" }}
>
Register at
<select
value={registrar}
onChange={(e) => setRegistrar(e.target.value)}
style={{ width: "auto", padding: "8px 10px", fontSize: 14 }}
>
{REGISTRARS.map((r) => (
<option key={r.id} value={r.id} title={r.note}>
{r.name}
</option>
))}
</select>
</label>
{ai ? (
<label
style={{ display: "inline-flex", alignItems: "center", gap: 8, fontSize: 14, color: "var(--muted)" }}
>
<input
type="checkbox"
checked={useAi}
onChange={(e) => setUseAi(e.target.checked)}
style={{ width: "auto" }}
/>
Use my {ai.provider === "anthropic" ? "Claude" : "Gemini"} key
</label>
) : null}
</div>
{tlds.length === 0 ? (
<p className="notice info">Pick at least one extension to check.</p>
) : null}
{error ? <p className="notice warn">{error}</p> : null}
{showsUnverifiable && ideas.length > 0 ? (
<p className="notice info">
{UNVERIFIABLE_TLDS.filter((t) => tlds.includes(t))
.map((t) => `.${t}`)
.join(", ")}{" "}
{UNVERIFIABLE_TLDS.filter((t) => tlds.includes(t)).length === 1 ? "publishes" : "publish"} no
RDAP service, so those are checked with a DNS lookup instead and shown in amber as{" "}
<em>probably free</em>. A registered-but-unused domain looks identical &mdash; confirm at
the registrar before you count on one.
</p>
) : null}
{ideas.length === 0 ? (
<p className="empty">
{busy ? "Working…" : "Describe the business above and press Find names."}
</p>
) : (
<div className="results">
{ideas.map((idea) => (
<NameCard
key={idea.name}
idea={idea}
tlds={tlds}
checks={checks}
pending={pending}
registrar={registrar}
/>
))}
</div>
)}
</>
);
}
function NameCard({
idea,
tlds,
checks,
pending,
registrar,
}: {
idea: Idea;
tlds: string[];
checks: Record<string, DomainCheck>;
pending: Set<string>;
registrar: string;
}) {
const isHack = Boolean(idea.hackTld && idea.hackStem);
const domains = isHack ? [`${idea.hackStem}.${idea.hackTld}`] : tlds.map((t) => `${idea.name}.${t}`);
// 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 (
<div className={`card${allTaken ? " is-taken" : ""}`}>
<div className="card-name">
{isHack ? (
<>
{idea.hackStem}
<span className="tld">.{idea.hackTld}</span>
</>
) : (
idea.name
)}
</div>
{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";
return (
<div className="card-meta" key={domain}>
<span className={`status ${status}`} title={check?.detail}>
{statusIcon(status)} {isHack ? domain : `.${domain.slice(idea.name.length + 1)}`}{" "}
<span style={{ fontWeight: 400 }}>{statusLabel(status)}</span>
</span>
{free ? (
<a
className="register-link"
href={registerUrl(domain, registrar)}
target="_blank"
rel="noopener noreferrer"
>
Register
</a>
) : null}
</div>
);
})}
<span className="strategy">{strategyLabel(idea.strategy)}</span>
</div>
);
}
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 "your AI key";
default:
return "";
}
}