← 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">&lt;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>
  );
}