← back to Open Seo
src/client/features/ai-search/components/BrandLookupCitationsCard.tsx
270 lines
import { useMemo, useState } from "react";
import { type SortingState } from "@tanstack/react-table";
import { ChevronDown, Download, Sheet, SlidersHorizontal } from "lucide-react";
import { useAppTable } from "@/client/components/table/AppDataTable";
import { exportTableToSheets } from "@/client/lib/exportToSheets";
import {
buildBrandLookupExport,
downloadBrandLookupCsv,
} from "@/client/features/ai-search/components/brandLookupExport";
import { BrandLookupFilterPanel } from "@/client/features/ai-search/components/BrandLookupFilterPanel";
import {
TopPagesTable,
TopQueriesTable,
buildTopPagesColumns,
buildTopQueriesColumns,
} from "@/client/features/ai-search/components/BrandLookupCitationTables";
import {
formatPlatformLabel,
PLATFORM_DOT_CLASS,
} from "@/client/features/ai-search/platformLabels";
import {
filterQueries,
filterTopPages,
} from "@/client/features/ai-search/brandLookupFiltering";
import { useBrandLookupFilters } from "@/client/features/ai-search/useBrandLookupFilters";
import type { CitationTab } from "@/client/features/ai-search/brandLookupFilterTypes";
import type { BrandLookupResult } from "@/types/schemas/ai-search";
const DEFAULT_PAGES_SORT: SortingState = [{ id: "capturedVolume", desc: true }];
const DEFAULT_QUERIES_SORT: SortingState = [
{ id: "aiSearchVolume", desc: true },
];
// DaisyUI focus-dropdowns stay open until the active element blurs.
function closeExportMenu(): void {
const active = document.activeElement;
if (active instanceof HTMLElement) active.blur();
}
export function CitationTabsCard({
result,
projectId,
}: {
result: BrandLookupResult;
projectId: string;
}) {
const [activeTab, setActiveTab] = useState<CitationTab>("queries");
const [pagesSort, setPagesSort] = useState<SortingState>(DEFAULT_PAGES_SORT);
const [queriesSort, setQueriesSort] =
useState<SortingState>(DEFAULT_QUERIES_SORT);
const filters = useBrandLookupFilters();
// The platform column only earns its place when a tab actually spans >1
// platform; otherwise it repeats one value on every row.
const queryPlatforms = [
...new Set(result.topQueries.map((query) => query.platform)),
];
const pagePlatforms = [
...new Set(result.topPages.map((page) => page.platform)),
];
const showQueryPlatform = queryPlatforms.length > 1;
const showPagePlatform = pagePlatforms.length > 1;
const targetDomain =
result.detectedTargetType === "domain" ? result.resolvedTarget : null;
const filteredPages = useMemo(
() => filterTopPages(result.topPages, filters.pages.values),
[result.topPages, filters.pages.values],
);
const filteredQueries = useMemo(
() => filterQueries(result.topQueries, filters.queries.values),
[result.topQueries, filters.queries.values],
);
const pagesColumns = useMemo(
() =>
buildTopPagesColumns({
showPlatform: showPagePlatform,
targetDomain,
projectId,
brand: result.resolvedTarget,
}),
[showPagePlatform, targetDomain, projectId, result.resolvedTarget],
);
const queriesColumns = useMemo(
() =>
buildTopQueriesColumns({
showPlatform: showQueryPlatform,
projectId,
brand: result.resolvedTarget,
}),
[showQueryPlatform, projectId, result.resolvedTarget],
);
const pagesTable = useAppTable({
data: filteredPages,
columns: pagesColumns,
state: { sorting: pagesSort },
onSortingChange: setPagesSort,
withSorting: true,
// Stable identity (default is the array index): KeywordsCell holds
// expanded state, which must follow the page when filtering/sorting
// reorders rows, not stick to whatever row lands in the same slot.
getRowId: (row) => `${row.platform}:${row.url}`,
});
const queriesTable = useAppTable({
data: filteredQueries,
columns: queriesColumns,
state: { sorting: queriesSort },
onSortingChange: setQueriesSort,
withSorting: true,
});
// Not memoized: TanStack's `getSortedRowModel()` is internally cached, and
// memoing on the table refs alone (which are stable across renders) would
// serve stale data when sort or filters change.
const exportTable = buildBrandLookupExport(
activeTab,
pagesTable.getSortedRowModel().rows.map((row) => row.original),
queriesTable.getSortedRowModel().rows.map((row) => row.original),
);
const handleExportCsv = () => {
downloadBrandLookupCsv(activeTab, result.resolvedTarget, exportTable);
closeExportMenu();
};
const handleExportSheets = () => {
void exportTableToSheets({
headers: exportTable.headers,
rows: exportTable.rows,
feature: `brand_lookup_${activeTab}`,
});
closeExportMenu();
};
const canExport = exportTable.rows.length > 0;
const currentFilterCount = filters[activeTab].activeFilterCount;
const queriesActive = activeTab === "queries";
const pagesActive = activeTab === "pages";
// When the active tab's platform column is hidden, surface the lone platform
// once here instead of repeating it on every row.
const activePlatforms = pagesActive ? pagePlatforms : queryPlatforms;
const captionPlatform =
activePlatforms.length === 1 ? activePlatforms[0] : null;
return (
<section className="overflow-hidden rounded-xl border border-base-300 bg-base-100">
<div className="flex items-center justify-between gap-3 border-b border-base-300 px-4 py-3">
<div role="tablist" className="tabs tabs-border w-fit">
<button
type="button"
role="tab"
aria-selected={queriesActive}
className={`tab ${queriesActive ? "tab-active" : ""}`}
onClick={() => setActiveTab("queries")}
>
Queries
</button>
<button
type="button"
role="tab"
aria-selected={pagesActive}
className={`tab ${pagesActive ? "tab-active" : ""}`}
onClick={() => setActiveTab("pages")}
>
Cited sources
</button>
</div>
<div className="dropdown dropdown-end">
<div
tabIndex={0}
role="button"
className={`btn btn-ghost btn-sm gap-1.5 ${canExport ? "" : "btn-disabled"}`}
>
<Download className="size-3.5" />
Export
<ChevronDown className="size-3.5" />
</div>
<ul
tabIndex={0}
className="menu dropdown-content z-10 mt-1 w-48 rounded-box border border-base-300 bg-base-100 p-1 shadow"
>
<li>
<button
type="button"
onClick={handleExportSheets}
disabled={!canExport}
>
<Sheet className="size-4" />
Google Sheets
</button>
</li>
<li>
<button
type="button"
onClick={handleExportCsv}
disabled={!canExport}
>
<Download className="size-4" />
CSV
</button>
</li>
</ul>
</div>
</div>
<div className="flex items-center gap-2 border-b border-base-300 px-4 py-2">
<button
type="button"
className={`btn btn-ghost btn-sm gap-1.5 ${filters.showFilters ? "btn-active" : ""}`}
onClick={() => filters.setShowFilters((current) => !current)}
title="Toggle table filters"
>
<SlidersHorizontal className="size-3.5" />
Filters
{currentFilterCount > 0 ? (
<span className="badge badge-xs badge-primary border-0 text-primary-content">
{currentFilterCount}
</span>
) : null}
</button>
</div>
<div className="flex items-center justify-between gap-3 border-b border-base-300 px-4 py-2 text-xs text-base-content/60">
<span>
{activeTab === "pages" ? (
<>
Pages cited alongside{" "}
<strong className="text-base-content/80">
{result.resolvedTarget}
</strong>{" "}
in AI answers. Prompt examples come from the fetched sample.
</>
) : (
<>
Fetched sample of prompts whose AI answer cited{" "}
<strong className="text-base-content/80">
{result.resolvedTarget}
</strong>{" "}
in its text or sources.
</>
)}
</span>
{captionPlatform ? (
<span className="inline-flex shrink-0 items-center gap-1.5 text-base-content/70">
<span
className={`size-1.5 rounded-full ${PLATFORM_DOT_CLASS[captionPlatform]}`}
/>
{formatPlatformLabel(captionPlatform)}
</span>
) : null}
</div>
{filters.showFilters ? (
<BrandLookupFilterPanel activeTab={activeTab} filters={filters} />
) : null}
{activeTab === "pages" ? (
<TopPagesTable table={pagesTable} />
) : (
<TopQueriesTable table={queriesTable} />
)}
</section>
);
}