← back to Trademarks Copyright
src/components/BrandHunter.tsx
404 lines
"use client";
import { useEffect, useMemo, useState } from "react";
import {
useReactTable, getCoreRowModel, getSortedRowModel, flexRender,
createColumnHelper, type SortingState,
} from "@tanstack/react-table";
type Brand = {
id: number;
domain: string;
brand_name: string | null;
category: string;
established_year: number | null;
years_in_business: number | null;
whois_created_date: string | null;
meta_description: string | null;
notes: string | null;
uspto_checked_at: string | null;
uspto_live_marks: number | null;
has_federal_registration: boolean | null;
uspto_search_url: string | null;
suggested_by: string;
status: string;
error_message: string | null;
last_checked_at: string;
http_status: number | null;
domain_expires_on: string | null;
days_to_expiry: number | null;
domain_lifecycle: string | null;
last_activity_year: number | null;
last_activity_signal: string | null;
years_since_activity: number | null;
abandonment_status: string | null;
opportunity_label: string | null;
};
const col = createColumnHelper<Brand>();
const OPPORTUNITY_META: Record<string, { label: string; color: string; hint: string }> = {
domain_snipe: {
label: "Domain snipe",
color: "bg-blue-600 text-white",
hint: "Domain expired or expiring. Register the domain; brand is dormant — buy cleanly.",
},
abandonment_watch: {
label: "Abandonment watch",
color: "bg-indigo-600 text-white",
hint: "Live domain but no recent activity. 3+ yrs stale = US trademark abandonment presumption may apply.",
},
licensing_outreach: {
label: "Licensing outreach",
color: "bg-emerald-700 text-white",
hint: "Active brand with no federal registration — legitimate to pitch THEM trademark/SEO/IP services. Not a squat target.",
},
do_not_target: {
label: "Do NOT target",
color: "bg-red-700 text-white",
hint: "Registered + actively using. ACPA/TTAB risk — keep away.",
},
unclear: {
label: "Unclear",
color: "bg-ink/20 text-ink",
hint: "Signals insufficient.",
},
};
function opportunityChip(s: string | null) {
const meta = OPPORTUNITY_META[s || "unclear"] || OPPORTUNITY_META.unclear;
return (
<span className={`rounded px-1.5 py-0.5 text-xs font-semibold ${meta.color}`} title={meta.hint}>
{meta.label}
</span>
);
}
function statusChip(s: string) {
const style =
s === "verified_unregistered" ? "bg-emerald-600 text-white" :
s === "registered" ? "bg-red-600 text-white" :
s === "parked" ? "bg-purple-600 text-white" :
s === "too_new" ? "bg-amber-500 text-white" :
s === "error" ? "bg-gray-500 text-white" :
"bg-ink/10 text-ink";
return <span className={`rounded px-1.5 py-0.5 text-xs font-semibold ${style}`}>{s}</span>;
}
export default function BrandHunter() {
const [rows, setRows] = useState<Brand[]>([]);
const [loading, setLoading] = useState(true);
const [q, setQ] = useState("");
const [statusFilter, setStatusFilter] = useState("");
const [opportunityFilter, setOpportunityFilter] = useState("");
const [minYears, setMinYears] = useState(0);
const [maxYears, setMaxYears] = useState<number | "">("");
const [sorting, setSorting] = useState<SortingState>([{ id: "years_in_business", desc: true }]);
const [hunting, setHunting] = useState(false);
const [cap, setCap] = useState(20);
const [log, setLog] = useState<string[]>([]);
async function load() {
setLoading(true);
const url = new URL("/api/brands", window.location.origin);
if (q) url.searchParams.set("q", q);
if (statusFilter) url.searchParams.set("status", statusFilter);
if (opportunityFilter) url.searchParams.set("opportunity", opportunityFilter);
url.searchParams.set("minYears", String(minYears));
if (maxYears !== "") url.searchParams.set("maxYears", String(maxYears));
const r = await fetch(url.toString());
const j = await r.json();
setRows(j.items || []);
setLoading(false);
}
useEffect(() => { load(); /* eslint-disable-next-line */ }, [q, statusFilter, opportunityFilter, minYears, maxYears]);
async function startHunt() {
if (hunting) return;
setHunting(true);
setLog([]);
const r = await fetch("/api/brands/hunt", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ category: "fashion", maxDiscoveries: cap }),
});
if (!r.body) { setHunting(false); return; }
const reader = r.body.getReader();
const dec = new TextDecoder();
let buf = "";
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
const parts = buf.split("\n\n");
buf = parts.pop() ?? "";
for (const part of parts) {
const line = part.split("\n").find((l) => l.startsWith("data: "));
if (!line) continue;
const payload = JSON.parse(line.slice(6));
const ts = new Date().toLocaleTimeString();
let msg = "";
switch (payload.kind) {
case "info": msg = `${payload.message}`; break;
case "start": msg = `→ ${payload.domain}`; break;
case "fetched": msg = ` ↓ ${payload.domain} HTTP ${payload.status}`; break;
case "extracted": msg = ` ⊙ ${payload.domain} brand="${payload.brand_name}" est=${payload.established_year ?? "?"}`; break;
case "whois": msg = ` ⊙ ${payload.domain} whois=${payload.created ?? "—"} (${payload.years ?? "?"}yrs) expires=${payload.expires ?? "—"}`; break;
case "activity": msg = ` ⊙ ${payload.domain} last activity=${payload.last_year ?? "?"} (${payload.years_stale ?? "?"}yrs stale · ${payload.status})`; break;
case "uspto": msg = ` ⊙ ${payload.domain} USPTO live=${payload.live_marks} ${payload.registered ? "REGISTERED" : "unregistered"}`; break;
case "label": msg = ` ★ ${payload.domain} → ${payload.label}`; break;
case "saved": msg = ` ✓ ${payload.domain} → ${payload.status} (${payload.years_in_business ?? "?"}yrs)`; break;
case "skipped": msg = ` · ${payload.domain} ${payload.reason}`; break;
case "discovered": msg = ` + Qwen suggested: ${payload.domains.join(", ")}`; break;
case "error": msg = ` ✗ ${payload.domain} ${payload.message}`; break;
case "done": msg = `★ done — discovered=${payload.totals.discovered} verified=${payload.totals.verified} errors=${payload.totals.errors}`; break;
case "fatal": msg = `FATAL — ${payload.message}`; break;
default: msg = JSON.stringify(payload);
}
setLog((L) => [...L.slice(-500), `${ts} ${msg}`]);
if (payload.kind === "saved" || payload.kind === "done") load();
}
}
} finally {
setHunting(false);
}
}
const columns = useMemo(
() => [
col.accessor("brand_name", {
header: "Brand",
cell: (c) => (
<div>
<div className="font-semibold">{c.getValue() as string || "—"}</div>
<a href={`https://${c.row.original.domain}`} target="_blank" rel="noreferrer" className="text-xs text-ink/60 hover:text-brass">
{c.row.original.domain}
</a>
</div>
),
}),
col.accessor("opportunity_label", {
header: "Opportunity",
cell: (c) => opportunityChip(c.getValue() as string | null),
}),
col.accessor("status", { header: "Reg.", cell: (c) => statusChip(c.getValue() as string) }),
col.accessor("domain_lifecycle", {
header: "Domain",
cell: (c) => {
const v = c.getValue() as string | null;
const days = c.row.original.days_to_expiry;
const chip = v === "live" ? <span className="rounded bg-emerald-600 px-1.5 py-0.5 text-xs text-white">live</span> :
v === "expiring_soon" ? <span className="rounded bg-amber-500 px-1.5 py-0.5 text-xs text-white">expiring {days ?? "?"}d</span> :
v === "expired" ? <span className="rounded bg-red-600 px-1.5 py-0.5 text-xs text-white">expired</span> :
<span className="text-ink/40 text-xs">?</span>;
return (
<div className="flex flex-col">
{chip}
{c.row.original.domain_expires_on && (
<span className="text-[10px] text-ink/50">exp {c.row.original.domain_expires_on.slice(0, 10)}</span>
)}
</div>
);
},
}),
col.accessor("abandonment_status", {
header: "Activity",
cell: (c) => {
const v = c.getValue() as string | null;
const yr = c.row.original.last_activity_year;
const stale = c.row.original.years_since_activity;
const chip = v === "active" ? <span className="rounded bg-emerald-600 px-1.5 py-0.5 text-xs text-white">active</span> :
v === "stale" ? <span className="rounded bg-amber-500 px-1.5 py-0.5 text-xs text-white">stale</span> :
v === "abandoned" ? <span className="rounded bg-red-600 px-1.5 py-0.5 text-xs text-white">abandoned</span> :
<span className="text-ink/40 text-xs">?</span>;
return (
<div className="flex flex-col" title={c.row.original.last_activity_signal ?? ""}>
{chip}
{yr && <span className="text-[10px] text-ink/50">last ©{yr}{stale !== null ? ` · ${stale}yr` : ""}</span>}
</div>
);
},
}),
col.accessor("years_in_business", {
header: "Yrs in Biz",
cell: (c) => {
const v = c.getValue() as number | null;
if (v === null) return <span className="text-ink/30">?</span>;
return (
<span className={v >= 10 ? "font-bold text-emerald-700" : v >= 5 ? "text-ink" : "text-ink/50"}>
{v}
</span>
);
},
}),
col.accessor("established_year", { header: "Est.", cell: (c) => (c.getValue() as number | null) ?? "—" }),
col.accessor("uspto_live_marks", {
header: "USPTO live",
cell: (c) => {
const v = c.getValue() as number | null;
const row = c.row.original;
if (v === null) return <span className="text-ink/30">—</span>;
if (v < 0) return <span className="text-ink/50" title={row.notes ?? "not checked"}>?</span>;
return (
<a
href={row.uspto_search_url ?? "#"}
target="_blank"
rel="noreferrer"
className="text-brass hover:underline"
title={row.notes ?? "Claude + USPTO web search"}
>
{v}
</a>
);
},
}),
col.accessor("has_federal_registration", {
header: "Registered?",
cell: (c) => {
const v = c.getValue() as boolean | null;
if (v === null) return <span className="text-ink/30">—</span>;
return v
? <span className="text-red-600">yes</span>
: <span className="font-semibold text-emerald-700">no</span>;
},
}),
col.accessor("meta_description", {
header: "Description",
cell: (c) => <span className="line-clamp-2 text-xs text-ink/70">{c.getValue() as string || "—"}</span>,
}),
col.accessor("suggested_by", { header: "Src", cell: (c) => <span className="tag">{c.getValue()}</span> }),
col.accessor("last_checked_at", {
header: "Checked",
cell: (c) => <span className="text-xs text-ink/60">{new Date(c.getValue() as string).toLocaleDateString()}</span>,
}),
],
[]
);
const table = useReactTable({
data: rows,
columns,
state: { sorting },
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
});
const verifiedCount = rows.filter((r) => r.status === "verified_unregistered").length;
return (
<div className="space-y-4">
<div className="card p-4">
<div className="flex flex-wrap items-center gap-3">
<div>
<div className="text-xs uppercase tracking-wide text-ink/60">Hunter</div>
<div className="text-sm">Qwen (local) discovers + extracts · Justia verifies USPTO registration</div>
</div>
<div className="ml-auto flex items-center gap-2">
<label className="text-sm text-ink/70">max per run</label>
<input type="number" min={1} max={100} value={cap} onChange={(e) => setCap(Number(e.target.value) || 20)} className="w-20" />
<button className="btn" onClick={startHunt} disabled={hunting}>
{hunting ? "Hunting…" : "Run hunter"}
</button>
</div>
</div>
{log.length > 0 && (
<pre className="mt-3 max-h-56 overflow-auto rounded bg-ledger p-3 font-mono text-xs text-parchment">
{log.join("\n")}
</pre>
)}
</div>
<div className="flex flex-wrap items-center gap-3">
<input placeholder="Search brand, domain, description…" value={q} onChange={(e) => setQ(e.target.value)} className="w-80" />
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
<option value="">Any registration</option>
<option value="verified_unregistered">Verified unregistered</option>
<option value="registered">Registered</option>
<option value="parked">Parked / redirect</option>
<option value="pending">Pending</option>
<option value="error">Error</option>
</select>
<select value={opportunityFilter} onChange={(e) => setOpportunityFilter(e.target.value)}>
<option value="">Any opportunity</option>
<option value="domain_snipe">Domain snipe</option>
<option value="abandonment_watch">Abandonment watch</option>
<option value="licensing_outreach">Licensing outreach</option>
<option value="do_not_target">Do NOT target</option>
<option value="unclear">Unclear</option>
</select>
<div className="flex items-center gap-1 text-sm text-ink/70">
<label>yrs</label>
<input
type="number"
min={0}
value={minYears}
onChange={(e) => setMinYears(Number(e.target.value) || 0)}
className="w-16"
title="Minimum years in business"
/>
<span>–</span>
<input
type="number"
min={0}
placeholder="∞"
value={maxYears}
onChange={(e) => setMaxYears(e.target.value === "" ? "" : Number(e.target.value))}
className="w-16"
title="Maximum years in business (blank = no cap)"
/>
</div>
<div className="flex gap-1">
<button className="btn-ghost text-xs" onClick={() => { setMinYears(5); setMaxYears(""); }} title="Established brands">5+ yrs</button>
<button className="btn-ghost text-xs" onClick={() => { setMinYears(0); setMaxYears(5); }} title="Newer brands — no common-law rights built up yet"><5 yrs</button>
<button className="btn-ghost text-xs" onClick={() => { setMinYears(10); setMaxYears(""); }} title="Long-running unregistered">10+ yrs</button>
<button className="btn-ghost text-xs" onClick={() => { setMinYears(0); setMaxYears(""); }}>All</button>
</div>
<div className="ml-auto text-sm">
<span className="font-semibold text-emerald-700">{verifiedCount}</span> verified unregistered · <span className="text-ink/60">{rows.length} total</span>
</div>
</div>
<div className="card overflow-x-auto">
<table className="min-w-full text-sm">
<thead className="border-b border-ink/10 bg-ink/5 text-left">
{table.getHeaderGroups().map((hg) => (
<tr key={hg.id}>
{hg.headers.map((h) => (
<th
key={h.id}
onClick={h.column.getCanSort() ? h.column.getToggleSortingHandler() : undefined}
className="select-none px-3 py-2 font-semibold text-ink/80"
style={{ cursor: h.column.getCanSort() ? "pointer" : "default" }}
>
{flexRender(h.column.columnDef.header, h.getContext())}
{{ asc: " ▲", desc: " ▼" }[h.column.getIsSorted() as string] ?? ""}
</th>
))}
</tr>
))}
</thead>
<tbody>
{loading && <tr><td colSpan={columns.length} className="p-6 text-center text-ink/50">Loading…</td></tr>}
{!loading && table.getRowModel().rows.length === 0 && (
<tr><td colSpan={columns.length} className="p-6 text-center text-ink/50">No candidates yet. Click “Run hunter” to seed the pool.</td></tr>
)}
{table.getRowModel().rows.map((row) => (
<tr key={row.id} className="border-b border-ink/5 hover:bg-brass/5">
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="px-3 py-2 align-top">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}