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, type SortingState,
} from "@tanstack/react-table"; } from "@tanstack/react-table";
import { Download, Info, SlidersHorizontal } from "lucide-react"; 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 { BrandLookupMentionTrendCard } from "@/client/features/ai-search/components/BrandLookupMentionTrendCard";
import { BrandLookupFilterPanel } from "@/client/features/ai-search/components/BrandLookupFilterPanel"; import { BrandLookupFilterPanel } from "@/client/features/ai-search/components/BrandLookupFilterPanel";
import { import {
@ -264,49 +268,19 @@ function CitationTabsCard({ result }: { result: BrandLookupResult }) {
getSortedRowModel: getSortedRowModel(), getSortedRowModel: getSortedRowModel(),
}); });
const handleExport = () => { // Not memoized: TanStack's `getSortedRowModel()` is internally cached, and
if (activeTab === "pages") { // memoing on the table refs alone (which are stable across renders) would
const sortedPages = pagesTable // serve stale data when sort or filters change.
.getSortedRowModel() const exportTable = buildBrandLookupExport(
.rows.map((row) => row.original); activeTab,
const csv = buildCsv( pagesTable.getSortedRowModel().rows.map((row) => row.original),
["URL", "Domain", "Platform", "Mentions"], queriesTable.getSortedRowModel().rows.map((row) => row.original),
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,
);
};
const canExport = const handleExport = () =>
activeTab === "pages" downloadBrandLookupCsv(activeTab, result.resolvedTarget, exportTable);
? filteredPages.length > 0
: filteredQueries.length > 0; const canExport = exportTable.rows.length > 0;
const currentFilterCount = filters[activeTab].activeFilterCount; const currentFilterCount = filters[activeTab].activeFilterCount;
@ -332,16 +306,24 @@ function CitationTabsCard({ result }: { result: BrandLookupResult }) {
</button> </button>
</div> </div>
<button <div className="flex items-center gap-2">
type="button" <ExportToSheetsButton
className="btn btn-ghost btn-sm gap-1.5" headers={exportTable.headers}
onClick={handleExport} rows={exportTable.rows}
disabled={!canExport} feature={`brand_lookup_${activeTab}`}
aria-label="Export current tab as CSV" className="btn-sm"
> />
<Download className="size-3.5" /> <button
Export CSV type="button"
</button> className="btn btn-ghost btn-sm gap-1.5"
onClick={handleExport}
disabled={!canExport}
aria-label="Export current tab as CSV"
>
<Download className="size-3.5" />
Export CSV
</button>
</div>
</div> </div>
<div className="flex items-center gap-2 border-b border-base-300 px-4 py-2"> <div className="flex items-center gap-2 border-b border-base-300 px-4 py-2">
@ -410,11 +392,3 @@ function formatRelative(iso: string): string {
const diffDay = Math.floor(diffHr / 24); const diffDay = Math.floor(diffHr / 24);
return `${diffDay}d ago`; 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({ export function ExportDropdown({
onExport, onExport,
}: { }: {
onExport: (format: "csv" | "json") => void; onExport: (format: "csv" | "json" | "sheets") => void;
}) { }) {
return ( return (
<div className="dropdown dropdown-end"> <div className="dropdown dropdown-end">
@ -221,8 +221,13 @@ export function ExportDropdown({
</div> </div>
<ul <ul
tabIndex={0} 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> <li>
<button onClick={() => onExport("csv")}>CSV</button> <button onClick={() => onExport("csv")}>CSV</button>
</li> </li>

View File

@ -129,7 +129,7 @@ function ResultsHeader({
hasPerformanceTab: boolean; hasPerformanceTab: boolean;
activeTab: string; activeTab: string;
setSearchParams: SearchSetter; setSearchParams: SearchSetter;
onExport: (format: "csv" | "json") => void; onExport: (format: "csv" | "json" | "sheets") => void;
}) { }) {
return ( return (
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-3"> <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 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) { function downloadFile(content: string, filename: string, mime: string) {
const blob = new Blob([content], { type: `${mime};charset=utf-8;` }); const blob = new Blob([content], { type: `${mime};charset=utf-8;` });
@ -11,22 +12,77 @@ function downloadFile(content: string, filename: string, mime: string) {
URL.revokeObjectURL(url); 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( export function exportPages(
pages: AuditResultsData["pages"], pages: AuditResultsData["pages"],
format: "csv" | "json", format: "csv" | "json" | "sheets",
) { ) {
const rows = pages.map((page: AuditResultsData["pages"][number]) => ({
url: page.url,
statusCode: page.statusCode,
title: page.title ?? "",
h1Count: page.h1Count,
wordCount: page.wordCount,
imagesTotal: page.imagesTotal,
imagesMissingAlt: page.imagesMissingAlt,
responseTimeMs: page.responseTimeMs,
}));
if (format === "json") { if (format === "json") {
const rows = pages.map((page) => ({
url: page.url,
statusCode: page.statusCode,
title: page.title ?? "",
h1Count: page.h1Count,
wordCount: page.wordCount,
imagesTotal: page.imagesTotal,
imagesMissingAlt: page.imagesMissingAlt,
responseTimeMs: page.responseTimeMs,
}));
downloadFile( downloadFile(
JSON.stringify(rows, null, 2), JSON.stringify(rows, null, 2),
"audit-pages.json", "audit-pages.json",
@ -35,41 +91,26 @@ export function exportPages(
return; return;
} }
const headers = [ if (format === "sheets") {
"URL", void exportTableToSheets({
"Status", headers: PAGES_HEADERS,
"Title", rows: pagesRows(pages),
"H1", feature: "audit_pages",
"Words", });
"Images", return;
"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,
]);
downloadCsv("audit-pages.csv", buildCsv(headers, lines)); downloadCsv("audit-pages.csv", buildCsv(PAGES_HEADERS, pagesRows(pages)));
} }
export function exportPerformance( export function exportPerformance(
lighthouse: AuditResultsData["lighthouse"], lighthouse: AuditResultsData["lighthouse"],
pages: AuditResultsData["pages"], pages: AuditResultsData["pages"],
format: "csv" | "json", format: "csv" | "json" | "sheets",
) { ) {
const rows = lighthouse.map( if (format === "json") {
(result: AuditResultsData["lighthouse"][number]) => { const rows = lighthouse.map((result) => {
const page = pages.find( const page = pages.find((candidate) => candidate.id === result.pageId);
(candidate: AuditResultsData["pages"][number]) =>
candidate.id === result.pageId,
);
return { return {
url: page?.url ?? "", url: page?.url ?? "",
strategy: result.strategy, strategy: result.strategy,
@ -81,10 +122,7 @@ export function exportPerformance(
inpMs: result.inpMs, inpMs: result.inpMs,
ttfbMs: result.ttfbMs, ttfbMs: result.ttfbMs,
}; };
}, });
);
if (format === "json") {
downloadFile( downloadFile(
JSON.stringify(rows, null, 2), JSON.stringify(rows, null, 2),
"audit-performance.json", "audit-performance.json",
@ -93,28 +131,16 @@ export function exportPerformance(
return; return;
} }
const headers = [ const rows = performanceRows(lighthouse, pages);
"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,
]);
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 { HeaderHelpLabel } from "@/client/features/keywords/components";
import { ArrowLeft, Download, SlidersHorizontal } from "lucide-react"; import { ArrowLeft, Download, SlidersHorizontal } from "lucide-react";
import { ExportToSheetsButton } from "@/client/components/table/ExportToSheetsButton";
import { import {
BacklinksNewLostChart, BacklinksNewLostChart,
BacklinksTrendChart, BacklinksTrendChart,
@ -16,7 +18,7 @@ import {
TAB_DESCRIPTIONS, TAB_DESCRIPTIONS,
formatRelativeTimestamp, formatRelativeTimestamp,
} from "./backlinksPageUtils"; } from "./backlinksPageUtils";
import { exportBacklinksTabCsv } from "./export"; import { buildBacklinksTabExport, exportBacklinksTabCsv } from "./export";
import type { BacklinksFiltersState } from "./useBacklinksFilters"; import type { BacklinksFiltersState } from "./useBacklinksFilters";
export function BacklinksOverviewPanels({ export function BacklinksOverviewPanels({
@ -82,6 +84,10 @@ export function BacklinksResultsCard({
exportTarget: string; exportTarget: string;
}) { }) {
const currentFilterCount = filters[activeTab].activeFilterCount; const currentFilterCount = filters[activeTab].activeFilterCount;
const exportTable = useMemo(
() => buildBacklinksTabExport({ tab: activeTab, rows: filteredData }),
[activeTab, filteredData],
);
return ( return (
<div className="border border-base-300 rounded-xl bg-base-100 overflow-hidden"> <div className="border border-base-300 rounded-xl bg-base-100 overflow-hidden">
@ -115,19 +121,27 @@ export function BacklinksResultsCard({
</p> </p>
</div> </div>
<button <div className="flex flex-wrap items-center gap-2">
className="btn btn-sm btn-ghost justify-start lg:justify-center" <ExportToSheetsButton
onClick={() => headers={exportTable.headers}
exportBacklinksTabCsv({ rows={exportTable.rows}
tab: activeTab, feature={`backlinks_${activeTab}`}
target: exportTarget, className="btn-sm"
rows: filteredData, />
}) <button
} className="btn btn-sm btn-ghost justify-start lg:justify-center"
> onClick={() =>
<Download className="size-4" /> exportBacklinksTabCsv({
Export CSV tab: activeTab,
</button> target: exportTarget,
rows: filteredData,
})
}
>
<Download className="size-4" />
Export CSV
</button>
</div>
</div> </div>
<div className="flex items-center gap-2 px-4 py-2 border-b border-base-300"> <div className="flex items-center gap-2 px-4 py-2 border-b border-base-300">

View File

@ -1,4 +1,4 @@
import { buildCsv, downloadCsv } from "@/client/lib/csv"; import { buildCsv, type CsvValue, downloadCsv } from "@/client/lib/csv";
import type { import type {
BacklinksOverviewData, BacklinksOverviewData,
BacklinksSearchState, BacklinksSearchState,
@ -10,103 +10,115 @@ type BacklinksFilteredData = {
topPages: BacklinksOverviewData["topPages"]; topPages: BacklinksOverviewData["topPages"];
}; };
export function buildBacklinksTabExport(args: {
tab: BacklinksSearchState["tab"];
rows: BacklinksFilteredData;
}): { headers: string[]; rows: CsvValue[][] } {
const { tab, rows } = args;
if (tab === "backlinks") {
return {
headers: [
"Domain",
"Source URL",
"Target URL",
"Anchor",
"Type",
"Dofollow",
"Rel Attributes",
"Domain Rank",
"Source Page Rank",
"Target Rank",
"Spam Score",
"First Seen",
"Last Seen",
"Lost",
"Broken",
"Links Count",
],
rows: rows.backlinks.map((row) => [
row.domainFrom,
row.urlFrom,
row.urlTo,
row.anchor,
row.itemType,
row.isDofollow,
row.relAttributes.join(", "),
row.domainFromRank,
row.pageFromRank,
row.rank,
row.spamScore,
row.firstSeen,
row.lastSeen,
row.isLost,
row.isBroken,
row.linksCount,
]),
};
}
if (tab === "domains") {
return {
headers: [
"Domain",
"Backlinks",
"Referring Pages",
"Rank",
"Spam Score",
"First Seen",
"Broken Backlinks",
"Broken Pages",
],
rows: rows.referringDomains.map((row) => [
row.domain,
row.backlinks,
row.referringPages,
row.rank,
row.spamScore,
row.firstSeen,
row.brokenBacklinks,
row.brokenPages,
]),
};
}
return {
headers: [
"Page",
"Backlinks",
"Referring Domains",
"Rank",
"Broken Backlinks",
],
rows: rows.topPages.map((row) => [
row.page,
row.backlinks,
row.referringDomains,
row.rank,
row.brokenBacklinks,
]),
};
}
export function buildBacklinksTabCsvFile(args: { export function buildBacklinksTabCsvFile(args: {
tab: BacklinksSearchState["tab"]; tab: BacklinksSearchState["tab"];
target: string; target: string;
rows: BacklinksFilteredData; rows: BacklinksFilteredData;
}) { }) {
const { tab, target, rows } = args; const { headers, rows } = buildBacklinksTabExport({
tab: args.tab,
if (tab === "backlinks") { rows: args.rows,
const headers = [ });
"Domain", const filenamePrefix =
"Source URL", args.tab === "backlinks"
"Target URL", ? "backlinks"
"Anchor", : args.tab === "domains"
"Type", ? "referring-domains"
"Dofollow", : "top-pages";
"Rel Attributes",
"Domain Rank",
"Source Page Rank",
"Target Rank",
"Spam Score",
"First Seen",
"Last Seen",
"Lost",
"Broken",
"Links Count",
];
const lines = rows.backlinks.map((row) => [
row.domainFrom,
row.urlFrom,
row.urlTo,
row.anchor,
row.itemType,
row.isDofollow,
row.relAttributes.join(", "),
row.domainFromRank,
row.pageFromRank,
row.rank,
row.spamScore,
row.firstSeen,
row.lastSeen,
row.isLost,
row.isBroken,
row.linksCount,
]);
return {
filename: buildFilename("backlinks", target),
content: buildCsv(headers, lines),
};
}
if (tab === "domains") {
const headers = [
"Domain",
"Backlinks",
"Referring Pages",
"Rank",
"Spam Score",
"First Seen",
"Broken Backlinks",
"Broken Pages",
];
const lines = rows.referringDomains.map((row) => [
row.domain,
row.backlinks,
row.referringPages,
row.rank,
row.spamScore,
row.firstSeen,
row.brokenBacklinks,
row.brokenPages,
]);
return {
filename: buildFilename("referring-domains", target),
content: buildCsv(headers, lines),
};
}
const headers = [
"Page",
"Backlinks",
"Referring Domains",
"Rank",
"Broken Backlinks",
];
const lines = rows.topPages.map((row) => [
row.page,
row.backlinks,
row.referringDomains,
row.rank,
row.brokenBacklinks,
]);
return { return {
filename: buildFilename("top-pages", target), filename: buildFilename(filenamePrefix, args.target),
content: buildCsv(headers, lines), content: buildCsv(headers, rows),
}; };
} }

View File

@ -6,6 +6,7 @@ import {
FileSpreadsheet, FileSpreadsheet,
Save, Save,
Search, Search,
Sheet,
SlidersHorizontal, SlidersHorizontal,
} from "lucide-react"; } from "lucide-react";
import { toast } from "sonner"; 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 { DomainKeywordsTable } from "@/client/features/domain/components/DomainKeywordsTable";
import { DomainPagesTable } from "@/client/features/domain/components/DomainPagesTable"; import { DomainPagesTable } from "@/client/features/domain/components/DomainPagesTable";
import type { useDomainFilters } from "@/client/features/domain/hooks/useDomainFilters"; import type { useDomainFilters } from "@/client/features/domain/hooks/useDomainFilters";
import { import { keywordsToTable, pagesToTable } from "@/client/features/domain/utils";
downloadCsv, import { buildCsv, downloadCsv } from "@/client/lib/csv";
keywordsToCsv, import { exportTableToSheets } from "@/client/lib/exportToSheets";
pagesToCsv,
} from "@/client/features/domain/utils";
import { captureClientEvent } from "@/client/lib/posthog"; import { captureClientEvent } from "@/client/lib/posthog";
import type { import type {
DomainActiveTab, DomainActiveTab,
@ -75,8 +74,11 @@ export function DomainResultsCard({
onToggleKeyword, onToggleKeyword,
onToggleAllVisible, onToggleAllVisible,
}: Props) { }: Props) {
const currentRows = const isKeywordsTab = activeTab === "keywords";
activeTab === "keywords" ? filteredKeywords : filteredPages; const currentRows = isKeywordsTab ? filteredKeywords : filteredPages;
const exportTable = isKeywordsTab
? keywordsToTable(filteredKeywords)
: pagesToTable(filteredPages);
const handleCopy = async () => { const handleCopy = async () => {
const text = JSON.stringify(currentRows, null, 2); const text = JSON.stringify(currentRows, null, 2);
@ -84,12 +86,19 @@ export function DomainResultsCard({
toast.success("Copied data"); toast.success("Copied data");
}; };
const handleExportToSheets = () => {
void exportTableToSheets({
headers: exportTable.headers,
rows: exportTable.rows,
feature: "domain_overview",
});
};
const handleDownload = (extension: "csv" | "xls") => { const handleDownload = (extension: "csv" | "xls") => {
const rows = downloadCsv(
activeTab === "keywords" `${overview.domain}-${activeTab}.${extension}`,
? keywordsToCsv(filteredKeywords) buildCsv(exportTable.headers, exportTable.rows),
: pagesToCsv(filteredPages); );
downloadCsv(rows, `${overview.domain}-${activeTab}.${extension}`);
if (extension === "csv") { if (extension === "csv") {
captureClientEvent("data:export", { captureClientEvent("data:export", {
@ -99,8 +108,6 @@ export function DomainResultsCard({
} }
}; };
const isKeywordsTab = activeTab === "keywords";
return ( return (
<div className="border border-base-300 rounded-xl bg-base-100 overflow-hidden"> <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"> <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> </div>
<ul <ul
tabIndex={0} 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> <li>
<button onClick={handleCopy}> <button onClick={handleCopy}>
<Copy className="size-4" /> <Copy className="size-4" />
Copy data Copy data (JSON)
</button> </button>
</li> </li>
<li> <li>

View File

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

View File

@ -6,6 +6,7 @@ import {
TrendingDown, TrendingDown,
TrendingUp, TrendingUp,
} from "lucide-react"; } from "lucide-react";
import { ExportToSheetsButton } from "@/client/components/table/ExportToSheetsButton";
import type { SerpResultItem } from "@/types/keywords"; import type { SerpResultItem } from "@/types/keywords";
import { formatNumber } from "../utils"; import { formatNumber } from "../utils";
@ -48,8 +49,33 @@ export function SerpAnalysisCard({
return ( return (
<div> <div>
<div className="text-xs text-base-content/50 mb-3"> <div className="flex items-center justify-between mb-3">
{items.length} organic results <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> </div>
<SerpAnalysisTable items={pageItems} /> <SerpAnalysisTable items={pageItems} />
<SerpAnalysisPagination <SerpAnalysisPagination

View File

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

View File

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

View File

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

View File

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

View File

@ -5,12 +5,14 @@ import {
exportAuditLighthouseIssues, exportAuditLighthouseIssues,
getAuditLighthouseIssues, getAuditLighthouseIssues,
} from "@/serverFunctions/lighthouse"; } from "@/serverFunctions/lighthouse";
import { exportTableToSheets } from "@/client/lib/exportToSheets";
import type { CategoryTab, ExportPayload, LighthouseIssue } from "./types"; import type { CategoryTab, ExportPayload, LighthouseIssue } from "./types";
import { import {
categoryLabel, categoryLabel,
categorySlug, categorySlug,
downloadTextFile, downloadTextFile,
issuesToCsv, issuesToCsv,
issuesToTable,
} from "./utils"; } from "./utils";
import { import {
LighthouseIssueList, LighthouseIssueList,
@ -62,6 +64,7 @@ export function LighthouseIssuesScreen(props: LighthouseIssuesScreenProps) {
runCopy, runCopy,
runExport, runExport,
runExportCsv, runExportCsv,
runExportSheets,
selectedCategoryLabel, selectedCategoryLabel,
severityCounts, severityCounts,
visibleIssues, visibleIssues,
@ -129,6 +132,7 @@ export function LighthouseIssuesScreen(props: LighthouseIssuesScreenProps) {
void runExport(data); void runExport(data);
}} }}
onExportCsv={runExportCsv} onExportCsv={runExportCsv}
onExportSheets={runExportSheets}
/> />
<LighthouseIssueList <LighthouseIssueList
issues={visibleIssues} issues={visibleIssues}
@ -184,6 +188,18 @@ function useLighthouseIssuesActions({
toast.success("CSV download started"); 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) => { const runCopy = async (data: ExportPayload, toastMessage: string) => {
try { try {
const exported = await exportMutation.mutateAsync(data); const exported = await exportMutation.mutateAsync(data);
@ -202,6 +218,7 @@ function useLighthouseIssuesActions({
runCopy, runCopy,
runExport, runExport,
runExportCsv, runExportCsv,
runExportSheets,
selectedCategoryLabel, selectedCategoryLabel,
severityCounts, severityCounts,
visibleIssues, 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"; 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) { export function categoryLabel(category: CategoryTab) {
if (category === "best-practices") return "Best practices"; if (category === "best-practices") return "Best practices";
if (category === "all") return "All"; if (category === "all") return "All";
@ -12,31 +42,7 @@ export function categorySlug(category: CategoryTab) {
} }
export function issuesToCsv(issues: LighthouseIssue[]) { export function issuesToCsv(issues: LighthouseIssue[]) {
const headers = [ return buildCsv(ISSUE_HEADERS, issuesToRows(issues));
"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);
} }
export function downloadTextFile( export function downloadTextFile(

View File

@ -1,9 +1,17 @@
import { useState } from "react"; 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({ export function ActionsMenu({
onCheckNow, onCheckNow,
onExport, onExport,
onExportToSheets,
onCopyKeywords, onCopyKeywords,
onRefreshMetrics, onRefreshMetrics,
isRunning, isRunning,
@ -13,6 +21,7 @@ export function ActionsMenu({
}: { }: {
onCheckNow: () => void; onCheckNow: () => void;
onExport: () => void; onExport: () => void;
onExportToSheets: () => void;
onCopyKeywords: () => void; onCopyKeywords: () => void;
onRefreshMetrics: () => void; onRefreshMetrics: () => void;
isRunning: boolean; isRunning: boolean;
@ -59,6 +68,17 @@ export function ActionsMenu({
/> />
{metricsRefreshing ? "Refreshing..." : "Refresh Metrics"} {metricsRefreshing ? "Refreshing..." : "Refresh Metrics"}
</button> </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 <button
className="flex w-full items-center gap-2 px-3 py-2 text-sm hover:bg-base-200" className="flex w-full items-center gap-2 px-3 py-2 text-sm hover:bg-base-200"
onClick={() => { 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 { useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Link } from "@tanstack/react-router";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { AutumnProvider, useCustomer } from "autumn-js/react"; import { AutumnProvider, useCustomer } from "autumn-js/react";
import { import {
@ -19,10 +18,13 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useSession } from "@/lib/auth-client"; import { useSession } from "@/lib/auth-client";
import { getCustomerPlanStatus } from "@/client/features/billing/plan-detection"; import { getCustomerPlanStatus } from "@/client/features/billing/plan-detection";
import { SUBSCRIBE_ROUTE } from "@/shared/billing";
import { captureClientEvent } from "@/client/lib/posthog"; import { captureClientEvent } from "@/client/lib/posthog";
import { FreePlanAlert } from "./FreePlanAlert";
import { RankTrackingTable } from "./RankTrackingTable"; import { RankTrackingTable } from "./RankTrackingTable";
import { exportRankTrackingCsv } from "./RankTrackingTableParts"; import {
exportRankTrackingCsv,
exportRankTrackingToSheets,
} from "./RankTrackingTableParts";
import type { import type {
RankTrackingConfig, RankTrackingConfig,
ComparePeriod, 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({ function RankTrackingDomainDetailInner({
config, config,
projectId, projectId,
@ -360,7 +341,7 @@ function RankTrackingDomainDetailInner({
const count = costEstimate?.keywordCount ?? rows?.length ?? 0; const count = costEstimate?.keywordCount ?? rows?.length ?? 0;
if (count > 0) requestCheck(count); if (count > 0) requestCheck(count);
}} }}
onRefreshMetrics={() => refreshMetrics()} onRefreshMetrics={refreshMetrics}
metricsRefreshing={metricsRefreshing} metricsRefreshing={metricsRefreshing}
onExport={() => onExport={() =>
exportRankTrackingCsv( exportRankTrackingCsv(
@ -370,9 +351,13 @@ function RankTrackingDomainDetailInner({
config.domain, config.domain,
) )
} }
onExportToSheets={() =>
exportRankTrackingToSheets(filtered, showDesktop, showMobile)
}
onCopyKeywords={() => { onCopyKeywords={() => {
const text = filtered.map((r) => r.keyword).join("\n"); void navigator.clipboard.writeText(
void navigator.clipboard.writeText(text); filtered.map((r) => r.keyword).join("\n"),
);
toast.success("Keywords copied to clipboard"); toast.success("Keywords copied to clipboard");
}} }}
isRunning={isBusy} isRunning={isBusy}

View File

@ -1,6 +1,7 @@
import { Sparkles } from "lucide-react"; import { Sparkles } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { buildCsv, downloadCsv } from "@/client/lib/csv"; import { buildCsv, downloadCsv } from "@/client/lib/csv";
import { exportTableToSheets } from "@/client/lib/exportToSheets";
import { captureClientEvent } from "@/client/lib/posthog"; import { captureClientEvent } from "@/client/lib/posthog";
import type { import type {
RankTrackingDeviceResult, RankTrackingDeviceResult,
@ -175,16 +176,11 @@ function csvChange(
return previous - current; return previous - current;
} }
export function exportRankTrackingCsv( function buildRankTrackingExport(
sorted: RankTrackingRow[], sorted: RankTrackingRow[],
showDesktop: boolean, showDesktop: boolean,
showMobile: boolean, showMobile: boolean,
domain: string, ): { headers: string[]; rows: (string | number)[][] } {
) {
if (sorted.length === 0) {
toast.error("No data to export");
return;
}
const headers = [ const headers = [
"Keyword", "Keyword",
"Volume", "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.keyword,
row.searchVolume ?? "", row.searchVolume ?? "",
row.keywordDifficulty ?? "", row.keywordDifficulty ?? "",
row.cpc != null ? row.cpc.toFixed(2) : "", row.cpc ?? "",
...(showDesktop ...(showDesktop
? [ ? [
row.desktop.position ?? "Not ranking", row.desktop.position ?? "",
csvChange(row.desktop.position, row.desktop.previousPosition), csvChange(row.desktop.position, row.desktop.previousPosition),
row.desktop.rankingUrl ?? "", row.desktop.rankingUrl ?? "",
row.desktop.serpFeatures.join(", "), row.desktop.serpFeatures.join(", "),
@ -222,13 +220,51 @@ export function exportRankTrackingCsv(
: []), : []),
...(showMobile ...(showMobile
? [ ? [
row.mobile.position ?? "Not ranking", row.mobile.position ?? "",
csvChange(row.mobile.position, row.mobile.previousPosition), csvChange(row.mobile.position, row.mobile.previousPosition),
row.mobile.rankingUrl ?? "", row.mobile.rankingUrl ?? "",
row.mobile.serpFeatures.join(", "), 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)); downloadCsv(`rank-tracking-${domain}.csv`, buildCsv(headers, csvRows));
captureClientEvent("rank_tracking:export_csv"); 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"; 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 { export function buildCsv(headers: string[], rows: CsvValue[][]): string {
const normalizedRows = rows.map((row) => 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 // Prevent CSV/TSV injection (formula injection) by prefixing dangerous
// with a single quote. See OWASP guidance: // characters with a single quote. See OWASP guidance:
// https://owasp.org/www-community/attacks/CSV_Injection // https://owasp.org/www-community/attacks/CSV_Injection
function sanitizeCsvValue( export function sanitizeCsvValue(
value: string | number | boolean, value: string | number | boolean,
): string | number | boolean { ): string | number | boolean {
if (typeof value !== "string" || value.length === 0) { 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 { QueryClientProvider } from "@tanstack/react-query";
import * as React from "react"; import * as React from "react";
import { DefaultCatchBoundary } from "@/client/components/DefaultCatchBoundary"; import { DefaultCatchBoundary } from "@/client/components/DefaultCatchBoundary";
import { ExportToSheetsModal } from "@/client/components/table/ExportToSheetsModal";
import { themePreferenceInitScript } from "@/client/lib/theme"; import { themePreferenceInitScript } from "@/client/lib/theme";
import { import {
identifyAnalyticsUser, identifyAnalyticsUser,
@ -128,6 +129,7 @@ function RootDocument({ children }: { children: React.ReactNode }) {
<> <>
<PostHogBootstrap /> <PostHogBootstrap />
{children} {children}
<ExportToSheetsModal />
<Toaster position="bottom-right" mobileOffset={{ bottom: 100 }} /> <Toaster position="bottom-right" mobileOffset={{ bottom: 100 }} />
{showDevtools ? ( {showDevtools ? (
<TanStackDevtools <TanStackDevtools

View File

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