feat: saved-names wishlist (#1); style the shared feedback chat-pop with Tailwind (#2)

- ☆ 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:
2026-08-16 18:14:40 -05:00
parent 9489ce5708
commit c7d6812466
11 changed files with 1591 additions and 33 deletions

View File

@@ -1,3 +1,6 @@
@tailwind components;
@tailwind utilities;
:root {
--bg: #0b0d10;
--panel: #14181d;
@@ -88,10 +91,13 @@ h2 {
margin-bottom: 7px;
}
input[type="text"],
input[type="password"],
textarea,
select {
/* Element rules are scoped to .wrap so they can't bleed into the shared
OTM widgets (SuggestionLightbulb) mounted outside it, which style
themselves with Tailwind utilities. */
.wrap input[type="text"],
.wrap input[type="password"],
.wrap textarea,
.wrap select {
width: 100%;
background: var(--panel);
color: var(--text);
@@ -103,9 +109,9 @@ select {
line-height: 1.4;
}
input:focus,
textarea:focus,
select:focus {
.wrap input:focus,
.wrap textarea:focus,
.wrap select:focus {
outline: none;
border-color: var(--accent-dim);
}
@@ -117,7 +123,7 @@ select:focus {
line-height: 1.5;
}
button {
.wrap button {
font-family: inherit;
font-size: 15px;
cursor: pointer;
@@ -128,23 +134,23 @@ button {
padding: 11px 18px;
}
button:hover:not(:disabled) {
.wrap button:hover:not(:disabled) {
border-color: var(--accent-dim);
}
button:disabled {
.wrap button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
button.primary {
.wrap button.primary {
background: var(--accent-dim);
border-color: var(--accent-dim);
color: #fff;
font-weight: 600;
}
button.primary:hover:not(:disabled) {
.wrap button.primary:hover:not(:disabled) {
background: var(--accent);
border-color: var(--accent);
}
@@ -254,6 +260,89 @@ button.primary:hover:not(:disabled) {
opacity: 0.55;
}
.card-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
}
/* Save star: bare glyph, no chrome. Filled + accent when saved. */
.wrap button.save {
background: none;
border: none;
padding: 0 2px;
line-height: 1;
font-size: 20px;
color: var(--faint);
border-radius: 6px;
flex: none;
}
.wrap button.save:hover:not(:disabled) {
color: var(--warn);
border-color: transparent;
}
.wrap button.save[aria-pressed="true"] {
color: var(--warn);
}
.card.is-saved {
border-color: #4a3f1c;
}
/* ---------- wishlist ---------- */
.wishlist {
margin-top: 22px;
padding: 14px 16px 16px;
border: 1px solid #4a3f1c;
border-radius: 14px;
background: linear-gradient(180deg, #17150f, var(--bg));
}
.wishlist-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
flex-wrap: wrap;
}
.wishlist h2 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.wishlist h2 .count {
display: inline-block;
min-width: 22px;
padding: 1px 7px;
margin-left: 6px;
border-radius: 999px;
background: var(--warn-bg);
color: var(--warn);
font-size: 12px;
text-align: center;
}
.wishlist-actions {
display: flex;
gap: 8px;
}
.wrap button.mini {
font-size: 13px;
padding: 6px 12px;
border-radius: 8px;
}
.wishlist .results {
margin-top: 10px;
}
.strategy {
font-size: 11px;
text-transform: uppercase;

View File

@@ -14,14 +14,13 @@ export const metadata: Metadata = {
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<html lang="en" className="dark">
<body>
<div className="wrap">
<div className="topbar">
<Link href="/" className="brand" style={{ textDecoration: "none" }}>
Powered by OTM
</Link>
<SuggestionLightbulb />
</div>
{children}
<footer className="footer">
@@ -41,6 +40,9 @@ export default function RootLayout({ children }: { children: ReactNode }) {
</span>
</footer>
</div>
{/* Shared OTM feedback chat-pop (bottom-right). Mounted outside .wrap
so the app's scoped element CSS can't restyle it. */}
<SuggestionLightbulb />
</body>
</html>
);

View File

@@ -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
&mdash; 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":

View File

@@ -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.1.0";
export const VERSION = "0.2.0";
export interface ChangelogEntry {
version: string;
@@ -11,6 +11,14 @@ export interface ChangelogEntry {
}
export const CHANGELOG: ChangelogEntry[] = [
{
version: "0.2.0",
date: "2026-08-16",
changes: [
"Saved names: press ☆ on any card to keep it in a shortlist while you keep looking. The list lives in your browser, remembers the availability it last saw, and has Re-check and Copy list buttons.",
"The feedback widget (bottom-right) is now properly styled — it was rendering as a bare unstyled button.",
],
},
{
version: "0.1.0",
date: "2026-08-10",

68
src/lib/wishlist.ts Normal file
View File

@@ -0,0 +1,68 @@
// The visitor's shortlist. Lives entirely in localStorage — this app has no
// accounts and no DB, and a shortlist you're still mulling over is exactly the
// kind of thing that shouldn't need one. Each entry snapshots the availability
// we last saw so the list still means something after the results it came
// from have been regenerated away.
export type SavedStatus = "available" | "taken" | "unverified-available" | "unknown";
export interface SavedCheck {
status: SavedStatus;
method: "rdap" | "dns" | "none";
detail?: string;
}
export interface SavedName {
/** The bare name (for a domain hack, the full stem+tld, e.g. "stud.io"). */
name: string;
/** Domains that were on the card when it was saved, in display order. */
domains: string[];
/** Last-known status per domain. Missing = never resolved. */
checks: Record<string, SavedCheck>;
savedAt: string; // ISO
}
const KEY = "names.wishlist";
export const WISHLIST_MAX = 60;
export function loadWishlist(): SavedName[] {
if (typeof window === "undefined") return [];
try {
const raw = window.localStorage.getItem(KEY);
if (!raw) return [];
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) return [];
return parsed.filter(
(e): e is SavedName =>
!!e && typeof e === "object" && typeof (e as SavedName).name === "string" && Array.isArray((e as SavedName).domains),
);
} catch {
return [];
}
}
export function saveWishlist(list: SavedName[]): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(KEY, JSON.stringify(list.slice(0, WISHLIST_MAX)));
} catch {
// Quota / private mode — the in-memory list still works for the session.
}
}
/** Plain-text export: one line per domain with its last-known status. */
export function wishlistAsText(list: SavedName[]): string {
const label: Record<SavedStatus, string> = {
available: "available",
"unverified-available": "probably free",
taken: "taken",
unknown: "unchecked",
};
return list
.flatMap((e) =>
e.domains.length
? e.domains.map((d) => `${d}${label[e.checks[d]?.status ?? "unknown"]}`)
: [e.name],
)
.join("\n");
}