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:
425
src/components/Finder.tsx
Normal file
425
src/components/Finder.tsx
Normal 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’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’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 — 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 "";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user