diff --git a/src/client/components/table/ExportToSheetsButton.tsx b/src/client/components/table/ExportToSheetsButton.tsx new file mode 100644 index 0000000..3e54386 --- /dev/null +++ b/src/client/components/table/ExportToSheetsButton.tsx @@ -0,0 +1,57 @@ +import { Sheet } from "lucide-react"; +import { useState } from "react"; +import type { CsvValue } from "@/client/lib/csv"; +import { exportTableToSheets } from "@/client/lib/exportToSheets"; + +type Props = { + headers: string[]; + /** + * Full filtered/sorted dataset (not a UI-paginated slice). Emit raw numeric + * values (not formatted strings) so Sheets parses them as numbers. + */ + rows: CsvValue[][]; + /** PostHog `source_feature` for the `data:export_sheets` event. */ + feature: string; + disabled?: boolean; + label?: string; + /** Render the icon without a text label (icon-only mode for tight rows). */ + iconOnly?: boolean; + /** Extra classes appended to the default button classes. */ + className?: string; +}; + +export function ExportToSheetsButton({ + headers, + rows, + feature, + disabled, + label = "Export to Sheets", + iconOnly, + className, +}: Props) { + const [busy, setBusy] = useState(false); + + const handleClick = async () => { + if (busy) return; + setBusy(true); + try { + await exportTableToSheets({ headers, rows, feature }); + } finally { + setBusy(false); + } + }; + + return ( + + ); +} diff --git a/src/client/components/table/ExportToSheetsModal.tsx b/src/client/components/table/ExportToSheetsModal.tsx new file mode 100644 index 0000000..896b0a8 --- /dev/null +++ b/src/client/components/table/ExportToSheetsModal.tsx @@ -0,0 +1,67 @@ +import { useEffect } from "react"; +import { useLocation } from "@tanstack/react-router"; +import { Check, ExternalLink, X } from "lucide-react"; +import { Modal } from "@/client/components/Modal"; +import { + closeExportToSheetsModal, + openGoogleSheetsTab, + useExportToSheetsModalState, +} from "@/client/lib/exportToSheets"; + +export function ExportToSheetsModal() { + const state = useExportToSheetsModalState(); + // Close any stale modal when the user navigates away mid-flow. Deps must + // be `[pathname]` only — adding `isOpen` would close the modal the instant + // it opens (the effect would fire on the open->true transition). + const pathname = useLocation({ select: (l) => l.pathname }); + useEffect(() => { + closeExportToSheetsModal(); + }, [pathname]); + + if (!state.isOpen) return null; + + const { rowCount } = state; + + const handleOpenSheet = () => { + openGoogleSheetsTab(); + closeExportToSheetsModal(); + }; + + return ( + +
+
+ + + +

+ Copied {rowCount} row{rowCount === 1 ? "" : "s"} to your clipboard +

+
+ +
+ +

+ Open a new Google Sheet and paste to fill it. +

+ +
+ +
+
+ ); +} diff --git a/src/client/features/ai-search/components/BrandLookupResults.tsx b/src/client/features/ai-search/components/BrandLookupResults.tsx index b505b40..1a1f6c3 100644 --- a/src/client/features/ai-search/components/BrandLookupResults.tsx +++ b/src/client/features/ai-search/components/BrandLookupResults.tsx @@ -6,7 +6,11 @@ import { type SortingState, } from "@tanstack/react-table"; import { Download, Info, SlidersHorizontal } from "lucide-react"; -import { buildCsv, downloadCsv } from "@/client/lib/csv"; +import { ExportToSheetsButton } from "@/client/components/table/ExportToSheetsButton"; +import { + buildBrandLookupExport, + downloadBrandLookupCsv, +} from "@/client/features/ai-search/components/brandLookupExport"; import { BrandLookupMentionTrendCard } from "@/client/features/ai-search/components/BrandLookupMentionTrendCard"; import { BrandLookupFilterPanel } from "@/client/features/ai-search/components/BrandLookupFilterPanel"; import { @@ -264,49 +268,19 @@ function CitationTabsCard({ result }: { result: BrandLookupResult }) { getSortedRowModel: getSortedRowModel(), }); - const handleExport = () => { - if (activeTab === "pages") { - const sortedPages = pagesTable - .getSortedRowModel() - .rows.map((row) => row.original); - const csv = buildCsv( - ["URL", "Domain", "Platform", "Mentions"], - sortedPages.map((row) => [ - row.url, - row.domain ?? "", - formatPlatformLabel(row.platform), - row.mentions ?? "", - ]), - ); - downloadCsv( - `ai-brand-lookup-pages-${slugify(result.resolvedTarget)}.csv`, - csv, - ); - return; - } - const sortedQueries = queriesTable - .getSortedRowModel() - .rows.map((row) => row.original); - const csv = buildCsv( - ["Query", "Platform", "AI search volume", "First seen", "Last seen"], - sortedQueries.map((row) => [ - row.question, - formatPlatformLabel(row.platform), - row.aiSearchVolume ?? "", - row.firstSeenAt ?? "", - row.lastSeenAt ?? "", - ]), - ); - downloadCsv( - `ai-brand-lookup-queries-${slugify(result.resolvedTarget)}.csv`, - csv, - ); - }; + // 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 canExport = - activeTab === "pages" - ? filteredPages.length > 0 - : filteredQueries.length > 0; + const handleExport = () => + downloadBrandLookupCsv(activeTab, result.resolvedTarget, exportTable); + + const canExport = exportTable.rows.length > 0; const currentFilterCount = filters[activeTab].activeFilterCount; @@ -332,16 +306,24 @@ function CitationTabsCard({ result }: { result: BrandLookupResult }) { - +
+ + +
@@ -410,11 +392,3 @@ function formatRelative(iso: string): string { const diffDay = Math.floor(diffHr / 24); return `${diffDay}d ago`; } - -function slugify(value: string): string { - return value - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, "") - .slice(0, 60); -} diff --git a/src/client/features/ai-search/components/brandLookupExport.ts b/src/client/features/ai-search/components/brandLookupExport.ts new file mode 100644 index 0000000..6584a85 --- /dev/null +++ b/src/client/features/ai-search/components/brandLookupExport.ts @@ -0,0 +1,63 @@ +import { buildCsv, type CsvValue, downloadCsv } from "@/client/lib/csv"; +import { formatPlatformLabel } from "@/client/features/ai-search/platformLabels"; +import type { BrandLookupResult } from "@/types/schemas/ai-search"; + +type CitationTab = "queries" | "pages"; + +type PageRow = BrandLookupResult["topPages"][number]; +type QueryRow = BrandLookupResult["topQueries"][number]; + +export function buildBrandLookupExport( + tab: CitationTab, + sortedPages: PageRow[], + sortedQueries: QueryRow[], +): { headers: string[]; rows: CsvValue[][] } { + if (tab === "pages") { + return { + headers: ["URL", "Domain", "Platform", "Mentions"], + rows: sortedPages.map((row) => [ + row.url, + row.domain ?? "", + formatPlatformLabel(row.platform), + row.mentions ?? "", + ]), + }; + } + return { + headers: [ + "Query", + "Platform", + "AI search volume", + "First seen", + "Last seen", + ], + rows: sortedQueries.map((row) => [ + row.question, + formatPlatformLabel(row.platform), + row.aiSearchVolume ?? "", + row.firstSeenAt ?? "", + row.lastSeenAt ?? "", + ]), + }; +} + +export function downloadBrandLookupCsv( + tab: CitationTab, + resolvedTarget: string, + table: { headers: string[]; rows: CsvValue[][] }, +) { + const slug = slugify(resolvedTarget); + const filename = + tab === "pages" + ? `ai-brand-lookup-pages-${slug}.csv` + : `ai-brand-lookup-queries-${slug}.csv`; + downloadCsv(filename, buildCsv(table.headers, table.rows)); +} + +function slugify(value: string): string { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 60); +} diff --git a/src/client/features/audit/results/ResultsTables.tsx b/src/client/features/audit/results/ResultsTables.tsx index 2ea5c4f..9465a10 100644 --- a/src/client/features/audit/results/ResultsTables.tsx +++ b/src/client/features/audit/results/ResultsTables.tsx @@ -210,7 +210,7 @@ function PerformanceRow({ export function ExportDropdown({ onExport, }: { - onExport: (format: "csv" | "json") => void; + onExport: (format: "csv" | "json" | "sheets") => void; }) { return (
@@ -221,8 +221,13 @@ export function ExportDropdown({