← back to Open Seo
src/client/features/ai-search/components/BrandLookupCitationTables.tsx
399 lines
import { useState } from "react";
import { createColumnHelper, type Table } from "@tanstack/react-table";
import { Link } from "@tanstack/react-router";
import { ExternalLink, Sparkles } from "lucide-react";
import { AppDataTable } from "@/client/components/table/AppDataTable";
import { SortableHeader } from "@/client/components/table/SortableHeader";
import { HeaderHelpLabel } from "@/client/features/keywords/components";
import { numericNullsLast } from "@/client/components/table/nullSafeSort";
import {
formatCount,
PLATFORM_DOT_CLASS,
PLATFORM_SHORT_LABEL,
} from "@/client/features/ai-search/platformLabels";
import { formatUrlForDisplay } from "@/client/components/table/url";
import type { BrandLookupResult } from "@/types/schemas/ai-search";
type TopPageRow = BrandLookupResult["topPages"][number];
type TopQueryRow = BrandLookupResult["topQueries"][number];
type PlatformKey = TopPageRow["platform"];
/** Uppercase column header with a hover/focus popover explaining the column. */
function HeaderWithHelp({
label,
helpText,
}: {
label: string;
helpText: string;
}) {
return (
<span className="uppercase tracking-wider">
<HeaderHelpLabel label={label} helpText={helpText} />
</span>
);
}
const PLATFORM_HELP =
"Which AI surface produced the answer — ChatGPT or Google AI Overview.";
/**
* Platform indicator used only when a table actually spans >1 platform. A dot +
* short label replaces the old full-width pill that repeated identically on
* every row.
*/
function PlatformCell({ platform }: { platform: PlatformKey }) {
return (
<span className="inline-flex items-center gap-1.5 text-xs text-base-content/70">
<span
className={`size-1.5 rounded-full ${PLATFORM_DOT_CLASS[platform]}`}
/>
{PLATFORM_SHORT_LABEL[platform]}
</span>
);
}
function urlPath(rawUrl: string): string {
try {
const url = new URL(rawUrl);
const path = `${url.pathname}${url.search}`;
return path === "/" ? "" : path;
} catch {
return "";
}
}
function normalizeDomain(value: string): string {
return value.replace(/^www\./i, "").toLowerCase();
}
/**
* The lookup targets a domain with include_subdomains, so the target's own
* pages can surface under any subdomain (docs.acme.com for acme.com) — those
* must get the "You" badge too.
*/
function isTargetDomain(domain: string, targetDomain: string): boolean {
const candidate = normalizeDomain(domain);
const target = normalizeDomain(targetDomain);
return candidate === target || candidate.endsWith(`.${target}`);
}
/** Domain-led cited page: bold domain + truncated path, links out. */
function PageUrlCell({
row,
targetDomain,
}: {
row: TopPageRow;
targetDomain: string | null;
}) {
const path = urlPath(row.url);
const isOwn =
targetDomain != null &&
row.domain != null &&
isTargetDomain(row.domain, targetDomain);
return (
<a
href={row.url}
target="_blank"
rel="noreferrer"
className="group block max-w-xl"
>
<span className="inline-flex items-center gap-1.5">
<span className="font-medium text-base-content group-hover:underline">
{row.domain ?? formatUrlForDisplay(row.url)}
</span>
{isOwn ? (
<span className="badge badge-primary badge-xs border-0">You</span>
) : null}
<ExternalLink className="size-3 shrink-0 text-base-content/40" />
</span>
{path ? (
<span className="block truncate text-xs text-base-content/50">
{path}
</span>
) : null}
</a>
);
}
/**
* The prompts (keywords) whose answers cited this page. Shows the top 3 inline;
* if there are more, a "+N more" toggle reveals the rest. Each prompt links into
* Prompt Explorer prefilled with it.
*/
function KeywordsCell({
keywords,
projectId,
brand,
}: {
keywords: TopPageRow["keywords"];
projectId: string;
brand: string;
}) {
const [expanded, setExpanded] = useState(false);
if (keywords.length === 0) {
return <span className="text-base-content/40">—</span>;
}
const visible = expanded ? keywords : keywords.slice(0, 3);
const remaining = keywords.length - visible.length;
return (
<div className="space-y-1">
<ul className="space-y-0.5">
{visible.map((keyword) => (
<li key={keyword.question}>
<Link
to="/p/$projectId/prompt-explorer"
params={{ projectId }}
search={{ q: keyword.question, hb: brand || undefined }}
className="group/kw inline-flex items-baseline gap-2 text-xs"
title="Run this prompt in Prompt Explorer"
>
<span className="text-base-content/80 group-hover/kw:underline">
{keyword.question}
</span>
<span
className="shrink-0 tabular-nums text-base-content/40"
title="Prompt volume in the fetched sample"
>
{formatCount(keyword.aiSearchVolume)} vol.
</span>
</Link>
</li>
))}
</ul>
{keywords.length > 3 ? (
<button
type="button"
onClick={() => setExpanded((current) => !current)}
className="text-xs text-base-content/50 hover:text-base-content"
>
{expanded ? "Show less" : `+${remaining} more`}
</button>
) : null}
</div>
);
}
const pagesHelper = createColumnHelper<TopPageRow>();
const queriesHelper = createColumnHelper<TopQueryRow>();
export function buildTopPagesColumns({
showPlatform,
targetDomain,
projectId,
brand,
}: {
showPlatform: boolean;
targetDomain: string | null;
projectId: string;
brand: string;
}) {
return [
pagesHelper.accessor("url", {
id: "url",
header: () => (
<HeaderWithHelp
label="Source"
helpText="A page cited as a source in AI answers where the searched brand or domain appears."
/>
),
enableSorting: false,
cell: ({ row }) => (
<PageUrlCell row={row.original} targetDomain={targetDomain} />
),
}),
...(showPlatform
? [
pagesHelper.accessor("platform", {
id: "platform",
header: () => (
<HeaderWithHelp label="Platform" helpText={PLATFORM_HELP} />
),
enableSorting: false,
cell: ({ getValue }) => <PlatformCell platform={getValue()} />,
}),
]
: []),
pagesHelper.display({
id: "keywords",
header: () => (
<HeaderWithHelp
label="Cited for"
helpText="Example prompts from the fetched sample where this page was cited."
/>
),
cell: ({ row }) => (
<KeywordsCell
keywords={row.original.keywords}
projectId={projectId}
brand={brand}
/>
),
}),
pagesHelper.accessor("capturedVolume", {
id: "capturedVolume",
header: ({ column }) => (
<SortableHeader
column={column}
label="Source vol."
helpText="Estimated monthly prompt demand DataForSEO reports for this cited source, across prompts where the searched brand or domain appears."
align="right"
/>
),
cell: ({ getValue }) => (
<span className="tabular-nums">{formatCount(getValue())}</span>
),
sortingFn: numericNullsLast,
sortDescFirst: true,
}),
];
}
export function buildTopQueriesColumns({
showPlatform,
projectId,
brand,
}: {
showPlatform: boolean;
projectId: string;
brand: string;
}) {
return [
queriesHelper.accessor("question", {
id: "question",
header: () => (
<HeaderWithHelp
label="Query"
helpText="A sampled user prompt whose AI answer cited the searched brand or domain in its text or sources. The prompt itself may not name the brand."
/>
),
enableSorting: false,
cell: ({ row }) => (
<>
<p className="break-words font-medium">{row.original.question}</p>
{row.original.brandsMentioned.length > 0 ? (
<p className="mt-0.5 text-xs text-base-content/50">
Brands: {row.original.brandsMentioned.slice(0, 5).join(", ")}
</p>
) : null}
</>
),
}),
...(showPlatform
? [
queriesHelper.accessor("platform", {
id: "platform",
header: () => (
<HeaderWithHelp label="Platform" helpText={PLATFORM_HELP} />
),
enableSorting: false,
cell: ({ getValue }) => <PlatformCell platform={getValue()} />,
}),
]
: []),
queriesHelper.accessor("aiSearchVolume", {
id: "aiSearchVolume",
header: ({ column }) => (
<SortableHeader
column={column}
label="AI search vol."
helpText="Estimated monthly search demand for this prompt's topic. This is prompt demand, not the number of brand mentions."
align="right"
/>
),
cell: ({ getValue }) => (
<span className="tabular-nums">{formatCount(getValue())}</span>
),
sortingFn: numericNullsLast,
sortDescFirst: true,
}),
queriesHelper.display({
id: "action",
header: () => <span className="sr-only">Actions</span>,
meta: { cellClassName: "w-px whitespace-nowrap text-right align-top" },
cell: ({ row }) => (
<span
className="tooltip tooltip-left opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100"
data-tip="Run this prompt in Prompt Explorer"
>
<Link
to="/p/$projectId/prompt-explorer"
params={{ projectId }}
search={{ q: row.original.question, hb: brand || undefined }}
className="btn btn-ghost btn-xs gap-1"
aria-label="Run this prompt in Prompt Explorer"
>
<Sparkles className="size-3.5" />
</Link>
</span>
),
}),
];
}
export function TopPagesTable({ table }: { table: Table<TopPageRow> }) {
if (table.getRowModel().rows.length === 0) {
return (
<p className="p-6 text-center text-sm text-base-content/60">
No cited sources to show.
</p>
);
}
return <BrandLookupTable table={table} urlLikeColumnId="url" />;
}
export function TopQueriesTable({ table }: { table: Table<TopQueryRow> }) {
if (table.getRowModel().rows.length === 0) {
return (
<p className="p-6 text-center text-sm text-base-content/60">
No matching queries found.
</p>
);
}
return <BrandLookupTable table={table} urlLikeColumnId="question" />;
}
function BrandLookupTable<T>({
table,
urlLikeColumnId,
}: {
table: Table<T>;
urlLikeColumnId: string;
}) {
return (
<AppDataTable
table={table}
getRowClassName={() => "group transition-colors hover:bg-base-200/40"}
getCellClassName={(_, columnId) =>
cellClassName(
columnId,
urlLikeColumnId,
table.getColumn(columnId)?.getCanSort() ?? false,
)
}
/>
);
}
function cellClassName(
columnId: string,
urlLikeColumnId: string,
isNumeric: boolean,
): string {
if (columnId === urlLikeColumnId) {
return "min-w-80 max-w-2xl align-top";
}
if (columnId === "keywords") {
return "max-w-lg align-top";
}
if (isNumeric) {
return "whitespace-nowrap text-right align-top";
}
return "whitespace-nowrap align-top";
}