- ☆ on every card saves it to a browser-only shortlist (localStorage names.wishlist, cap 60) shown as a Saved panel with snapshotted statuses, Re-check and Copy list. - SuggestionLightbulb from @otm/account-panel is Tailwind-styled and names had no Tailwind, so it rendered as a bare button. Added Tailwind (preflight off, dark class pinned), scoped element CSS to .wrap and mounted the widget outside it. - v0.2.0 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XStQKxPfEjrTvWo83KFCxG
This commit is contained in:
@@ -5,6 +5,7 @@ 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 { loadWishlist, saveWishlist, wishlistAsText, WISHLIST_MAX, type SavedName } from "@/lib/wishlist";
|
||||
|
||||
type Availability = "available" | "taken" | "unverified-available" | "unknown";
|
||||
|
||||
@@ -44,6 +45,113 @@ export default function Finder() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [ai, setAi] = useState<AiSettings | null>(null);
|
||||
const [useAi, setUseAi] = 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.
|
||||
@@ -283,6 +391,33 @@ export default function Finder() {
|
||||
</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."}
|
||||
@@ -297,6 +432,8 @@ export default function Finder() {
|
||||
checks={checks}
|
||||
pending={pending}
|
||||
registrar={registrar}
|
||||
saved={savedNames.has(idea.hackTld && idea.hackStem ? `${idea.hackStem}.${idea.hackTld}` : idea.name)}
|
||||
onToggleSave={toggleSaved}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -311,31 +448,48 @@ function NameCard({
|
||||
checks,
|
||||
pending,
|
||||
registrar,
|
||||
saved,
|
||||
onToggleSave,
|
||||
}: {
|
||||
idea: Idea;
|
||||
tlds: string[];
|
||||
checks: Record<string, DomainCheck>;
|
||||
pending: Set<string>;
|
||||
registrar: string;
|
||||
saved: boolean;
|
||||
onToggleSave: (name: string, domains: string[]) => void;
|
||||
}) {
|
||||
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" : ""}`}>
|
||||
<div className="card-name">
|
||||
{isHack ? (
|
||||
<>
|
||||
{idea.hackStem}
|
||||
<span className="tld">.{idea.hackTld}</span>
|
||||
</>
|
||||
) : (
|
||||
idea.name
|
||||
)}
|
||||
<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) => {
|
||||
@@ -369,6 +523,55 @@ function NameCard({
|
||||
);
|
||||
}
|
||||
|
||||
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":
|
||||
|
||||
Reference in New Issue
Block a user