feat: add 'Export to Google Sheets' to every data table (#144)

This commit is contained in:
Ben Senescu 2026-05-03 15:25:25 -04:00 committed by GitHub
parent 368b3ce375
commit ab50e59ff8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 1082 additions and 415 deletions

View File

@ -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 (
<button
type="button"
className={`btn btn-ghost btn-xs gap-1 ${className ?? ""}`}
onClick={handleClick}
disabled={disabled || rows.length === 0 || busy}
title="Copy table and open a new Google Sheet"
aria-label={iconOnly ? "Export to Sheets" : undefined}
>
<Sheet className="size-3.5" />
{iconOnly ? null : label}
</button>
);
}

View File

@ -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 (
<Modal maxWidth="max-w-md">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2">
<span className="inline-flex size-8 items-center justify-center rounded-full bg-success/15 text-success">
<Check className="size-4" />
</span>
<h3 className="text-base font-semibold">
Copied {rowCount} row{rowCount === 1 ? "" : "s"} to your clipboard
</h3>
</div>
<button
type="button"
className="btn btn-ghost btn-xs btn-square"
onClick={closeExportToSheetsModal}
aria-label="Close"
>
<X className="size-4" />
</button>
</div>
<p className="text-sm text-base-content/75">
Open a new Google Sheet and paste to fill it.
</p>
<div className="flex justify-end">
<button
type="button"
className="btn btn-primary btn-sm gap-1.5"
onClick={handleOpenSheet}
>
Open new Google Sheet
<ExternalLink className="size-3.5" />
</button>
</div>
</Modal>
);
}

View File

@ -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 ?? "",
]),
// 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),
);
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,
);
};
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,6 +306,13 @@ function CitationTabsCard({ result }: { result: BrandLookupResult }) {
</button>
</div>
<div className="flex items-center gap-2">
<ExportToSheetsButton
headers={exportTable.headers}
rows={exportTable.rows}
feature={`brand_lookup_${activeTab}`}
className="btn-sm"
/>
<button
type="button"
className="btn btn-ghost btn-sm gap-1.5"
@ -343,6 +324,7 @@ function CitationTabsCard({ result }: { result: BrandLookupResult }) {
Export CSV
</button>
</div>
</div>
<div className="flex items-center gap-2 border-b border-base-300 px-4 py-2">
<button
@ -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);
}

View File

@ -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);
}

View File

