Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XStQKxPfEjrTvWo83KFCxG
755 lines
26 KiB
TypeScript
755 lines
26 KiB
TypeScript
"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";
|
|
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;
|
|
}
|
|
|
|
/** 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);
|
|
// 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<string | null>(null);
|
|
// 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<number | null>(null);
|
|
// Hide names whose every checked extension is taken (issue #3). Persisted.
|
|
const [hideTaken, setHideTaken] = useState(false);
|
|
const [saved, setSaved] = useState<SavedName[]>([]);
|
|
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<string, DomainCheck> = {};
|
|
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.");
|
|
}
|
|
};
|
|
|
|
// 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());
|
|
try {
|
|
setHideTaken(window.localStorage.getItem("names.hideTaken") === "1");
|
|
} catch {
|
|
/* private mode */
|
|
}
|
|
}, []);
|
|
|
|
const toggleHideTaken = (v: boolean) => {
|
|
setHideTaken(v);
|
|
try {
|
|
window.localStorage.setItem("names.hideTaken", v ? "1" : "0");
|
|
} catch {
|
|
/* private mode */
|
|
}
|
|
};
|
|
|
|
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=<reason>.
|
|
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);
|
|
|
|
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,
|
|
});
|
|
|
|
// AI names: the visitor's own key if they saved one and asked us to use
|
|
// it, else the platform gateway if they're signed in with OTM. A failure
|
|
// here is never fatal — the built-in list is already computed.
|
|
const wantsOwnKey = useAi && ai;
|
|
const wantsPlatform = !ai && platformAi && account && usePlatformAi;
|
|
let gotAi = 0;
|
|
if (wantsOwnKey || wantsPlatform) {
|
|
try {
|
|
const aiNames = wantsOwnKey
|
|
? await generateWithAi(ai, parsedKeywords, style, 16)
|
|
: await generateWithPlatformAi(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);
|
|
gotAi = 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);
|
|
setAiCount(wantsOwnKey || wantsPlatform ? gotAi : null);
|
|
setBusy(false);
|
|
await lookup(domainsFor(list, tlds), myRun);
|
|
},
|
|
[account, ai, domainsFor, lookup, parsedKeywords, platformAi, style, tlds, useAi, usePlatformAi],
|
|
);
|
|
|
|
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));
|
|
|
|
/** 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 visibleIdeas = hideTaken ? ideas.filter((i) => !isAllTaken(i)) : ideas;
|
|
const hiddenCount = ideas.length - visibleIdeas.length;
|
|
|
|
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>
|
|
) : platformAi && account ? (
|
|
<label
|
|
style={{ display: "inline-flex", alignItems: "center", gap: 8, fontSize: 14, color: "var(--muted)" }}
|
|
title={`Signed in as ${account.email}`}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={usePlatformAi}
|
|
onChange={(e) => setUsePlatformAi(e.target.checked)}
|
|
style={{ width: "auto" }}
|
|
/>
|
|
AI names
|
|
<button type="button" className="linkish" onClick={signOut} title={`Sign out ${account.email}`}>
|
|
sign out
|
|
</button>
|
|
</label>
|
|
) : platformAi ? (
|
|
<a className="signin" href={otmSsoUrl("/")} title="Free OTM account · adds AI-generated names">
|
|
Sign in with OTM for AI names →
|
|
</a>
|
|
) : null}
|
|
</div>
|
|
|
|
{tlds.length === 0 ? (
|
|
<p className="notice info">Pick at least one extension to check.</p>
|
|
) : null}
|
|
|
|
{signinNote ? <p className="notice warn">{signinNote}</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}
|
|
|
|
{saved.length > 0 ? (
|
|
<section className="wishlist" aria-label="Saved names">
|
|
<div className="wishlist-head">
|
|
<h2>
|
|
Saved <span className="count">{saved.length}</span>
|
|
</h2>
|
|
<div className="wishlist-actions">
|
|
<button type="button" className="mini" onClick={recheckSaved} disabled={rechecking}>
|
|
{rechecking ? "Checking…" : "Re-check"}
|
|
</button>
|
|
<button type="button" className="mini" onClick={copySaved}>
|
|
{copied ? "Copied" : "Copy list"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<p className="hint">
|
|
Kept in this browser only. Statuses are from the last time each name was checked
|
|
— press Re-check before you commit.
|
|
</p>
|
|
<div className="results">
|
|
{saved.map((entry) => (
|
|
<SavedCard key={entry.name} entry={entry} registrar={registrar} onRemove={() => removeSaved(entry.name)} />
|
|
))}
|
|
</div>
|
|
</section>
|
|
) : null}
|
|
|
|
{ideas.length === 0 ? (
|
|
<p className="empty">
|
|
{busy ? "Working…" : "Describe the business above and press Find names."}
|
|
</p>
|
|
) : (
|
|
<>
|
|
<div className="results-bar">
|
|
<span className="results-summary">
|
|
{aiCount != null ? (
|
|
<>
|
|
<span className="ai-badge">AI</span> {aiCount} {aiCount === 1 ? "name" : "names"} ·{" "}
|
|
{ideas.length - aiCount} built-in
|
|
</>
|
|
) : (
|
|
<>{ideas.length} names</>
|
|
)}
|
|
{hideTaken && hiddenCount > 0 ? <> · {hiddenCount} taken hidden</> : null}
|
|
</span>
|
|
<label className="results-toggle">
|
|
<input type="checkbox" checked={hideTaken} onChange={(e) => toggleHideTaken(e.target.checked)} />
|
|
Hide taken
|
|
</label>
|
|
</div>
|
|
<div className="results">
|
|
{visibleIdeas.map((idea) => (
|
|
<NameCard
|
|
key={idea.name}
|
|
idea={idea}
|
|
tlds={tlds}
|
|
checks={checks}
|
|
pending={pending}
|
|
registrar={registrar}
|
|
saved={savedNames.has(idea.hackTld && idea.hackStem ? `${idea.hackStem}.${idea.hackTld}` : idea.name)}
|
|
onToggleSave={toggleSaved}
|
|
hideTaken={hideTaken}
|
|
/>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function NameCard({
|
|
idea,
|
|
tlds,
|
|
checks,
|
|
pending,
|
|
registrar,
|
|
saved,
|
|
onToggleSave,
|
|
hideTaken,
|
|
}: {
|
|
idea: Idea;
|
|
tlds: string[];
|
|
checks: Record<string, DomainCheck>;
|
|
pending: Set<string>;
|
|
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 (
|
|
<div className={`card${allTaken ? " is-taken" : ""}${saved ? " is-saved" : ""}`}>
|
|
<div className="card-head">
|
|
<div className="card-name">
|
|
{isHack ? (
|
|
<>
|
|
{idea.hackStem}
|
|
<span className="tld">.{idea.hackTld}</span>
|
|
</>
|
|
) : (
|
|
idea.name
|
|
)}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className="save"
|
|
aria-pressed={saved}
|
|
title={saved ? "Remove from saved" : "Save for later"}
|
|
aria-label={saved ? `Remove ${saveKey} from saved` : `Save ${saveKey}`}
|
|
onClick={() => onToggleSave(saveKey, domains)}
|
|
>
|
|
{saved ? "★" : "☆"}
|
|
</button>
|
|
</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";
|
|
if (hideTaken && status === "taken") return null;
|
|
|
|
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">
|
|
{idea.strategy === "ai" ? <span className="ai-badge">AI</span> : null} {strategyLabel(idea.strategy)}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div className={`card is-saved${allTaken ? " is-taken" : ""}`}>
|
|
<div className="card-head">
|
|
<div className="card-name">{entry.name}</div>
|
|
<button
|
|
type="button"
|
|
className="save"
|
|
aria-pressed
|
|
title="Remove from saved"
|
|
aria-label={`Remove ${entry.name} from saved`}
|
|
onClick={onRemove}
|
|
>
|
|
★
|
|
</button>
|
|
</div>
|
|
{entry.domains.map((domain) => {
|
|
const check = entry.checks[domain];
|
|
const status: Availability = 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(entry.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>
|
|
);
|
|
})}
|
|
</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 "generated";
|
|
default:
|
|
return "";
|
|
}
|
|
}
|