← back to Trademarks Copyright
src/app/alerts/page.tsx
163 lines
import Link from "next/link";
import { query } from "@/lib/db";
export const metadata = {
title: "Alerts — Ranked Catalog",
description: "Every abandoned trademark + public-domain work in the catalog, ranked by composite score — the same content that gets sent to steve-office via George.",
};
export const dynamic = "force-dynamic";
type Idea = { title: string; description: string };
type Row = {
id: number; kind: "trademark" | "copyright"; name: string; status: string;
original_owner: string | null; nice_class: string | null; goods_services: string | null;
expired_date: string; registered_date: string | null; registration_number: string | null;
source: string; source_url: string | null; notes: string | null;
distinctiveness_score: number; recognition_score: number;
domain_available: boolean | null; competing_active_marks: number; monetization_breadth: number;
composite_score: string;
monetization_ideas: Idea[];
};
export default async function AlertsPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) {
const sp = await searchParams;
const kind = sp.kind || "";
const where: string[] = ["composite_score IS NOT NULL"];
const params: unknown[] = [];
if (kind === "trademark" || kind === "copyright") {
params.push(kind);
where.push(`kind = $${params.length}`);
}
const { rows } = await query<Row>(
`SELECT id, kind, name, status, original_owner, nice_class, goods_services,
expired_date, registered_date, registration_number,
source, source_url, notes,
distinctiveness_score, recognition_score,
domain_available, competing_active_marks, monetization_breadth,
composite_score, monetization_ideas
FROM items
WHERE ${where.join(" AND ")}
ORDER BY composite_score DESC`,
params
);
const total = rows.length;
return (
<article className="mx-auto max-w-4xl">
<div className="card p-6 mb-4">
<div className="flex flex-wrap items-center gap-3">
<div>
<div className="text-xs uppercase tracking-widest text-brass">Ranked Catalog</div>
<h1 className="mt-2 text-3xl font-semibold">All {total} alerts, scored 0–100.</h1>
<p className="mt-1 text-sm text-ink/70">
The same items George sent to <code>steve@designerwallcoverings.com</code> today — ranked, cross-referenced, and with full metadata.
</p>
</div>
<div className="ml-auto flex items-center gap-2 text-sm">
<Link href="/alerts" className={`tag ${!kind ? "bg-ink/10 font-bold" : ""}`}>All</Link>
<Link href="/alerts?kind=trademark" className={`tag ${kind === "trademark" ? "bg-ink/10 font-bold" : ""}`}>Trademarks</Link>
<Link href="/alerts?kind=copyright" className={`tag ${kind === "copyright" ? "bg-ink/10 font-bold" : ""}`}>Public domain</Link>
</div>
</div>
</div>
<ol className="space-y-3" start={1}>
{rows.map((r, idx) => (
<AlertCard key={r.id} rank={idx + 1} item={r} />
))}
</ol>
<div className="mt-8 card p-6 text-sm text-ink/70">
This catalog powers the daily 6 AM alert. Rank and score are recomputed whenever seed data or the scoring weights change.
</div>
</article>
);
}
function medal(rank: number): string {
return rank === 1 ? "🥇" : rank === 2 ? "🥈" : rank === 3 ? "🥉" : `#${rank}`;
}
function scoreColor(n: number): string {
const hue = Math.round((n / 100) * 120);
return `hsl(${hue} 55% 40%)`;
}
function AlertCard({ rank, item }: { rank: number; item: Row }) {
const score = Number(item.composite_score);
const date = new Date(item.expired_date).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
const ideas: Idea[] = Array.isArray(item.monetization_ideas) ? item.monetization_ideas : [];
return (
<li className="card overflow-hidden" id={`rank-${rank}`}>
<div className="flex items-start gap-4 p-5 border-l-4" style={{ borderColor: "#b38f4e" }}>
<div className="shrink-0 text-center" style={{ minWidth: 72 }}>
<div className="text-3xl">{medal(rank)}</div>
<div className="mt-1 inline-block rounded px-2 py-0.5 text-xs font-bold text-white" style={{ backgroundColor: scoreColor(score) }}>
{score.toFixed(1)}
</div>
</div>
<div className="flex-1">
<div className="flex flex-wrap items-baseline gap-2">
<h2 className="text-xl font-semibold">
<Link href={`/item/${item.id}`} className="hover:text-brass">{item.name}</Link>
</h2>
<span className="tag">{item.kind}</span>
<span className="tag">{item.status}</span>
<span className="text-xs text-ink/60">expired {date}</span>
{item.registration_number && <span className="text-xs text-ink/60">· reg #{item.registration_number}</span>}
</div>
<dl className="mt-3 grid grid-cols-1 gap-x-6 gap-y-1 text-sm md:grid-cols-2">
<KV k="Original owner" v={item.original_owner} />
{item.nice_class && <KV k="NICE class" v={item.nice_class} />}
{item.goods_services && <KV k="Goods / services" v={item.goods_services} />}
<KV k="Distinctiveness" v={String(item.distinctiveness_score)} />
<KV k="Recognition" v={String(item.recognition_score)} />
<KV k="Domain (.com)" v={item.domain_available === true ? "yes" : item.domain_available === false ? "no" : "unknown"} />
<KV k="Competing live marks" v={String(item.competing_active_marks)} />
<KV k="Monetization breadth" v={String(item.monetization_breadth)} />
<KV k="Source" v={item.source_url
? <a href={item.source_url} target="_blank" rel="noreferrer" className="text-brass hover:underline">{item.source}</a>
: item.source
} />
</dl>
{item.notes && (
<p className="mt-3 text-sm text-ink/80"><strong>Notes:</strong> {item.notes}</p>
)}
{ideas.length > 0 && (
<div className="mt-3">
<div className="text-xs uppercase tracking-wide text-brass">Monetization angles</div>
<ul className="mt-1 space-y-1 text-sm">
{ideas.map((idea, i) => (
<li key={i}>
<strong>{idea.title}</strong> — <span className="text-ink/80">{idea.description}</span>
</li>
))}
</ul>
</div>
)}
<div className="mt-3 text-xs">
<Link href={`/item/${item.id}`} className="text-brass hover:underline">Open in app →</Link>
<a href={`#rank-${rank}`} className="ml-3 text-ink/40 hover:text-brass">#</a>
</div>
</div>
</div>
</li>
);
}
function KV({ k, v }: { k: string; v: React.ReactNode }) {
return (
<>
<dt className="text-ink/55">{k}</dt>
<dd className="text-ink/90">{v ?? "—"}</dd>
</>
);
}