@ -210,7 +210,7 @@ function PerformanceRow({
export function ExportDropdown({
onExport,
}: {
onExport: (format: "csv" | "json") => void;
onExport: (format: "csv" | "json" | "sheets") => void;
}) {
return (
<div className="dropdown dropdown-end">
@ -221,8 +221,13 @@ export function ExportDropdown({
</div>
<ul
tabIndex={0}
className="dropdown-content z-10 menu p-2 shadow-lg bg-base-100 border border-base-300 rounded-box w-40"
className="dropdown-content z-10 menu p-2 shadow-lg bg-base-100 border border-base-300 rounded-box w-52"
>
<li>
<button onClick={() => onExport("sheets")}>
Export to Google Sheets
</button>
</li>
<li>
<button onClick={() => onExport("csv")}>CSV</button>
</li>

View File

@ -129,7 +129,7 @@ function ResultsHeader({
hasPerformanceTab: boolean;
activeTab: string;
setSearchParams: SearchSetter;
onExport: (format: "csv" | "json") => void;
onExport: (format: "csv" | "json" | "sheets") => void;
}) {
return (
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-3">

View File

@ -1,5 +1,6 @@
import type { AuditResultsData } from "@/client/features/audit/results/types";
import { buildCsv, downloadCsv } from "@/client/lib/csv";
import { buildCsv, type CsvValue, downloadCsv } from "@/client/lib/csv";
import { exportTableToSheets } from "@/client/lib/exportToSheets";
function downloadFile(content: string, filename: string, mime: string) {
const blob = new Blob([content], { type: `${mime};charset=utf-8;` });
@ -11,11 +12,68 @@ function downloadFile(content: string, filename: string, mime: string) {
URL.revokeObjectURL(url);
}
const PAGES_HEADERS = [
"URL",
"Status",
"Title",
"H1",
"Words",
"Images",
"Missing Alt",
"Response Time (ms)",
];
function pagesRows(pages: AuditResultsData["pages"]): CsvValue[][] {
return pages.map((page) => [
page.url,
page.statusCode,
page.title ?? "",
page.h1Count,
page.wordCount,
page.imagesTotal,
page.imagesMissingAlt,
page.responseTimeMs,
]);
}
const PERFORMANCE_HEADERS = [
"URL",
"Device",
"Performance",
"Accessibility",
"SEO",
"LCP (ms)",
"CLS",
"INP (ms)",
"TTFB (ms)",
];
function performanceRows(
lighthouse: AuditResultsData["lighthouse"],
pages: AuditResultsData["pages"],
): CsvValue[][] {
return lighthouse.map((result) => {
const page = pages.find((candidate) => candidate.id === result.pageId);
return [
page?.url ?? "",
result.strategy,
result.performanceScore,
result.accessibilityScore,
result.seoScore,
result.lcpMs,
result.cls,
result.inpMs,
result.ttfbMs,
];
});
}
export function exportPages(
pages: AuditResultsData["pages"],
format: "csv" | "json",
format: "csv" | "json" | "sheets",
) {
const rows = pages.map((page: AuditResultsData["pages"][number]) => ({
if (format === "json") {
const rows = pages.map((page) => ({
url: page.url,
statusCode: page.statusCode,
title: page.title ?? "",
@ -25,8 +83,6 @@ export function exportPages(
imagesMissingAlt: page.imagesMissingAlt,
responseTimeMs: page.responseTimeMs,
}));
if (format === "json") {
downloadFile(
JSON.stringify(rows, null, 2),
"audit-pages.json",
@ -35,41 +91,26 @@ export function exportPages(
return;
}
const headers = [
"URL",
"Status",
"Title",
"H1",
"Words",
"Images",
"Missing Alt",
"Response Time (ms)",
];
const lines = rows.map((row: (typeof rows)[number]) => [
row.url,
row.statusCode,
row.title,
row.h1Count,
row.wordCount,
row.imagesTotal,
row.imagesMissingAlt,
row.responseTimeMs,
]);
if (format === "sheets") {
void exportTableToSheets({
headers: PAGES_HEADERS,
rows: pagesRows(pages),
feature: "audit_pages",
});
return;
}
downloadCsv("audit-pages.csv", buildCsv(headers, lines));
downloadCsv("audit-pages.csv", buildCsv(PAGES_HEADERS, pagesRows(pages)));
}
export function exportPerformance(
lighthouse: AuditResultsData["lighthouse"],
pages: AuditResultsData["pages"],
format: "csv" | "json",
format: "csv" | "json" | "sheets",
) {
const rows = lighthouse.map(
(result: AuditResultsData["lighthouse"][number]) => {
const page = pages.find(
(candidate: AuditResultsData["pages"][number]) =>
candidate.id === result.pageId,
);
if (format === "json") {
const rows = lighthouse.map((result) => {
const page = pages.find((candidate) => candidate.id === result.pageId);
return {
url: page?.url ?? "",
strategy: result.strategy,
@ -81,10 +122,7 @@ export function exportPerformance(
inpMs: result.inpMs,
ttfbMs: result.ttfbMs,
};
},
);
if (format === "json") {
});
downloadFile(
JSON.stringify(rows, null, 2),
"audit-performance.json",
@ -93,28 +131,16 @@ export function exportPerformance(
return;
}
const headers = [
"URL",
"Device",
"Performance",
"Accessibility",
"SEO",
"LCP (ms)",
"CLS",
"INP (ms)",
"TTFB (ms)",
];
const lines = rows.map((row: (typeof rows)[number]) => [
row.url,
row.strategy,
row.performance,
row.accessibility,
row.seo,
row.lcpMs,
row.cls,
row.inpMs,
row.ttfbMs,
]);
const rows = performanceRows(lighthouse, pages);
downloadCsv("audit-performance.csv", buildCsv(headers, lines));
if (format === "sheets") {
void exportTableToSheets({
headers: PERFORMANCE_HEADERS,
rows,
feature: "audit_performance",
});
return;
}
downloadCsv("audit-performance.csv", buildCsv(PERFORMANCE_HEADERS, rows));
}

View File

@ -1,5 +1,7 @@
import { useMemo } from "react";
import { HeaderHelpLabel } from "@/client/features/keywords/components";
import { ArrowLeft, Download, SlidersHorizontal } from "lucide-react";
import { ExportToSheetsButton } from "@/client/components/table/ExportToSheetsButton";
import {
BacklinksNewLostChart,
BacklinksTrendChart,
@ -16,7 +18,7 @@ import {
TAB_DESCRIPTIONS,
formatRelativeTimestamp,
} from "./backlinksPageUtils";
import { exportBacklinksTabCsv } from "./export";
import { buildBacklinksTabExport, exportBacklinksTabCsv } from "./export";
import type { BacklinksFiltersState } from "./useBacklinksFilters";
export function BacklinksOverviewPanels({
@ -82,6 +84,10 @@ export function BacklinksResultsCard({
exportTarget: string;
}) {
const currentFilterCount = filters[activeTab].activeFilterCount;
const exportTable = useMemo(
() => buildBacklinksTabExport({ tab: activeTab, rows: filteredData }),
[activeTab, filteredData],
);
return (
<div className="border border-base-300 rounded-xl bg-base-100 overflow-hidden">
@ -115,6 +121,13 @@ export function BacklinksResultsCard({
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<ExportToSheetsButton
headers={exportTable.headers}
rows={exportTable.rows}
feature={`backlinks_${activeTab}`}
className="btn-sm"
/>
<button
className="btn btn-sm btn-ghost justify-start lg:justify-center"
onClick={() =>
@ -129,6 +142,7 @@ export function BacklinksResultsCard({
Export CSV
</button>
</div>
</div>
<div className="flex items-center gap-2 px-4 py-2 border-b border-base-300">
<button

View File

@ -1,4 +1,4 @@
import { buildCsv, downloadCsv } from "@/client/lib/csv";
import { buildCsv, type CsvValue, downloadCsv } from "@/client/lib/csv";
import type {
BacklinksOverviewData,
BacklinksSearchState,
@ -10,15 +10,15 @@ type BacklinksFilteredData = {
topPages: BacklinksOverviewData["topPages"];
};
export function buildBacklinksTabCsvFile(args: {
export function buildBacklinksTabExport(args: {
tab: BacklinksSearchState["tab"];
target: string;
rows: BacklinksFilteredData;
}) {
const { tab, target, rows } = args;
}): { headers: string[]; rows: CsvValue[][] } {
const { tab, rows } = args;
if (tab === "backlinks") {
const headers = [
return {
headers: [
"Domain",
"Source URL",
"Target URL",
@ -35,8 +35,8 @@ export function buildBacklinksTabCsvFile(args: {
"Lost",
"Broken",
"Links Count",
];
const lines = rows.backlinks.map((row) => [
],
rows: rows.backlinks.map((row) => [
row.domainFrom,
row.urlFrom,
row.urlTo,
@ -53,16 +53,13 @@ export function buildBacklinksTabCsvFile(args: {
row.isLost,
row.isBroken,
row.linksCount,
]);
return {
filename: buildFilename("backlinks", target),
content: buildCsv(headers, lines),
]),
};
}
if (tab === "domains") {
const headers = [
return {
headers: [
"Domain",
"Backlinks",
"Referring Pages",
@ -71,8 +68,8 @@ export function buildBacklinksTabCsvFile(args: {
"First Seen",
"Broken Backlinks",
"Broken Pages",
];
const lines = rows.referringDomains.map((row) => [
],
rows: rows.referringDomains.map((row) => [
row.domain,
row.backlinks,
row.referringPages,
@ -81,32 +78,47 @@ export function buildBacklinksTabCsvFile(args: {
row.firstSeen,
row.brokenBacklinks,
row.brokenPages,
]);
return {
filename: buildFilename("referring-domains", target),
content: buildCsv(headers, lines),
]),
};
}
const headers = [
return {
headers: [
"Page",
"Backlinks",
"Referring Domains",
"Rank",
"Broken Backlinks",
];
const lines = rows.topPages.map((row) => [
],
rows: rows.topPages.map((row) => [
row.page,
row.backlinks,
row.referringDomains,
row.rank,
row.brokenBacklinks,
]);
]),
};
}
export function buildBacklinksTabCsvFile(args: {
tab: BacklinksSearchState["tab"];
target: string;
rows: BacklinksFilteredData;
}) {
const { headers, rows } = buildBacklinksTabExport({
tab: args.tab,
rows: args.rows,
});
const filenamePrefix =
args.tab === "backlinks"
? "backlinks"
: args.tab === "domains"
? "referring-domains"
: "top-pages";
return {
filename: buildFilename("top-pages", target),
content: buildCsv(headers, lines),
filename: buildFilename(filenamePrefix, args.target),
content: buildCsv(headers, rows),
};
}

View File

@ -6,6 +6,7 @@ import {
FileSpreadsheet,
Save,
Search,
Sheet,
SlidersHorizontal,
} from "lucide-react";
import { toast } from "sonner";
@ -13,11 +14,9 @@ import { DomainFilterPanel } from "@/client/features/domain/components/DomainFil
import { DomainKeywordsTable } from "@/client/features/domain/components/DomainKeywordsTable";
import { DomainPagesTable } from "@/client/features/domain/components/DomainPagesTable";
import type { useDomainFilters } from "@/client/features/domain/hooks/useDomainFilters";
import {
downloadCsv,
keywordsToCsv,
pagesToCsv,
} from "@/client/features/domain/utils";
import { keywordsToTable, pagesToTable } from "@/client/features/domain/utils";
import { buildCsv, downloadCsv } from "@/client/lib/csv";
import { exportTableToSheets } from "@/client/lib/exportToSheets";
import { captureClientEvent } from "@/client/lib/posthog";
import type {
DomainActiveTab,
@ -75,8 +74,11 @@ export function DomainResultsCard({
onToggleKeyword,
onToggleAllVisible,
}: Props) {
const currentRows =
activeTab === "keywords" ? filteredKeywords : filteredPages;
const isKeywordsTab = activeTab === "keywords";
const currentRows = isKeywordsTab ? filteredKeywords : filteredPages;
const exportTable = isKeywordsTab
? keywordsToTable(filteredKeywords)
: pagesToTable(filteredPages);
const handleCopy = async () => {
const text = JSON.stringify(currentRows, null, 2);
@ -84,12 +86,19 @@ export function DomainResultsCard({
toast.success("Copied data");
};
const handleExportToSheets = () => {
void exportTableToSheets({
headers: exportTable.headers,
rows: exportTable.rows,
feature: "domain_overview",
});
};
const handleDownload = (extension: "csv" | "xls") => {
const rows =
activeTab === "keywords"
? keywordsToCsv(filteredKeywords)
: pagesToCsv(filteredPages);
downloadCsv(rows, `${overview.domain}-${activeTab}.${extension}`);
downloadCsv(
`${overview.domain}-${activeTab}.${extension}`,
buildCsv(exportTable.headers, exportTable.rows),
);
if (extension === "csv") {
captureClientEvent("data:export", {
@ -99,8 +108,6 @@ export function DomainResultsCard({
}
};
const isKeywordsTab = activeTab === "keywords";
return (
<div className="border border-base-300 rounded-xl bg-base-100 overflow-hidden">
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-3 px-4 py-3 border-b border-base-300">
@ -144,12 +151,18 @@ export function DomainResultsCard({
</div>
<ul
tabIndex={0}
className="dropdown-content z-10 menu p-2 shadow-lg bg-base-100 border border-base-300 rounded-box w-48"
className="dropdown-content z-10 menu p-2 shadow-lg bg-base-100 border border-base-300 rounded-box w-56"
>
<li>
<button onClick={handleExportToSheets}>
<Sheet className="size-4" />
Export to Google Sheets
</button>
</li>
<li>
<button onClick={handleCopy}>
<Copy className="size-4" />
Copy data
Copy data (JSON)
</button>
</li>
<li>

View File

@ -4,7 +4,6 @@ import type {
PageRow,
SortOrder,
} from "@/client/features/domain/types";
import { buildCsv, downloadCsv as downloadCsvFile } from "@/client/lib/csv";
export function toSortMode(value: string | null): DomainSortMode | undefined {
if (
@ -101,17 +100,12 @@ export function formatMetric(
return formatNumber(value);
}
export function keywordsToCsv(rows: KeywordRow[]): string {
const headers = [
"Keyword",
"Rank",
"Volume",
"Traffic",
"CPC",
"URL",
"Score",
];
const lines = rows.map((row) => [
type ExportTable = { headers: string[]; rows: (string | number | null)[][] };
export function keywordsToTable(rows: KeywordRow[]): ExportTable {
return {
headers: ["Keyword", "Rank", "Volume", "Traffic", "CPC", "URL", "Score"],
rows: rows.map((row) => [
row.keyword,
row.position,
row.searchVolume,
@ -119,22 +113,19 @@ export function keywordsToCsv(rows: KeywordRow[]): string {
row.cpc,
row.relativeUrl ?? row.url,
row.keywordDifficulty,
]);
return buildCsv(headers, lines);
]),
};
}
export function pagesToCsv(rows: PageRow[]): string {
const headers = ["Page", "Organic Traffic", "Keywords"];
const lines = rows.map((row) => [
export function pagesToTable(rows: PageRow[]): ExportTable {
return {
headers: ["Page", "Organic Traffic", "Keywords"],
rows: rows.map((row) => [
row.relativePath ?? row.page,
row.organicTraffic,
row.keywords,
]);
return buildCsv(headers, lines);
}
export function downloadCsv(content: string, filename: string) {
downloadCsvFile(filename, content);
]),
};
}
export function resolveDomainPageHref(

View File

@ -6,6 +6,7 @@ import {
TrendingDown,
TrendingUp,
} from "lucide-react";
import { ExportToSheetsButton } from "@/client/components/table/ExportToSheetsButton";
import type { SerpResultItem } from "@/types/keywords";
import { formatNumber } from "../utils";
@ -48,9 +49,34 @@ export function SerpAnalysisCard({
return (
<div>
<div className="text-xs text-base-content/50 mb-3">
<div className="flex items-center justify-between mb-3">
<div className="text-xs text-base-content/50">
{items.length} organic results
</div>
<ExportToSheetsButton
headers={[
"Rank",
"Title",
"URL",
"Domain",
"Traffic",
"Referring Domains",
"Backlinks",
"Rank Change",
]}
rows={items.map((item) => [
item.rank,
item.title ?? "",
item.url,
item.domain,
item.etv ?? "",
item.referringDomains ?? "",
item.backlinks ?? "",
item.isNew ? "new" : (item.rankChange ?? ""),
])}
feature="serp_analysis"
/>
</div>
<SerpAnalysisTable items={pageItems} />
<SerpAnalysisPagination
page={page}

View File

@ -5,6 +5,8 @@ import {
Save,
SlidersHorizontal,
} from "lucide-react";
import { ExportToSheetsButton } from "@/client/components/table/ExportToSheetsButton";
import { KEYWORD_RESEARCH_HEADERS } from "@/client/features/keywords/state/keywordControllerActions";
import {
AreaTrendChart,
KeywordRow,
@ -103,8 +105,14 @@ function DesktopKeywordPanel({ controller }: Props) {
}
function DesktopTableCard({ controller }: Props) {
const { activeFilterCount, filteredRows, rows, selectedRows, showFilters } =
controller;
const {
activeFilterCount,
filteredRows,
rows,
selectedRows,
sheetsExportRows,
showFilters,
} = controller;
const keywordCountLabel =
selectedRows.size > 0
@ -141,13 +149,19 @@ function DesktopTableCard({ controller }: Props) {
<Save className="size-3.5" />
<span className="hidden lg:inline">Save Keywords</span>
</button>
<ExportToSheetsButton
headers={KEYWORD_RESEARCH_HEADERS}
rows={sheetsExportRows}
feature="keyword_research"
className="btn-sm"
/>
<button
className="btn btn-ghost btn-sm gap-1"
onClick={controller.exportCsv}
disabled={filteredRows.length === 0}
>
<FileDown className="size-3.5" />
<span className="hidden lg:inline">Export</span>
<span className="hidden lg:inline">Export CSV</span>
</button>
</div>

View File

@ -1,4 +1,6 @@
import { FileDown, RotateCcw, Save, SlidersHorizontal } from "lucide-react";
import { ExportToSheetsButton } from "@/client/components/table/ExportToSheetsButton";
import { KEYWORD_RESEARCH_HEADERS } from "@/client/features/keywords/state/keywordControllerActions";
import {
KeywordCard,
SerpAnalysisCard,
@ -58,8 +60,14 @@ export function KeywordResearchMobileResults({ controller }: Props) {
}
function MobileKeywordCards({ controller }: Props) {
const { activeFilterCount, filteredRows, rows, selectedRows, showFilters } =
controller;
const {
activeFilterCount,
filteredRows,
rows,
selectedRows,
sheetsExportRows,
showFilters,
} = controller;
const keywordCountLabel =
selectedRows.size > 0
@ -105,10 +113,17 @@ function MobileKeywordCards({ controller }: Props) {
>
<Save className="size-3.5" />
</button>
<ExportToSheetsButton
headers={KEYWORD_RESEARCH_HEADERS}
rows={sheetsExportRows}
feature="keyword_research"
iconOnly
/>
<button
className="btn btn-ghost btn-xs"
onClick={controller.exportCsv}
disabled={filteredRows.length === 0}
title="Download CSV"
>
<FileDown className="size-3.5" />
</button>

View File

@ -1,5 +1,6 @@
import { useMemo } from "react";
import { toast } from "sonner";
import { buildCsv, downloadCsv } from "@/client/lib/csv";
import { buildCsv, type CsvValue, downloadCsv } from "@/client/lib/csv";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { captureClientEvent } from "@/client/lib/posthog";
import { getLanguageCode } from "@/client/features/keywords/utils";
@ -8,6 +9,26 @@ import type { SaveKeywordsInput } from "@/types/schemas/keywords";
import type { SortDir, SortField } from "@/client/features/keywords/components";
import type { KeywordResearchControllerInput } from "./useKeywordResearchController";
export const KEYWORD_RESEARCH_HEADERS = [
"Keyword",
"Volume",
"CPC",
"Competition",
"Score",
"Intent",
];
function keywordResearchRow(row: KeywordResearchRow): CsvValue[] {
return [
row.keyword,
row.searchVolume ?? "",
row.cpc ?? "",
row.competition ?? "",
row.keywordDifficulty ?? "",
row.intent,
];
}
type SaveExportActionParams = {
selectedRows: Set<string>;
rows: KeywordResearchRow[];
@ -100,38 +121,37 @@ export function useSaveAndExportActions(params: SaveExportActionParams) {
);
};
const exportCsv = () => {
const source =
selectedRows.size > 0
const sheetsExportRows: CsvValue[][] = useMemo(
() =>
(selectedRows.size > 0
? filteredRows.filter((row) => selectedRows.has(row.keyword))
: filteredRows;
if (source.length === 0) {
: filteredRows
).map(keywordResearchRow),
[filteredRows, selectedRows],
);
const exportCsv = () => {
if (sheetsExportRows.length === 0) {
toast.error("No data to export");
return;
}
const headers = [
"Keyword",
"Volume",
"CPC",
"Competition",
"Difficulty",
"Intent",
];
const csvRows = source.map((row) => [
row.keyword,
row.searchVolume ?? "",
row.cpc?.toFixed(2) ?? "",
row.competition?.toFixed(2) ?? "",
row.keywordDifficulty ?? "",
row.intent,
]);
const csv = buildCsv(headers, csvRows);
downloadCsv("keyword-research.csv", csv);
// CSV file keeps cents-formatted CPC/competition for human readability.
const csvRows = sheetsExportRows.map((row) =>
row.map((cell, idx) =>
(idx === 2 || idx === 3) && typeof cell === "number"
? cell.toFixed(2)
: cell,
),
);
downloadCsv(
"keyword-research.csv",
buildCsv(KEYWORD_RESEARCH_HEADERS, csvRows),
);
captureClientEvent("data:export", {
source_feature: "keyword_research",
result_count: source.length,
result_count: sheetsExportRows.length,
});
};
return { handleSaveKeywords, confirmSave, exportCsv };
return { handleSaveKeywords, confirmSave, exportCsv, sheetsExportRows };
}

View File

@ -75,7 +75,7 @@ export function useKeywordResearchController(
[input.sortDir, input.sortField, setSearchParams],
);
const { handleSaveKeywords, confirmSave, exportCsv } =
const { handleSaveKeywords, confirmSave, exportCsv, sheetsExportRows } =
useSaveAndExportActions({
selectedRows: state.selectedRows,
rows: state.rows,
@ -114,6 +114,7 @@ export function useKeywordResearchController(
confirmSave,
controlsForm: state.controlsForm,
exportCsv,
sheetsExportRows,
filteredRows: state.filteredRows,
filtersForm: state.filtersForm,
handleRowClick,

View File

@ -4,6 +4,7 @@ import {
Download,
FileWarning,
Info,
Sheet,
TriangleAlert,
} from "lucide-react";
import type {
@ -88,6 +89,7 @@ export function LighthouseIssuesToolbar({
onCopy,
onExport,
onExportCsv,
onExportSheets,
}: {
category: CategoryTab;
categoryCounts: Record<CategoryTab, number>;
@ -99,6 +101,10 @@ export function LighthouseIssuesToolbar({
onCopy: (data: ExportPayload, toastMessage: string) => void;
onExport: (data: ExportPayload) => void;
onExportCsv: (issues: LighthouseIssue[], variant: "all" | "current") => void;
onExportSheets: (
issues: LighthouseIssue[],
variant: "all" | "current",
) => void;
}) {
const exportCurrentCategory: ExportPayload =
category === "all" ? { mode: "issues" } : { mode: "category", category };
@ -121,6 +127,7 @@ export function LighthouseIssuesToolbar({
onCopy={onCopy}
onExport={onExport}
onExportCsv={onExportCsv}
onExportSheets={onExportSheets}
visibleIssues={visibleIssues}
/>
</div>
@ -167,6 +174,7 @@ function ExportMenu({
onCopy,
onExport,
onExportCsv,
onExportSheets,
visibleIssues,
}: {
allIssues: LighthouseIssue[];
@ -176,6 +184,10 @@ function ExportMenu({
onCopy: (data: ExportPayload, toastMessage: string) => void;
onExport: (data: ExportPayload) => void;
onExportCsv: (issues: LighthouseIssue[], variant: "all" | "current") => void;
onExportSheets: (
issues: LighthouseIssue[],
variant: "all" | "current",
) => void;
visibleIssues: LighthouseIssue[];
}) {
return (
@ -189,6 +201,27 @@ function ExportMenu({
tabIndex={0}
className="dropdown-content z-10 menu p-2 shadow-lg bg-base-100 border border-base-300 rounded-box w-72"
>
<li className="menu-title">
<span>Export to Google Sheets</span>
</li>
<li>
<button
disabled={!visibleIssues.length}
onClick={() => onExportSheets(visibleIssues, "current")}
>
<Sheet className="size-4" />
Open in Sheets {categoryLabelLower}
</button>
</li>
<li>
<button
disabled={!allIssues.length}
onClick={() => onExportSheets(allIssues, "all")}
>
<Sheet className="size-4" />
Open in Sheets all actionable
</button>
</li>
<li className="menu-title">
<span>Copy</span>
</li>

View File

@ -5,12 +5,14 @@ import {
exportAuditLighthouseIssues,
getAuditLighthouseIssues,
} from "@/serverFunctions/lighthouse";
import { exportTableToSheets } from "@/client/lib/exportToSheets";
import type { CategoryTab, ExportPayload, LighthouseIssue } from "./types";
import {
categoryLabel,
categorySlug,
downloadTextFile,
issuesToCsv,
issuesToTable,
} from "./utils";
import {
LighthouseIssueList,
@ -62,6 +64,7 @@ export function LighthouseIssuesScreen(props: LighthouseIssuesScreenProps) {
runCopy,
runExport,
runExportCsv,
runExportSheets,
selectedCategoryLabel,
severityCounts,
visibleIssues,
@ -129,6 +132,7 @@ export function LighthouseIssuesScreen(props: LighthouseIssuesScreenProps) {
void runExport(data);
}}
onExportCsv={runExportCsv}
onExportSheets={runExportSheets}
/>
<LighthouseIssueList
issues={visibleIssues}
@ -184,6 +188,18 @@ function useLighthouseIssuesActions({
toast.success("CSV download started");
};
const runExportSheets = (
rows: LighthouseIssue[],
variant: "all" | "current",
) => {
const table = issuesToTable(rows);
void exportTableToSheets({
headers: table.headers,
rows: table.rows,
feature: `lighthouse_issues_${variant}`,
});
};
const runCopy = async (data: ExportPayload, toastMessage: string) => {
try {
const exported = await exportMutation.mutateAsync(data);
@ -202,6 +218,7 @@ function useLighthouseIssuesActions({
runCopy,
runExport,
runExportCsv,
runExportSheets,
selectedCategoryLabel,
severityCounts,
visibleIssues,

View File

@ -1,6 +1,36 @@
import { buildCsv } from "@/client/lib/csv";
import { buildCsv, type CsvValue } from "@/client/lib/csv";
import type { CategoryTab, LighthouseIssue } from "./types";
const ISSUE_HEADERS = [
"Category",
"Severity",
"Score",
"Title",
"Display Value",
"Description",
"Impact (ms)",
"Impact (bytes)",
"Affected Items",
];
function issuesToRows(issues: LighthouseIssue[]): CsvValue[][] {
return issues.map((issue) => [
issue.category,
issue.severity,
issue.score ?? "",
issue.title,
issue.displayValue ?? "",
issue.description ?? "",
issue.impactMs ?? "",
issue.impactBytes ?? "",
issue.items.length,
]);
}
export function issuesToTable(issues: LighthouseIssue[]) {
return { headers: ISSUE_HEADERS, rows: issuesToRows(issues) };
}
export function categoryLabel(category: CategoryTab) {
if (category === "best-practices") return "Best practices";
if (category === "all") return "All";
@ -12,31 +42,7 @@ export function categorySlug(category: CategoryTab) {
}
export function issuesToCsv(issues: LighthouseIssue[]) {
const headers = [
"Category",
"Severity",
"Score",
"Title",
"Display Value",
"Description",
"Impact (ms)",
"Impact (bytes)",
"Affected Items",
];
const rows = issues.map((issue) => [
issue.category,
issue.severity,
issue.score ?? "",
issue.title,
issue.displayValue ?? "",
issue.description ?? "",
issue.impactMs ?? "",
issue.impactBytes ?? "",
issue.items.length,
]);
return buildCsv(headers, rows);
return buildCsv(ISSUE_HEADERS, issuesToRows(issues));
}
export function downloadTextFile(

View File

@ -1,9 +1,17 @@
import { useState } from "react";
import { MoreHorizontal, Play, Download, Copy, RefreshCw } from "lucide-react";
import {
Copy,
Download,
MoreHorizontal,
Play,
RefreshCw,
Sheet,
} from "lucide-react";
export function ActionsMenu({
onCheckNow,
onExport,
onExportToSheets,
onCopyKeywords,
onRefreshMetrics,
isRunning,
@ -13,6 +21,7 @@ export function ActionsMenu({
}: {
onCheckNow: () => void;
onExport: () => void;
onExportToSheets: () => void;
onCopyKeywords: () => void;
onRefreshMetrics: () => void;
isRunning: boolean;
@ -59,6 +68,17 @@ export function ActionsMenu({
/>
{metricsRefreshing ? "Refreshing..." : "Refresh Metrics"}
</button>
<button
className="flex w-full items-center gap-2 px-3 py-2 text-sm hover:bg-base-200"
onClick={() => {
onExportToSheets();
setOpen(false);
}}
disabled={!hasData}
>
<Sheet className="size-3.5" />
Export to Google Sheets
</button>
<button
className="flex w-full items-center gap-2 px-3 py-2 text-sm hover:bg-base-200"
onClick={() => {

View File

@ -0,0 +1,24 @@
import { Link } from "@tanstack/react-router";
import { AlertTriangle } from "lucide-react";
import { SUBSCRIBE_ROUTE } from "@/shared/billing";
export function FreePlanAlert({ visible }: { visible: boolean }) {
if (!visible) return null;
return (
<div className="alert alert-warning text-sm py-2">
<AlertTriangle className="size-4" />
<span>
We only start to track keyword positions once you{" "}
<Link
to={SUBSCRIBE_ROUTE}
search={{ upgrade: true }}
className="link font-medium"
>
upgrade to the paid plan
</Link>
.
</span>
</div>
);
}

View File

@ -1,6 +1,5 @@
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { Link } from "@tanstack/react-router";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { AutumnProvider, useCustomer } from "autumn-js/react";
import {
@ -19,10 +18,13 @@ import {
} from "lucide-react";
import { useSession } from "@/lib/auth-client";
import { getCustomerPlanStatus } from "@/client/features/billing/plan-detection";
import { SUBSCRIBE_ROUTE } from "@/shared/billing";
import { captureClientEvent } from "@/client/lib/posthog";
import { FreePlanAlert } from "./FreePlanAlert";
import { RankTrackingTable } from "./RankTrackingTable";
import { exportRankTrackingCsv } from "./RankTrackingTableParts";
import {
exportRankTrackingCsv,
exportRankTrackingToSheets,
} from "./RankTrackingTableParts";
import type {
RankTrackingConfig,
ComparePeriod,
@ -67,27 +69,6 @@ export function RankTrackingDomainDetail(props: {
);
}
function FreePlanAlert({ visible }: { visible: boolean }) {
if (!visible) return null;
return (
<div className="alert alert-warning text-sm py-2">
<AlertTriangle className="size-4" />
<span>
We only start to track keyword positions once you{" "}
<Link
to={SUBSCRIBE_ROUTE}
search={{ upgrade: true }}
className="link font-medium"
>
upgrade to the paid plan
</Link>
.
</span>
</div>
);
}
function RankTrackingDomainDetailInner({
config,
projectId,
@ -360,7 +341,7 @@ function RankTrackingDomainDetailInner({
const count = costEstimate?.keywordCount ?? rows?.length ?? 0;
if (count > 0) requestCheck(count);
}}
onRefreshMetrics={() => refreshMetrics()}
onRefreshMetrics={refreshMetrics}
metricsRefreshing={metricsRefreshing}
onExport={() =>
exportRankTrackingCsv(
@ -370,9 +351,13 @@ function RankTrackingDomainDetailInner({
config.domain,
)
}
onExportToSheets={() =>
exportRankTrackingToSheets(filtered, showDesktop, showMobile)
}
onCopyKeywords={() => {
const text = filtered.map((r) => r.keyword).join("\n");
void navigator.clipboard.writeText(text);
void navigator.clipboard.writeText(
filtered.map((r) => r.keyword).join("\n"),
);
toast.success("Keywords copied to clipboard");
}}
isRunning={isBusy}

View File

@ -1,6 +1,7 @@
import { Sparkles } from "lucide-react";
import { toast } from "sonner";
import { buildCsv, downloadCsv } from "@/client/lib/csv";
import { exportTableToSheets } from "@/client/lib/exportToSheets";
import { captureClientEvent } from "@/client/lib/posthog";
import type {
RankTrackingDeviceResult,
@ -175,16 +176,11 @@ function csvChange(
return previous - current;
}
export function exportRankTrackingCsv(
function buildRankTrackingExport(
sorted: RankTrackingRow[],
showDesktop: boolean,
showMobile: boolean,
domain: string,
) {
if (sorted.length === 0) {
toast.error("No data to export");
return;
}
): { headers: string[]; rows: (string | number)[][] } {
const headers = [
"Keyword",
"Volume",
@ -207,14 +203,16 @@ export function exportRankTrackingCsv(
]
: []),
];
const csvRows = sorted.map((row) => [
// Emit empty cells (not "Not ranking" strings) so Sheets infers a numeric
// column type and the user can sort by position.
const rows = sorted.map((row) => [
row.keyword,
row.searchVolume ?? "",
row.keywordDifficulty ?? "",
row.cpc != null ? row.cpc.toFixed(2) : "",
row.cpc ?? "",
...(showDesktop
? [
row.desktop.position ?? "Not ranking",
row.desktop.position ?? "",
csvChange(row.desktop.position, row.desktop.previousPosition),
row.desktop.rankingUrl ?? "",
row.desktop.serpFeatures.join(", "),
@ -222,13 +220,51 @@ export function exportRankTrackingCsv(
: []),
...(showMobile
? [
row.mobile.position ?? "Not ranking",
row.mobile.position ?? "",
csvChange(row.mobile.position, row.mobile.previousPosition),
row.mobile.rankingUrl ?? "",
row.mobile.serpFeatures.join(", "),
]
: []),
]);
return { headers, rows };
}
export function exportRankTrackingToSheets(
sorted: RankTrackingRow[],
showDesktop: boolean,
showMobile: boolean,
) {
const { headers, rows } = buildRankTrackingExport(
sorted,
showDesktop,
showMobile,
);
void exportTableToSheets({ headers, rows, feature: "rank_tracking" });
}
export function exportRankTrackingCsv(
sorted: RankTrackingRow[],
showDesktop: boolean,
showMobile: boolean,
domain: string,
) {
if (sorted.length === 0) {
toast.error("No data to export");
return;
}
const { headers, rows } = buildRankTrackingExport(
sorted,
showDesktop,
showMobile,
);
// CSV file download keeps cents-formatted CPC for human readability;
// clipboard/Sheets export uses raw numbers (see buildRankTrackingExport).
const csvRows = rows.map((row) =>
row.map((cell, idx) =>
idx === 3 && typeof cell === "number" ? cell.toFixed(2) : cell,
),
);
downloadCsv(`rank-tracking-${domain}.csv`, buildCsv(headers, csvRows));
captureClientEvent("rank_tracking:export_csv");
}

View File

@ -0,0 +1,95 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { copyTableToClipboard } from "./clipboard";
type WrittenItem = {
plain: string;
html: string;
};
class FakeClipboardItem {
constructor(public types: Record<string, Promise<Blob> | Blob>) {}
async getType(type: string): Promise<Blob> {
return await this.types[type];
}
}
function mockClipboard() {
vi.stubGlobal("ClipboardItem", FakeClipboardItem);
const written: WrittenItem[] = [];
const writeMock = vi.fn(async (items: FakeClipboardItem[]) => {
for (const item of items) {
const plainBlob = await item.getType("text/plain");
const htmlBlob = await item.getType("text/html");
written.push({
plain: await plainBlob.text(),
html: await htmlBlob.text(),
});
}
});
vi.stubGlobal("navigator", { clipboard: { write: writeMock } });
return { written, writeMock };
}
describe("copyTableToClipboard", () => {
beforeEach(() => {
vi.unstubAllGlobals();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("emits TSV with tab/newline separators", async () => {
const { written } = mockClipboard();
await copyTableToClipboard(
["Keyword", "Volume"],
[
["seo audit", 1200],
["site speed", 800],
],
);
expect(written[0].plain).toBe(
"Keyword\tVolume\nseo audit\t1200\nsite speed\t800",
);
});
it("emits HTML with raw numeric cells (no comma formatting)", async () => {
const { written } = mockClipboard();
await copyTableToClipboard(["Volume"], [[1234]]);
expect(written[0].html).toContain("<td>1234</td>");
});
it("sanitizes formula-injection cells with a leading apostrophe", async () => {
const { written } = mockClipboard();
await copyTableToClipboard(["Keyword"], [['=HYPERLINK("evil")']]);
// OWASP guard prefixes the cell with `'` so Sheets/Excel treat it as text.
expect(written[0].plain).toContain("'=HYPERLINK");
expect(written[0].html).toMatch(/<td>'=HYPERLINK/);
});
it("escapes HTML special characters in string cells", async () => {
const { written } = mockClipboard();
await copyTableToClipboard(["Title"], [['<script>alert("x")</script>']]);
expect(written[0].html).not.toContain("<script>");
expect(written[0].html).toContain("&lt;script&gt;");
});
it("flattens tabs and newlines inside string cells", async () => {
const { written } = mockClipboard();
await copyTableToClipboard(["Body"], [["one\ttwo\nthree"]]);
// Single space replaces both \t and \n so the row stays one line in TSV.
expect(written[0].plain).toBe("Body\none two three");
});
it("treats null and undefined as empty cells", async () => {
const { written } = mockClipboard();
await copyTableToClipboard(["A", "B"], [[null, undefined]]);
expect(written[0].plain).toBe("A\tB\n\t");
});
it("throws when the Clipboard API is unavailable", async () => {
vi.stubGlobal("navigator", {});
await expect(copyTableToClipboard(["X"], [["y"]])).rejects.toThrow(
/Clipboard API not available/,
);
});
});

View File

@ -0,0 +1,72 @@
import { sanitizeCsvValue, type CsvValue } from "./csv";
export const GOOGLE_SHEETS_NEW_URL = "https://sheets.new";
export async function copyTableToClipboard(
headers: string[],
rows: CsvValue[][],
): Promise<void> {
if (typeof navigator === "undefined" || !navigator.clipboard?.write) {
throw new Error("Clipboard API not available in this browser.");
}
const safeRows = rows.map((row) =>
row.map((value) => sanitizeCsvValue(value ?? "")),
);
const tsv = buildTsv(headers, safeRows);
const html = buildHtmlTable(headers, safeRows);
// Wrap blobs in Promise.resolve for Safari <17.4 compatibility — older
// Safari requires Promise<Blob> values; modern browsers accept either.
await navigator.clipboard.write([
new ClipboardItem({
"text/plain": Promise.resolve(new Blob([tsv], { type: "text/plain" })),
"text/html": Promise.resolve(new Blob([html], { type: "text/html" })),
}),
]);
}
function buildTsv(
headers: string[],
rows: (string | number | boolean)[][],
): string {
const lines = [headers.map(tsvCell).join("\t")];
for (const row of rows) {
lines.push(row.map(tsvCell).join("\t"));
}
return lines.join("\n");
}
function tsvCell(value: string | number | boolean): string {
if (typeof value !== "string") return String(value);
return value.replace(/[\t\r\n]+/g, " ");
}
function buildHtmlTable(
headers: string[],
rows: (string | number | boolean)[][],
): string {
const thead = `<thead><tr>${headers.map((h) => `<th>${escapeHtml(h)}</th>`).join("")}</tr></thead>`;
const tbody = `<tbody>${rows
.map(
(row) =>
`<tr>${row.map((cell) => `<td>${escapeHtmlCell(cell)}</td>`).join("")}</tr>`,
)
.join("")}</tbody>`;
return `<table>${thead}${tbody}</table>`;
}
function escapeHtmlCell(value: string | number | boolean): string {
if (typeof value === "number" || typeof value === "boolean")
return String(value);
return escapeHtml(value);
}
function escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}

View File

@ -1,6 +1,6 @@
import Papa from "papaparse";
type CsvValue = string | number | boolean | null | undefined;
export type CsvValue = string | number | boolean | null | undefined;
export function buildCsv(headers: string[], rows: CsvValue[][]): string {
const normalizedRows = rows.map((row) =>
@ -19,10 +19,10 @@ export function buildCsv(headers: string[], rows: CsvValue[][]): string {
);
}
// Prevent CSV injection (formula injection) by prefixing dangerous characters
// with a single quote. See OWASP guidance:
// Prevent CSV/TSV injection (formula injection) by prefixing dangerous
// characters with a single quote. See OWASP guidance:
// https://owasp.org/www-community/attacks/CSV_Injection
function sanitizeCsvValue(
export function sanitizeCsvValue(
value: string | number | boolean,
): string | number | boolean {
if (typeof value !== "string" || value.length === 0) {

View File

@ -0,0 +1,69 @@
import { useSyncExternalStore } from "react";
import { toast } from "sonner";
import {
copyTableToClipboard,
GOOGLE_SHEETS_NEW_URL,
} from "@/client/lib/clipboard";
import type { CsvValue } from "@/client/lib/csv";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { captureClientEvent } from "@/client/lib/posthog";
type ModalState = { isOpen: false } | { isOpen: true; rowCount: number };
const listeners = new Set<() => void>();
let state: ModalState = { isOpen: false };
function emit() {
for (const listener of listeners) listener();
}
function setState(next: ModalState) {
state = next;
emit();
}
export function useExportToSheetsModalState(): ModalState {
return useSyncExternalStore(
(listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
() => state,
() => state,
);
}
export function closeExportToSheetsModal() {
setState({ isOpen: false });
}
export function openGoogleSheetsTab() {
window.open(GOOGLE_SHEETS_NEW_URL, "_blank", "noopener,noreferrer");
}
/**
* Copy a table to the clipboard and open the "paste into a new Google Sheet"
* modal. The modal handles opening sheets.new on user click we don't auto-
* redirect because users wouldn't realize the data was on their clipboard.
*/
export async function exportTableToSheets(args: {
headers: string[];
rows: CsvValue[][];
feature: string;
}) {
const { headers, rows, feature } = args;
if (rows.length === 0) {
toast.error("No data to export");
return;
}
try {
await copyTableToClipboard(headers, rows);
captureClientEvent("data:export_sheets", {
source_feature: feature,
result_count: rows.length,
});
setState({ isOpen: true, rowCount: rows.length });
} catch (error) {
toast.error(getStandardErrorMessage(error, "Could not copy to clipboard"));
}
}

View File

@ -11,6 +11,7 @@ import { TanStackDevtools } from "@tanstack/react-devtools";
import { QueryClientProvider } from "@tanstack/react-query";
import * as React from "react";
import { DefaultCatchBoundary } from "@/client/components/DefaultCatchBoundary";
import { ExportToSheetsModal } from "@/client/components/table/ExportToSheetsModal";
import { themePreferenceInitScript } from "@/client/lib/theme";
import {
identifyAnalyticsUser,
@ -128,6 +129,7 @@ function RootDocument({ children }: { children: React.ReactNode }) {
<>
<PostHogBootstrap />
{children}
<ExportToSheetsModal />
<Toaster position="bottom-right" mobileOffset={{ bottom: 100 }} />
{showDevtools ? (
<TanStackDevtools

View File

@ -14,7 +14,9 @@ import {
Trash2,
Copy,
} from "lucide-react";
import { buildCsv, downloadCsv } from "@/client/lib/csv";
import { ExportToSheetsButton } from "@/client/components/table/ExportToSheetsButton";
import { KEYWORD_RESEARCH_HEADERS } from "@/client/features/keywords/state/keywordControllerActions";
import { buildCsv, type CsvValue, downloadCsv } from "@/client/lib/csv";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { captureClientEvent } from "@/client/lib/posthog";
@ -106,34 +108,34 @@ function SavedKeywordsPage() {
}
};
const exportCsv = () => {
if (savedKeywords.length === 0) {
toast.error("No keywords to export");
return;
}
const headers = [
"Keyword",
"Volume",
"CPC",
"Competition",
"Difficulty",
"Intent",
"Fetched At",
];
const csvRows = savedKeywords.map((kw) => [
const savedHeaders = [...KEYWORD_RESEARCH_HEADERS, "Fetched At"];
const sheetsExportRows: CsvValue[][] = savedKeywords.map((kw) => [
kw.keyword,
kw.searchVolume ?? "",
kw.cpc?.toFixed(2) ?? "",
kw.competition?.toFixed(2) ?? "",
kw.cpc ?? "",
kw.competition ?? "",
kw.keywordDifficulty ?? "",
kw.intent ?? "",
kw.fetchedAt ?? "",
]);
const csv = buildCsv(headers, csvRows);
downloadCsv("saved-keywords.csv", csv);
const exportCsv = () => {
if (sheetsExportRows.length === 0) {
toast.error("No keywords to export");
return;
}
// CSV file keeps cents-formatted CPC/competition for human readability.
const csvRows = sheetsExportRows.map((row) =>
row.map((cell, idx) =>
(idx === 2 || idx === 3) && typeof cell === "number"
? cell.toFixed(2)
: cell,
),
);
downloadCsv("saved-keywords.csv", buildCsv(savedHeaders, csvRows));
captureClientEvent("data:export", {
source_feature: "saved_keywords",
result_count: savedKeywords.length,
result_count: sheetsExportRows.length,
});
};
@ -148,9 +150,17 @@ function SavedKeywordsPage() {
</p>
</div>
{savedKeywords.length > 0 && (
<div className="flex items-center gap-2">
<ExportToSheetsButton
headers={savedHeaders}
rows={sheetsExportRows}
feature="saved_keywords"
className="btn-sm"
/>
<button className="btn btn-sm" onClick={exportCsv}>
<Download className="size-4" /> Export CSV
</button>
</div>
)}
</div>