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,6 +306,13 @@ function CitationTabsCard({ result }: { result: BrandLookupResult }) {
</button> </button>
</div> </div>
<div className="flex items-center gap-2">
<ExportToSheetsButton
headers={exportTable.headers}
rows={exportTable.rows}
feature={`brand_lookup_${activeTab}`}
className="btn-sm"
/>
<button <button
type="button" type="button"
className="btn btn-ghost btn-sm gap-1.5" className="btn btn-ghost btn-sm gap-1.5"
@ -343,6 +324,7 @@ function CitationTabsCard({ result }: { result: BrandLookupResult }) {
Export CSV Export CSV
</button> </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">
<button <button
@ -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,31 +12,7 @@ function downloadFile(content: string, filename: string, mime: string) {
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }
export function exportPages( const PAGES_HEADERS = [
pages: AuditResultsData["pages"],
format: "csv" | "json",
) {
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") {
downloadFile(
JSON.stringify(rows, null, 2),
"audit-pages.json",
"application/json",
);
return;
}
const headers = [
"URL", "URL",
"Status", "Status",
"Title", "Title",
@ -45,31 +22,95 @@ export function exportPages(
"Missing Alt", "Missing Alt",
"Response Time (ms)", "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)); 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" | "sheets",
) {
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(
JSON.stringify(rows, null, 2),
"audit-pages.json",
"application/json",
);
return;
}
if (format === "sheets") {
void exportTableToSheets({
headers: PAGES_HEADERS,
rows: pagesRows(pages),
feature: "audit_pages",
});
return;
}
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,6 +121,13 @@ export function BacklinksResultsCard({
</p> </p>
</div> </div>
<div className="flex flex-wrap items-center gap-2">
<ExportToSheetsButton
headers={exportTable.headers}
rows={exportTable.rows}
feature={`backlinks_${activeTab}`}
className="btn-sm"
/>
<button <button
className="btn btn-sm btn-ghost justify-start lg:justify-center" className="btn btn-sm btn-ghost justify-start lg:justify-center"
onClick={() => onClick={() =>
@ -129,6 +142,7 @@ export function BacklinksResultsCard({
Export CSV Export CSV
</button> </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">
<button <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 { import type {
BacklinksOverviewData, BacklinksOverviewData,
BacklinksSearchState, BacklinksSearchState,
@ -10,15 +10,15 @@ type BacklinksFilteredData = {
topPages: BacklinksOverviewData["topPages"]; topPages: BacklinksOverviewData["topPages"];
}; };
export function buildBacklinksTabCsvFile(args: { export function buildBacklinksTabExport(args: {
tab: BacklinksSearchState["tab"]; tab: BacklinksSearchState["tab"];
target: string;
rows: BacklinksFilteredData; rows: BacklinksFilteredData;
}) { }): { headers: string[]; rows: CsvValue[][] } {
const { tab, target, rows } = args; const { tab, rows } = args;
if (tab === "backlinks") { if (tab === "backlinks") {
const headers = [ return {
headers: [
"Domain", "Domain",
"Source URL", "Source URL",
"Target URL", "Target URL",
@ -35,8 +35,8 @@ export function buildBacklinksTabCsvFile(args: {
"Lost", "Lost",
"Broken", "Broken",
"Links Count", "Links Count",
]; ],
const lines = rows.backlinks.map((row) => [ rows: rows.backlinks.map((row) => [
row.domainFrom, row.domainFrom,
row.urlFrom, row.urlFrom,
row.urlTo, row.urlTo,
@ -53,16 +53,13 @@ export function buildBacklinksTabCsvFile(args: {
row.isLost, row.isLost,
row.isBroken, row.isBroken,
row.linksCount, row.linksCount,
]); ]),
return {
filename: buildFilename("backlinks", target),
content: buildCsv(headers, lines),
}; };
} }
if (tab === "domains") { if (tab === "domains") {
const headers = [ return {
headers: [
"Domain", "Domain",
"Backlinks", "Backlinks",
"Referring Pages", "Referring Pages",
@ -71,8 +68,8 @@ export function buildBacklinksTabCsvFile(args: {
"First Seen", "First Seen",
"Broken Backlinks", "Broken Backlinks",
"Broken Pages", "Broken Pages",
]; ],
const lines = rows.referringDomains.map((row) => [ rows: rows.referringDomains.map((row) => [
row.domain, row.domain,
row.backlinks, row.backlinks,
row.referringPages, row.referringPages,
@ -81,32 +78,47 @@ export function buildBacklinksTabCsvFile(args: {
row.firstSeen, row.firstSeen,
row.brokenBacklinks, row.brokenBacklinks,
row.brokenPages, row.brokenPages,
]); ]),
return {
filename: buildFilename("referring-domains", target),
content: buildCsv(headers, lines),
}; };
} }
const headers = [ return {
headers: [
"Page", "Page",
"Backlinks", "Backlinks",
"Referring Domains", "Referring Domains",
"Rank", "Rank",
"Broken Backlinks", "Broken Backlinks",
]; ],
const lines = rows.topPages.map((row) => [ rows: rows.topPages.map((row) => [
row.page, row.page,
row.backlinks, row.backlinks,
row.referringDomains, row.referringDomains,
row.rank, row.rank,
row.brokenBacklinks, 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 { 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,17 +100,12 @@ 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",
"URL",
"Score",
];
const lines = rows.map((row) => [
row.keyword, row.keyword,
row.position, row.position,
row.searchVolume, row.searchVolume,
@ -119,22 +113,19 @@ export function keywordsToCsv(rows: KeywordRow[]): string {
row.cpc, row.cpc,
row.relativeUrl ?? row.url, row.relativeUrl ?? row.url,
row.keywordDifficulty, 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"],
rows: rows.map((row) => [
row.relativePath ?? row.page, row.relativePath ?? row.page,
row.organicTraffic, row.organicTraffic,
row.keywords, 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,9 +49,34 @@ 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">
<div className="text-xs text-base-content/50">
{items.length} organic results {items.length} organic results
</div> </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} /> <SerpAnalysisTable items={pageItems} />
<SerpAnalysisPagination <SerpAnalysisPagination
page={page} page={page}

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 exportCsv = () => { const savedHeaders = [...KEYWORD_RESEARCH_HEADERS, "Fetched At"];
if (savedKeywords.length === 0) { const sheetsExportRows: CsvValue[][] = savedKeywords.map((kw) => [
toast.error("No keywords to export");
return;
}
const headers = [
"Keyword",
"Volume",
"CPC",
"Competition",
"Difficulty",
"Intent",
"Fetched At",
];
const csvRows = savedKeywords.map((kw) => [
kw.keyword, kw.keyword,
kw.searchVolume ?? "", kw.searchVolume ?? "",
kw.cpc?.toFixed(2) ?? "", kw.cpc ?? "",
kw.competition?.toFixed(2) ?? "", kw.competition ?? "",
kw.keywordDifficulty ?? "", kw.keywordDifficulty ?? "",
kw.intent ?? "", kw.intent ?? "",
kw.fetchedAt ?? "", 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", { 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 && (
<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}> <button className="btn btn-sm" onClick={exportCsv}>
<Download className="size-4" /> Export CSV <Download className="size-4" /> Export CSV
</button> </button>
</div>
)} )}
</div> </div>