feat: add 'Export to Google Sheets' to every data table (#144)
This commit is contained in:
parent
368b3ce375
commit
ab50e59ff8
57
src/client/components/table/ExportToSheetsButton.tsx
Normal file
57
src/client/components/table/ExportToSheetsButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
67
src/client/components/table/ExportToSheetsModal.tsx
Normal file
67
src/client/components/table/ExportToSheetsModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -6,7 +6,11 @@ import {
|
||||
type SortingState,
|
||||
} from "@tanstack/react-table";
|
||||
import { Download, Info, SlidersHorizontal } from "lucide-react";
|
||||
import { buildCsv, downloadCsv } from "@/client/lib/csv";
|
||||
import { ExportToSheetsButton } from "@/client/components/table/ExportToSheetsButton";
|
||||
import {
|
||||
buildBrandLookupExport,
|
||||
downloadBrandLookupCsv,
|
||||
} from "@/client/features/ai-search/components/brandLookupExport";
|
||||
import { BrandLookupMentionTrendCard } from "@/client/features/ai-search/components/BrandLookupMentionTrendCard";
|
||||
import { BrandLookupFilterPanel } from "@/client/features/ai-search/components/BrandLookupFilterPanel";
|
||||
import {
|
||||
@ -264,49 +268,19 @@ function CitationTabsCard({ result }: { result: BrandLookupResult }) {
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
});
|
||||
|
||||
const handleExport = () => {
|
||||
if (activeTab === "pages") {
|
||||
const sortedPages = pagesTable
|
||||
.getSortedRowModel()
|
||||
.rows.map((row) => row.original);
|
||||
const csv = buildCsv(
|
||||
["URL", "Domain", "Platform", "Mentions"],
|
||||
sortedPages.map((row) => [
|
||||
row.url,
|
||||
row.domain ?? "",
|
||||
formatPlatformLabel(row.platform),
|
||||
row.mentions ?? "",
|
||||
]),
|
||||
);
|
||||
downloadCsv(
|
||||
`ai-brand-lookup-pages-${slugify(result.resolvedTarget)}.csv`,
|
||||
csv,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const sortedQueries = queriesTable
|
||||
.getSortedRowModel()
|
||||
.rows.map((row) => row.original);
|
||||
const csv = buildCsv(
|
||||
["Query", "Platform", "AI search volume", "First seen", "Last seen"],
|
||||
sortedQueries.map((row) => [
|
||||
row.question,
|
||||
formatPlatformLabel(row.platform),
|
||||
row.aiSearchVolume ?? "",
|
||||
row.firstSeenAt ?? "",
|
||||
row.lastSeenAt ?? "",
|
||||
]),
|
||||
);
|
||||
downloadCsv(
|
||||
`ai-brand-lookup-queries-${slugify(result.resolvedTarget)}.csv`,
|
||||
csv,
|
||||
);
|
||||
};
|
||||
// Not memoized: TanStack's `getSortedRowModel()` is internally cached, and
|
||||
// memoing on the table refs alone (which are stable across renders) would
|
||||
// serve stale data when sort or filters change.
|
||||
const exportTable = buildBrandLookupExport(
|
||||
activeTab,
|
||||
pagesTable.getSortedRowModel().rows.map((row) => row.original),
|
||||
queriesTable.getSortedRowModel().rows.map((row) => row.original),
|
||||
);
|
||||
|
||||
const canExport =
|
||||
activeTab === "pages"
|
||||
? filteredPages.length > 0
|
||||
: filteredQueries.length > 0;
|
||||
const handleExport = () =>
|
||||
downloadBrandLookupCsv(activeTab, result.resolvedTarget, exportTable);
|
||||
|
||||
const canExport = exportTable.rows.length > 0;
|
||||
|
||||
const currentFilterCount = filters[activeTab].activeFilterCount;
|
||||
|
||||
@ -332,16 +306,24 @@ function CitationTabsCard({ result }: { result: BrandLookupResult }) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="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 className="flex items-center gap-2">
|
||||
<ExportToSheetsButton
|
||||
headers={exportTable.headers}
|
||||
rows={exportTable.rows}
|
||||
feature={`brand_lookup_${activeTab}`}
|
||||
className="btn-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm gap-1.5"
|
||||
onClick={handleExport}
|
||||
disabled={!canExport}
|
||||
aria-label="Export current tab as CSV"
|
||||
>
|
||||
<Download className="size-3.5" />
|
||||
Export CSV
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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);
|
||||
return `${diffDay}d ago`;
|
||||
}
|
||||
|
||||
function slugify(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 60);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
@ -210,7 +210,7 @@ function PerformanceRow({
|
||||
export function ExportDropdown({
|
||||
onExport,
|
||||
}: {
|
||||
onExport: (format: "csv" | "json") => void;
|
||||
onExport: (format: "csv" | "json" | "sheets") => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="dropdown dropdown-end">
|
||||
@ -221,8 +221,13 @@ export function ExportDropdown({
|
||||
</div>
|
||||
<ul
|
||||
tabIndex={0}
|
||||
className="dropdown-content z-10 menu p-2 shadow-lg bg-base-100 border border-base-300 rounded-box w-40"
|
||||
className="dropdown-content z-10 menu p-2 shadow-lg bg-base-100 border border-base-300 rounded-box w-52"
|
||||
>
|
||||
<li>
|
||||
<button onClick={() => onExport("sheets")}>
|
||||
Export to Google Sheets
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button onClick={() => onExport("csv")}>CSV</button>
|
||||
</li>
|
||||
|
||||
@ -129,7 +129,7 @@ function ResultsHeader({
|
||||
hasPerformanceTab: boolean;
|
||||
activeTab: string;
|
||||
setSearchParams: SearchSetter;
|
||||
onExport: (format: "csv" | "json") => void;
|
||||
onExport: (format: "csv" | "json" | "sheets") => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-3">
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import type { AuditResultsData } from "@/client/features/audit/results/types";
|
||||
import { buildCsv, downloadCsv } from "@/client/lib/csv";
|
||||
import { buildCsv, type CsvValue, downloadCsv } from "@/client/lib/csv";
|
||||
import { exportTableToSheets } from "@/client/lib/exportToSheets";
|
||||
|
||||
function downloadFile(content: string, filename: string, mime: string) {
|
||||
const blob = new Blob([content], { type: `${mime};charset=utf-8;` });
|
||||
@ -11,22 +12,77 @@ function downloadFile(content: string, filename: string, mime: string) {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
const PAGES_HEADERS = [
|
||||
"URL",
|
||||
"Status",
|
||||
"Title",
|
||||
"H1",
|
||||
"Words",
|
||||
"Images",
|
||||
"Missing Alt",
|
||||
"Response Time (ms)",
|
||||
];
|
||||
|
||||
function pagesRows(pages: AuditResultsData["pages"]): CsvValue[][] {
|
||||
return pages.map((page) => [
|
||||
page.url,
|
||||
page.statusCode,
|
||||
page.title ?? "",
|
||||
page.h1Count,
|
||||
page.wordCount,
|
||||
page.imagesTotal,
|
||||
page.imagesMissingAlt,
|
||||
page.responseTimeMs,
|
||||
]);
|
||||
}
|
||||
|
||||
const PERFORMANCE_HEADERS = [
|
||||
"URL",
|
||||
"Device",
|
||||
"Performance",
|
||||
"Accessibility",
|
||||
"SEO",
|
||||
"LCP (ms)",
|
||||
"CLS",
|
||||
"INP (ms)",
|
||||
"TTFB (ms)",
|
||||
];
|
||||
|
||||
function performanceRows(
|
||||
lighthouse: AuditResultsData["lighthouse"],
|
||||
pages: AuditResultsData["pages"],
|
||||
): CsvValue[][] {
|
||||
return lighthouse.map((result) => {
|
||||
const page = pages.find((candidate) => candidate.id === result.pageId);
|
||||
return [
|
||||
page?.url ?? "",
|
||||
result.strategy,
|
||||
result.performanceScore,
|
||||
result.accessibilityScore,
|
||||
result.seoScore,
|
||||
result.lcpMs,
|
||||
result.cls,
|
||||
result.inpMs,
|
||||
result.ttfbMs,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
export function exportPages(
|
||||
pages: AuditResultsData["pages"],
|
||||
format: "csv" | "json",
|
||||
format: "csv" | "json" | "sheets",
|
||||
) {
|
||||
const rows = pages.map((page: AuditResultsData["pages"][number]) => ({
|
||||
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") {
|
||||
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",
|
||||
@ -35,41 +91,26 @@ export function exportPages(
|
||||
return;
|
||||
}
|
||||
|
||||
const headers = [
|
||||
"URL",
|
||||
"Status",
|
||||
"Title",
|
||||
"H1",
|
||||
"Words",
|
||||
"Images",
|
||||
"Missing Alt",
|
||||
"Response Time (ms)",
|
||||
];
|
||||
const lines = rows.map((row: (typeof rows)[number]) => [
|
||||
row.url,
|
||||
row.statusCode,
|
||||
row.title,
|
||||
row.h1Count,
|
||||
row.wordCount,
|
||||
row.imagesTotal,
|
||||
row.imagesMissingAlt,
|
||||
row.responseTimeMs,
|
||||
]);
|
||||
if (format === "sheets") {
|
||||
void exportTableToSheets({
|
||||
headers: PAGES_HEADERS,
|
||||
rows: pagesRows(pages),
|
||||
feature: "audit_pages",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
downloadCsv("audit-pages.csv", buildCsv(headers, lines));
|
||||
downloadCsv("audit-pages.csv", buildCsv(PAGES_HEADERS, pagesRows(pages)));
|
||||
}
|
||||
|
||||
export function exportPerformance(
|
||||
lighthouse: AuditResultsData["lighthouse"],
|
||||
pages: AuditResultsData["pages"],
|
||||
format: "csv" | "json",
|
||||
format: "csv" | "json" | "sheets",
|
||||
) {
|
||||
const rows = lighthouse.map(
|
||||
(result: AuditResultsData["lighthouse"][number]) => {
|
||||
const page = pages.find(
|
||||
(candidate: AuditResultsData["pages"][number]) =>
|
||||
candidate.id === result.pageId,
|
||||
);
|
||||
if (format === "json") {
|
||||
const rows = lighthouse.map((result) => {
|
||||
const page = pages.find((candidate) => candidate.id === result.pageId);
|
||||
return {
|
||||
url: page?.url ?? "",
|
||||
strategy: result.strategy,
|
||||
@ -81,10 +122,7 @@ export function exportPerformance(
|
||||
inpMs: result.inpMs,
|
||||
ttfbMs: result.ttfbMs,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
if (format === "json") {
|
||||
});
|
||||
downloadFile(
|
||||
JSON.stringify(rows, null, 2),
|
||||
"audit-performance.json",
|
||||
@ -93,28 +131,16 @@ export function exportPerformance(
|
||||
return;
|
||||
}
|
||||
|
||||
const headers = [
|
||||
"URL",
|
||||
"Device",
|
||||
"Performance",
|
||||
"Accessibility",
|
||||
"SEO",
|
||||
"LCP (ms)",
|
||||
"CLS",
|
||||
"INP (ms)",
|
||||
"TTFB (ms)",
|
||||
];
|
||||
const lines = rows.map((row: (typeof rows)[number]) => [
|
||||
row.url,
|
||||
row.strategy,
|
||||
row.performance,
|
||||
row.accessibility,
|
||||
row.seo,
|
||||
row.lcpMs,
|
||||
row.cls,
|
||||
row.inpMs,
|
||||
row.ttfbMs,
|
||||
]);
|
||||
const rows = performanceRows(lighthouse, pages);
|
||||
|
||||
downloadCsv("audit-performance.csv", buildCsv(headers, lines));
|
||||
if (format === "sheets") {
|
||||
void exportTableToSheets({
|
||||
headers: PERFORMANCE_HEADERS,
|
||||
rows,
|
||||
feature: "audit_performance",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
downloadCsv("audit-performance.csv", buildCsv(PERFORMANCE_HEADERS, rows));
|
||||
}
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import { useMemo } from "react";
|
||||
import { HeaderHelpLabel } from "@/client/features/keywords/components";
|
||||
import { ArrowLeft, Download, SlidersHorizontal } from "lucide-react";
|
||||
import { ExportToSheetsButton } from "@/client/components/table/ExportToSheetsButton";
|
||||
import {
|
||||
BacklinksNewLostChart,
|
||||
BacklinksTrendChart,
|
||||
@ -16,7 +18,7 @@ import {
|
||||
TAB_DESCRIPTIONS,
|
||||
formatRelativeTimestamp,
|
||||
} from "./backlinksPageUtils";
|
||||
import { exportBacklinksTabCsv } from "./export";
|
||||
import { buildBacklinksTabExport, exportBacklinksTabCsv } from "./export";
|
||||
import type { BacklinksFiltersState } from "./useBacklinksFilters";
|
||||
|
||||
export function BacklinksOverviewPanels({
|
||||
@ -82,6 +84,10 @@ export function BacklinksResultsCard({
|
||||
exportTarget: string;
|
||||
}) {
|
||||
const currentFilterCount = filters[activeTab].activeFilterCount;
|
||||
const exportTable = useMemo(
|
||||
() => buildBacklinksTabExport({ tab: activeTab, rows: filteredData }),
|
||||
[activeTab, filteredData],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="border border-base-300 rounded-xl bg-base-100 overflow-hidden">
|
||||
@ -115,19 +121,27 @@ export function BacklinksResultsCard({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="btn btn-sm btn-ghost justify-start lg:justify-center"
|
||||
onClick={() =>
|
||||
exportBacklinksTabCsv({
|
||||
tab: activeTab,
|
||||
target: exportTarget,
|
||||
rows: filteredData,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Download className="size-4" />
|
||||
Export CSV
|
||||
</button>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<ExportToSheetsButton
|
||||
headers={exportTable.headers}
|
||||
rows={exportTable.rows}
|
||||
feature={`backlinks_${activeTab}`}
|
||||
className="btn-sm"
|
||||
/>
|
||||
<button
|
||||
className="btn btn-sm btn-ghost justify-start lg:justify-center"
|
||||
onClick={() =>
|
||||
exportBacklinksTabCsv({
|
||||
tab: activeTab,
|
||||
target: exportTarget,
|
||||
rows: filteredData,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Download className="size-4" />
|
||||
Export CSV
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-base-300">
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { buildCsv, downloadCsv } from "@/client/lib/csv";
|
||||
import { buildCsv, type CsvValue, downloadCsv } from "@/client/lib/csv";
|
||||
import type {
|
||||
BacklinksOverviewData,
|
||||
BacklinksSearchState,
|
||||
@ -10,103 +10,115 @@ type BacklinksFilteredData = {
|
||||
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: {
|
||||
tab: BacklinksSearchState["tab"];
|
||||
target: string;
|
||||
rows: BacklinksFilteredData;
|
||||
}) {
|
||||
const { tab, target, rows } = args;
|
||||
|
||||
if (tab === "backlinks") {
|
||||
const 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",
|
||||
];
|
||||
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,
|
||||
]);
|
||||
const { headers, rows } = buildBacklinksTabExport({
|
||||
tab: args.tab,
|
||||
rows: args.rows,
|
||||
});
|
||||
const filenamePrefix =
|
||||
args.tab === "backlinks"
|
||||
? "backlinks"
|
||||
: args.tab === "domains"
|
||||
? "referring-domains"
|
||||
: "top-pages";
|
||||
|
||||
return {
|
||||
filename: buildFilename("top-pages", target),
|
||||
content: buildCsv(headers, lines),
|
||||
filename: buildFilename(filenamePrefix, args.target),
|
||||
content: buildCsv(headers, rows),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -6,6 +6,7 @@ import {
|
||||
FileSpreadsheet,
|
||||
Save,
|
||||
Search,
|
||||
Sheet,
|
||||
SlidersHorizontal,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
@ -13,11 +14,9 @@ import { DomainFilterPanel } from "@/client/features/domain/components/DomainFil
|
||||
import { DomainKeywordsTable } from "@/client/features/domain/components/DomainKeywordsTable";
|
||||
import { DomainPagesTable } from "@/client/features/domain/components/DomainPagesTable";
|
||||
import type { useDomainFilters } from "@/client/features/domain/hooks/useDomainFilters";
|
||||
import {
|
||||
downloadCsv,
|
||||
keywordsToCsv,
|
||||
pagesToCsv,
|
||||
} from "@/client/features/domain/utils";
|
||||
import { keywordsToTable, pagesToTable } from "@/client/features/domain/utils";
|
||||
import { buildCsv, downloadCsv } from "@/client/lib/csv";
|
||||
import { exportTableToSheets } from "@/client/lib/exportToSheets";
|
||||
import { captureClientEvent } from "@/client/lib/posthog";
|
||||
import type {
|
||||
DomainActiveTab,
|
||||
@ -75,8 +74,11 @@ export function DomainResultsCard({
|
||||
onToggleKeyword,
|
||||
onToggleAllVisible,
|
||||
}: Props) {
|
||||
const currentRows =
|
||||
activeTab === "keywords" ? filteredKeywords : filteredPages;
|
||||
const isKeywordsTab = activeTab === "keywords";
|
||||
const currentRows = isKeywordsTab ? filteredKeywords : filteredPages;
|
||||
const exportTable = isKeywordsTab
|
||||
? keywordsToTable(filteredKeywords)
|
||||
: pagesToTable(filteredPages);
|
||||
|
||||
const handleCopy = async () => {
|
||||
const text = JSON.stringify(currentRows, null, 2);
|
||||
@ -84,12 +86,19 @@ export function DomainResultsCard({
|
||||
toast.success("Copied data");
|
||||
};
|
||||
|
||||
const handleExportToSheets = () => {
|
||||
void exportTableToSheets({
|
||||
headers: exportTable.headers,
|
||||
rows: exportTable.rows,
|
||||
feature: "domain_overview",
|
||||
});
|
||||
};
|
||||
|
||||
const handleDownload = (extension: "csv" | "xls") => {
|
||||
const rows =
|
||||
activeTab === "keywords"
|
||||
? keywordsToCsv(filteredKeywords)
|
||||
: pagesToCsv(filteredPages);
|
||||
downloadCsv(rows, `${overview.domain}-${activeTab}.${extension}`);
|
||||
downloadCsv(
|
||||
`${overview.domain}-${activeTab}.${extension}`,
|
||||
buildCsv(exportTable.headers, exportTable.rows),
|
||||
);
|
||||
|
||||
if (extension === "csv") {
|
||||
captureClientEvent("data:export", {
|
||||
@ -99,8 +108,6 @@ export function DomainResultsCard({
|
||||
}
|
||||
};
|
||||
|
||||
const isKeywordsTab = activeTab === "keywords";
|
||||
|
||||
return (
|
||||
<div className="border border-base-300 rounded-xl bg-base-100 overflow-hidden">
|
||||
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-3 px-4 py-3 border-b border-base-300">
|
||||
@ -144,12 +151,18 @@ export function DomainResultsCard({
|
||||
</div>
|
||||
<ul
|
||||
tabIndex={0}
|
||||
className="dropdown-content z-10 menu p-2 shadow-lg bg-base-100 border border-base-300 rounded-box w-48"
|
||||
className="dropdown-content z-10 menu p-2 shadow-lg bg-base-100 border border-base-300 rounded-box w-56"
|
||||
>
|
||||
<li>
|
||||
<button onClick={handleExportToSheets}>
|
||||
<Sheet className="size-4" />
|
||||
Export to Google Sheets
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button onClick={handleCopy}>
|
||||
<Copy className="size-4" />
|
||||
Copy data
|
||||
Copy data (JSON)
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
|
||||
@ -4,7 +4,6 @@ import type {
|
||||
PageRow,
|
||||
SortOrder,
|
||||
} from "@/client/features/domain/types";
|
||||
import { buildCsv, downloadCsv as downloadCsvFile } from "@/client/lib/csv";
|
||||
|
||||
export function toSortMode(value: string | null): DomainSortMode | undefined {
|
||||
if (
|
||||
@ -101,40 +100,32 @@ export function formatMetric(
|
||||
return formatNumber(value);
|
||||
}
|
||||
|
||||
export function keywordsToCsv(rows: KeywordRow[]): string {
|
||||
const headers = [
|
||||
"Keyword",
|
||||
"Rank",
|
||||
"Volume",
|
||||
"Traffic",
|
||||
"CPC",
|
||||
"URL",
|
||||
"Score",
|
||||
];
|
||||
const lines = rows.map((row) => [
|
||||
row.keyword,
|
||||
row.position,
|
||||
row.searchVolume,
|
||||
row.traffic,
|
||||
row.cpc,
|
||||
row.relativeUrl ?? row.url,
|
||||
row.keywordDifficulty,
|
||||
]);
|
||||
return buildCsv(headers, lines);
|
||||
type ExportTable = { headers: string[]; rows: (string | number | null)[][] };
|
||||
|
||||
export function keywordsToTable(rows: KeywordRow[]): ExportTable {
|
||||
return {
|
||||
headers: ["Keyword", "Rank", "Volume", "Traffic", "CPC", "URL", "Score"],
|
||||
rows: rows.map((row) => [
|
||||
row.keyword,
|
||||
row.position,
|
||||
row.searchVolume,
|
||||
row.traffic,
|
||||
row.cpc,
|
||||
row.relativeUrl ?? row.url,
|
||||
row.keywordDifficulty,
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
export function pagesToCsv(rows: PageRow[]): string {
|
||||
const headers = ["Page", "Organic Traffic", "Keywords"];
|
||||
const lines = rows.map((row) => [
|
||||
row.relativePath ?? row.page,
|
||||
row.organicTraffic,
|
||||
row.keywords,
|
||||
]);
|
||||
return buildCsv(headers, lines);
|
||||
}
|
||||
|
||||
export function downloadCsv(content: string, filename: string) {
|
||||
downloadCsvFile(filename, content);
|
||||
export function pagesToTable(rows: PageRow[]): ExportTable {
|
||||
return {
|
||||
headers: ["Page", "Organic Traffic", "Keywords"],
|
||||
rows: rows.map((row) => [
|
||||
row.relativePath ?? row.page,
|
||||
row.organicTraffic,
|
||||
row.keywords,
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveDomainPageHref(
|
||||
|
||||
@ -6,6 +6,7 @@ import {
|
||||
TrendingDown,
|
||||
TrendingUp,
|
||||
} from "lucide-react";
|
||||
import { ExportToSheetsButton } from "@/client/components/table/ExportToSheetsButton";
|
||||
import type { SerpResultItem } from "@/types/keywords";
|
||||
import { formatNumber } from "../utils";
|
||||
|
||||
@ -48,8 +49,33 @@ export function SerpAnalysisCard({
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="text-xs text-base-content/50 mb-3">
|
||||
{items.length} organic results
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="text-xs text-base-content/50">
|
||||
{items.length} organic results
|
||||
</div>
|
||||
<ExportToSheetsButton
|
||||
headers={[
|
||||
"Rank",
|
||||
"Title",
|
||||
"URL",
|
||||
"Domain",
|
||||
"Traffic",
|
||||
"Referring Domains",
|
||||
"Backlinks",
|
||||
"Rank Change",
|
||||
]}
|
||||
rows={items.map((item) => [
|
||||
item.rank,
|
||||
item.title ?? "",
|
||||
item.url,
|
||||
item.domain,
|
||||
item.etv ?? "",
|
||||
item.referringDomains ?? "",
|
||||
item.backlinks ?? "",
|
||||
item.isNew ? "new" : (item.rankChange ?? ""),
|
||||
])}
|
||||
feature="serp_analysis"
|
||||
/>
|
||||
</div>
|
||||
<SerpAnalysisTable items={pageItems} />
|
||||
<SerpAnalysisPagination
|
||||
|
||||
@ -5,6 +5,8 @@ import {
|
||||
Save,
|
||||
SlidersHorizontal,
|
||||
} from "lucide-react";
|
||||
import { ExportToSheetsButton } from "@/client/components/table/ExportToSheetsButton";
|
||||
import { KEYWORD_RESEARCH_HEADERS } from "@/client/features/keywords/state/keywordControllerActions";
|
||||
import {
|
||||
AreaTrendChart,
|
||||
KeywordRow,
|
||||
@ -103,8 +105,14 @@ function DesktopKeywordPanel({ controller }: Props) {
|
||||
}
|
||||
|
||||
function DesktopTableCard({ controller }: Props) {
|
||||
const { activeFilterCount, filteredRows, rows, selectedRows, showFilters } =
|
||||
controller;
|
||||
const {
|
||||
activeFilterCount,
|
||||
filteredRows,
|
||||
rows,
|
||||
selectedRows,
|
||||
sheetsExportRows,
|
||||
showFilters,
|
||||
} = controller;
|
||||
|
||||
const keywordCountLabel =
|
||||
selectedRows.size > 0
|
||||
@ -141,13 +149,19 @@ function DesktopTableCard({ controller }: Props) {
|
||||
<Save className="size-3.5" />
|
||||
<span className="hidden lg:inline">Save Keywords</span>
|
||||
</button>
|
||||
<ExportToSheetsButton
|
||||
headers={KEYWORD_RESEARCH_HEADERS}
|
||||
rows={sheetsExportRows}
|
||||
feature="keyword_research"
|
||||
className="btn-sm"
|
||||
/>
|
||||
<button
|
||||
className="btn btn-ghost btn-sm gap-1"
|
||||
onClick={controller.exportCsv}
|
||||
disabled={filteredRows.length === 0}
|
||||
>
|
||||
<FileDown className="size-3.5" />
|
||||
<span className="hidden lg:inline">Export</span>
|
||||
<span className="hidden lg:inline">Export CSV</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
import { FileDown, RotateCcw, Save, SlidersHorizontal } from "lucide-react";
|
||||
import { ExportToSheetsButton } from "@/client/components/table/ExportToSheetsButton";
|
||||
import { KEYWORD_RESEARCH_HEADERS } from "@/client/features/keywords/state/keywordControllerActions";
|
||||
import {
|
||||
KeywordCard,
|
||||
SerpAnalysisCard,
|
||||
@ -58,8 +60,14 @@ export function KeywordResearchMobileResults({ controller }: Props) {
|
||||
}
|
||||
|
||||
function MobileKeywordCards({ controller }: Props) {
|
||||
const { activeFilterCount, filteredRows, rows, selectedRows, showFilters } =
|
||||
controller;
|
||||
const {
|
||||
activeFilterCount,
|
||||
filteredRows,
|
||||
rows,
|
||||
selectedRows,
|
||||
sheetsExportRows,
|
||||
showFilters,
|
||||
} = controller;
|
||||
|
||||
const keywordCountLabel =
|
||||
selectedRows.size > 0
|
||||
@ -105,10 +113,17 @@ function MobileKeywordCards({ controller }: Props) {
|
||||
>
|
||||
<Save className="size-3.5" />
|
||||
</button>
|
||||
<ExportToSheetsButton
|
||||
headers={KEYWORD_RESEARCH_HEADERS}
|
||||
rows={sheetsExportRows}
|
||||
feature="keyword_research"
|
||||
iconOnly
|
||||
/>
|
||||
<button
|
||||
className="btn btn-ghost btn-xs"
|
||||
onClick={controller.exportCsv}
|
||||
disabled={filteredRows.length === 0}
|
||||
title="Download CSV"
|
||||
>
|
||||
<FileDown className="size-3.5" />
|
||||
</button>
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { useMemo } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { buildCsv, downloadCsv } from "@/client/lib/csv";
|
||||
import { buildCsv, type CsvValue, downloadCsv } from "@/client/lib/csv";
|
||||
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||
import { captureClientEvent } from "@/client/lib/posthog";
|
||||
import { getLanguageCode } from "@/client/features/keywords/utils";
|
||||
@ -8,6 +9,26 @@ import type { SaveKeywordsInput } from "@/types/schemas/keywords";
|
||||
import type { SortDir, SortField } from "@/client/features/keywords/components";
|
||||
import type { KeywordResearchControllerInput } from "./useKeywordResearchController";
|
||||
|
||||
export const KEYWORD_RESEARCH_HEADERS = [
|
||||
"Keyword",
|
||||
"Volume",
|
||||
"CPC",
|
||||
"Competition",
|
||||
"Score",
|
||||
"Intent",
|
||||
];
|
||||
|
||||
function keywordResearchRow(row: KeywordResearchRow): CsvValue[] {
|
||||
return [
|
||||
row.keyword,
|
||||
row.searchVolume ?? "",
|
||||
row.cpc ?? "",
|
||||
row.competition ?? "",
|
||||
row.keywordDifficulty ?? "",
|
||||
row.intent,
|
||||
];
|
||||
}
|
||||
|
||||
type SaveExportActionParams = {
|
||||
selectedRows: Set<string>;
|
||||
rows: KeywordResearchRow[];
|
||||
@ -100,38 +121,37 @@ export function useSaveAndExportActions(params: SaveExportActionParams) {
|
||||
);
|
||||
};
|
||||
|
||||
const exportCsv = () => {
|
||||
const source =
|
||||
selectedRows.size > 0
|
||||
const sheetsExportRows: CsvValue[][] = useMemo(
|
||||
() =>
|
||||
(selectedRows.size > 0
|
||||
? filteredRows.filter((row) => selectedRows.has(row.keyword))
|
||||
: filteredRows;
|
||||
if (source.length === 0) {
|
||||
: filteredRows
|
||||
).map(keywordResearchRow),
|
||||
[filteredRows, selectedRows],
|
||||
);
|
||||
|
||||
const exportCsv = () => {
|
||||
if (sheetsExportRows.length === 0) {
|
||||
toast.error("No data to export");
|
||||
return;
|
||||
}
|
||||
const headers = [
|
||||
"Keyword",
|
||||
"Volume",
|
||||
"CPC",
|
||||
"Competition",
|
||||
"Difficulty",
|
||||
"Intent",
|
||||
];
|
||||
const csvRows = source.map((row) => [
|
||||
row.keyword,
|
||||
row.searchVolume ?? "",
|
||||
row.cpc?.toFixed(2) ?? "",
|
||||
row.competition?.toFixed(2) ?? "",
|
||||
row.keywordDifficulty ?? "",
|
||||
row.intent,
|
||||
]);
|
||||
const csv = buildCsv(headers, csvRows);
|
||||
downloadCsv("keyword-research.csv", csv);
|
||||
// CSV file keeps cents-formatted CPC/competition for human readability.
|
||||
const csvRows = sheetsExportRows.map((row) =>
|
||||
row.map((cell, idx) =>
|
||||
(idx === 2 || idx === 3) && typeof cell === "number"
|
||||
? cell.toFixed(2)
|
||||
: cell,
|
||||
),
|
||||
);
|
||||
downloadCsv(
|
||||
"keyword-research.csv",
|
||||
buildCsv(KEYWORD_RESEARCH_HEADERS, csvRows),
|
||||
);
|
||||
captureClientEvent("data:export", {
|
||||
source_feature: "keyword_research",
|
||||
result_count: source.length,
|
||||
result_count: sheetsExportRows.length,
|
||||
});
|
||||
};
|
||||
|
||||
return { handleSaveKeywords, confirmSave, exportCsv };
|
||||
return { handleSaveKeywords, confirmSave, exportCsv, sheetsExportRows };
|
||||
}
|
||||
|
||||
@ -75,7 +75,7 @@ export function useKeywordResearchController(
|
||||
[input.sortDir, input.sortField, setSearchParams],
|
||||
);
|
||||
|
||||
const { handleSaveKeywords, confirmSave, exportCsv } =
|
||||
const { handleSaveKeywords, confirmSave, exportCsv, sheetsExportRows } =
|
||||
useSaveAndExportActions({
|
||||
selectedRows: state.selectedRows,
|
||||
rows: state.rows,
|
||||
@ -114,6 +114,7 @@ export function useKeywordResearchController(
|
||||
confirmSave,
|
||||
controlsForm: state.controlsForm,
|
||||
exportCsv,
|
||||
sheetsExportRows,
|
||||
filteredRows: state.filteredRows,
|
||||
filtersForm: state.filtersForm,
|
||||
handleRowClick,
|
||||
|
||||
@ -4,6 +4,7 @@ import {
|
||||
Download,
|
||||
FileWarning,
|
||||
Info,
|
||||
Sheet,
|
||||
TriangleAlert,
|
||||
} from "lucide-react";
|
||||
import type {
|
||||
@ -88,6 +89,7 @@ export function LighthouseIssuesToolbar({
|
||||
onCopy,
|
||||
onExport,
|
||||
onExportCsv,
|
||||
onExportSheets,
|
||||
}: {
|
||||
category: CategoryTab;
|
||||
categoryCounts: Record<CategoryTab, number>;
|
||||
@ -99,6 +101,10 @@ export function LighthouseIssuesToolbar({
|
||||
onCopy: (data: ExportPayload, toastMessage: string) => void;
|
||||
onExport: (data: ExportPayload) => void;
|
||||
onExportCsv: (issues: LighthouseIssue[], variant: "all" | "current") => void;
|
||||
onExportSheets: (
|
||||
issues: LighthouseIssue[],
|
||||
variant: "all" | "current",
|
||||
) => void;
|
||||
}) {
|
||||
const exportCurrentCategory: ExportPayload =
|
||||
category === "all" ? { mode: "issues" } : { mode: "category", category };
|
||||
@ -121,6 +127,7 @@ export function LighthouseIssuesToolbar({
|
||||
onCopy={onCopy}
|
||||
onExport={onExport}
|
||||
onExportCsv={onExportCsv}
|
||||
onExportSheets={onExportSheets}
|
||||
visibleIssues={visibleIssues}
|
||||
/>
|
||||
</div>
|
||||
@ -167,6 +174,7 @@ function ExportMenu({
|
||||
onCopy,
|
||||
onExport,
|
||||
onExportCsv,
|
||||
onExportSheets,
|
||||
visibleIssues,
|
||||
}: {
|
||||
allIssues: LighthouseIssue[];
|
||||
@ -176,6 +184,10 @@ function ExportMenu({
|
||||
onCopy: (data: ExportPayload, toastMessage: string) => void;
|
||||
onExport: (data: ExportPayload) => void;
|
||||
onExportCsv: (issues: LighthouseIssue[], variant: "all" | "current") => void;
|
||||
onExportSheets: (
|
||||
issues: LighthouseIssue[],
|
||||
variant: "all" | "current",
|
||||
) => void;
|
||||
visibleIssues: LighthouseIssue[];
|
||||
}) {
|
||||
return (
|
||||
@ -189,6 +201,27 @@ function ExportMenu({
|
||||
tabIndex={0}
|
||||
className="dropdown-content z-10 menu p-2 shadow-lg bg-base-100 border border-base-300 rounded-box w-72"
|
||||
>
|
||||
<li className="menu-title">
|
||||
<span>Export to Google Sheets</span>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
disabled={!visibleIssues.length}
|
||||
onClick={() => onExportSheets(visibleIssues, "current")}
|
||||
>
|
||||
<Sheet className="size-4" />
|
||||
Open in Sheets — {categoryLabelLower}
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
disabled={!allIssues.length}
|
||||
onClick={() => onExportSheets(allIssues, "all")}
|
||||
>
|
||||
<Sheet className="size-4" />
|
||||
Open in Sheets — all actionable
|
||||
</button>
|
||||
</li>
|
||||
<li className="menu-title">
|
||||
<span>Copy</span>
|
||||
</li>
|
||||
|
||||
@ -5,12 +5,14 @@ import {
|
||||
exportAuditLighthouseIssues,
|
||||
getAuditLighthouseIssues,
|
||||
} from "@/serverFunctions/lighthouse";
|
||||
import { exportTableToSheets } from "@/client/lib/exportToSheets";
|
||||
import type { CategoryTab, ExportPayload, LighthouseIssue } from "./types";
|
||||
import {
|
||||
categoryLabel,
|
||||
categorySlug,
|
||||
downloadTextFile,
|
||||
issuesToCsv,
|
||||
issuesToTable,
|
||||
} from "./utils";
|
||||
import {
|
||||
LighthouseIssueList,
|
||||
@ -62,6 +64,7 @@ export function LighthouseIssuesScreen(props: LighthouseIssuesScreenProps) {
|
||||
runCopy,
|
||||
runExport,
|
||||
runExportCsv,
|
||||
runExportSheets,
|
||||
selectedCategoryLabel,
|
||||
severityCounts,
|
||||
visibleIssues,
|
||||
@ -129,6 +132,7 @@ export function LighthouseIssuesScreen(props: LighthouseIssuesScreenProps) {
|
||||
void runExport(data);
|
||||
}}
|
||||
onExportCsv={runExportCsv}
|
||||
onExportSheets={runExportSheets}
|
||||
/>
|
||||
<LighthouseIssueList
|
||||
issues={visibleIssues}
|
||||
@ -184,6 +188,18 @@ function useLighthouseIssuesActions({
|
||||
toast.success("CSV download started");
|
||||
};
|
||||
|
||||
const runExportSheets = (
|
||||
rows: LighthouseIssue[],
|
||||
variant: "all" | "current",
|
||||
) => {
|
||||
const table = issuesToTable(rows);
|
||||
void exportTableToSheets({
|
||||
headers: table.headers,
|
||||
rows: table.rows,
|
||||
feature: `lighthouse_issues_${variant}`,
|
||||
});
|
||||
};
|
||||
|
||||
const runCopy = async (data: ExportPayload, toastMessage: string) => {
|
||||
try {
|
||||
const exported = await exportMutation.mutateAsync(data);
|
||||
@ -202,6 +218,7 @@ function useLighthouseIssuesActions({
|
||||
runCopy,
|
||||
runExport,
|
||||
runExportCsv,
|
||||
runExportSheets,
|
||||
selectedCategoryLabel,
|
||||
severityCounts,
|
||||
visibleIssues,
|
||||
|
||||
@ -1,6 +1,36 @@
|
||||
import { buildCsv } from "@/client/lib/csv";
|
||||
import { buildCsv, type CsvValue } from "@/client/lib/csv";
|
||||
import type { CategoryTab, LighthouseIssue } from "./types";
|
||||
|
||||
const ISSUE_HEADERS = [
|
||||
"Category",
|
||||
"Severity",
|
||||
"Score",
|
||||
"Title",
|
||||
"Display Value",
|
||||
"Description",
|
||||
"Impact (ms)",
|
||||
"Impact (bytes)",
|
||||
"Affected Items",
|
||||
];
|
||||
|
||||
function issuesToRows(issues: LighthouseIssue[]): CsvValue[][] {
|
||||
return issues.map((issue) => [
|
||||
issue.category,
|
||||
issue.severity,
|
||||
issue.score ?? "",
|
||||
issue.title,
|
||||
issue.displayValue ?? "",
|
||||
issue.description ?? "",
|
||||
issue.impactMs ?? "",
|
||||
issue.impactBytes ?? "",
|
||||
issue.items.length,
|
||||
]);
|
||||
}
|
||||
|
||||
export function issuesToTable(issues: LighthouseIssue[]) {
|
||||
return { headers: ISSUE_HEADERS, rows: issuesToRows(issues) };
|
||||
}
|
||||
|
||||
export function categoryLabel(category: CategoryTab) {
|
||||
if (category === "best-practices") return "Best practices";
|
||||
if (category === "all") return "All";
|
||||
@ -12,31 +42,7 @@ export function categorySlug(category: CategoryTab) {
|
||||
}
|
||||
|
||||
export function issuesToCsv(issues: LighthouseIssue[]) {
|
||||
const headers = [
|
||||
"Category",
|
||||
"Severity",
|
||||
"Score",
|
||||
"Title",
|
||||
"Display Value",
|
||||
"Description",
|
||||
"Impact (ms)",
|
||||
"Impact (bytes)",
|
||||
"Affected Items",
|
||||
];
|
||||
|
||||
const rows = issues.map((issue) => [
|
||||
issue.category,
|
||||
issue.severity,
|
||||
issue.score ?? "",
|
||||
issue.title,
|
||||
issue.displayValue ?? "",
|
||||
issue.description ?? "",
|
||||
issue.impactMs ?? "",
|
||||
issue.impactBytes ?? "",
|
||||
issue.items.length,
|
||||
]);
|
||||
|
||||
return buildCsv(headers, rows);
|
||||
return buildCsv(ISSUE_HEADERS, issuesToRows(issues));
|
||||
}
|
||||
|
||||
export function downloadTextFile(
|
||||
|
||||
@ -1,9 +1,17 @@
|
||||
import { useState } from "react";
|
||||
import { MoreHorizontal, Play, Download, Copy, RefreshCw } from "lucide-react";
|
||||
import {
|
||||
Copy,
|
||||
Download,
|
||||
MoreHorizontal,
|
||||
Play,
|
||||
RefreshCw,
|
||||
Sheet,
|
||||
} from "lucide-react";
|
||||
|
||||
export function ActionsMenu({
|
||||
onCheckNow,
|
||||
onExport,
|
||||
onExportToSheets,
|
||||
onCopyKeywords,
|
||||
onRefreshMetrics,
|
||||
isRunning,
|
||||
@ -13,6 +21,7 @@ export function ActionsMenu({
|
||||
}: {
|
||||
onCheckNow: () => void;
|
||||
onExport: () => void;
|
||||
onExportToSheets: () => void;
|
||||
onCopyKeywords: () => void;
|
||||
onRefreshMetrics: () => void;
|
||||
isRunning: boolean;
|
||||
@ -59,6 +68,17 @@ export function ActionsMenu({
|
||||
/>
|
||||
{metricsRefreshing ? "Refreshing..." : "Refresh Metrics"}
|
||||
</button>
|
||||
<button
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-sm hover:bg-base-200"
|
||||
onClick={() => {
|
||||
onExportToSheets();
|
||||
setOpen(false);
|
||||
}}
|
||||
disabled={!hasData}
|
||||
>
|
||||
<Sheet className="size-3.5" />
|
||||
Export to Google Sheets
|
||||
</button>
|
||||
<button
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-sm hover:bg-base-200"
|
||||
onClick={() => {
|
||||
|
||||
24
src/client/features/rank-tracking/FreePlanAlert.tsx
Normal file
24
src/client/features/rank-tracking/FreePlanAlert.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,5 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AutumnProvider, useCustomer } from "autumn-js/react";
|
||||
import {
|
||||
@ -19,10 +18,13 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useSession } from "@/lib/auth-client";
|
||||
import { getCustomerPlanStatus } from "@/client/features/billing/plan-detection";
|
||||
import { SUBSCRIBE_ROUTE } from "@/shared/billing";
|
||||
import { captureClientEvent } from "@/client/lib/posthog";
|
||||
import { FreePlanAlert } from "./FreePlanAlert";
|
||||
import { RankTrackingTable } from "./RankTrackingTable";
|
||||
import { exportRankTrackingCsv } from "./RankTrackingTableParts";
|
||||
import {
|
||||
exportRankTrackingCsv,
|
||||
exportRankTrackingToSheets,
|
||||
} from "./RankTrackingTableParts";
|
||||
import type {
|
||||
RankTrackingConfig,
|
||||
ComparePeriod,
|
||||
@ -67,27 +69,6 @@ export function RankTrackingDomainDetail(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function FreePlanAlert({ visible }: { visible: boolean }) {
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<div className="alert alert-warning text-sm py-2">
|
||||
<AlertTriangle className="size-4" />
|
||||
<span>
|
||||
We only start to track keyword positions once you{" "}
|
||||
<Link
|
||||
to={SUBSCRIBE_ROUTE}
|
||||
search={{ upgrade: true }}
|
||||
className="link font-medium"
|
||||
>
|
||||
upgrade to the paid plan
|
||||
</Link>
|
||||
.
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RankTrackingDomainDetailInner({
|
||||
config,
|
||||
projectId,
|
||||
@ -360,7 +341,7 @@ function RankTrackingDomainDetailInner({
|
||||
const count = costEstimate?.keywordCount ?? rows?.length ?? 0;
|
||||
if (count > 0) requestCheck(count);
|
||||
}}
|
||||
onRefreshMetrics={() => refreshMetrics()}
|
||||
onRefreshMetrics={refreshMetrics}
|
||||
metricsRefreshing={metricsRefreshing}
|
||||
onExport={() =>
|
||||
exportRankTrackingCsv(
|
||||
@ -370,9 +351,13 @@ function RankTrackingDomainDetailInner({
|
||||
config.domain,
|
||||
)
|
||||
}
|
||||
onExportToSheets={() =>
|
||||
exportRankTrackingToSheets(filtered, showDesktop, showMobile)
|
||||
}
|
||||
onCopyKeywords={() => {
|
||||
const text = filtered.map((r) => r.keyword).join("\n");
|
||||
void navigator.clipboard.writeText(text);
|
||||
void navigator.clipboard.writeText(
|
||||
filtered.map((r) => r.keyword).join("\n"),
|
||||
);
|
||||
toast.success("Keywords copied to clipboard");
|
||||
}}
|
||||
isRunning={isBusy}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { buildCsv, downloadCsv } from "@/client/lib/csv";
|
||||
import { exportTableToSheets } from "@/client/lib/exportToSheets";
|
||||
import { captureClientEvent } from "@/client/lib/posthog";
|
||||
import type {
|
||||
RankTrackingDeviceResult,
|
||||
@ -175,16 +176,11 @@ function csvChange(
|
||||
return previous - current;
|
||||
}
|
||||
|
||||
export function exportRankTrackingCsv(
|
||||
function buildRankTrackingExport(
|
||||
sorted: RankTrackingRow[],
|
||||
showDesktop: boolean,
|
||||
showMobile: boolean,
|
||||
domain: string,
|
||||
) {
|
||||
if (sorted.length === 0) {
|
||||
toast.error("No data to export");
|
||||
return;
|
||||
}
|
||||
): { headers: string[]; rows: (string | number)[][] } {
|
||||
const headers = [
|
||||
"Keyword",
|
||||
"Volume",
|
||||
@ -207,14 +203,16 @@ export function exportRankTrackingCsv(
|
||||
]
|
||||
: []),
|
||||
];
|
||||
const csvRows = sorted.map((row) => [
|
||||
// Emit empty cells (not "Not ranking" strings) so Sheets infers a numeric
|
||||
// column type and the user can sort by position.
|
||||
const rows = sorted.map((row) => [
|
||||
row.keyword,
|
||||
row.searchVolume ?? "",
|
||||
row.keywordDifficulty ?? "",
|
||||
row.cpc != null ? row.cpc.toFixed(2) : "",
|
||||
row.cpc ?? "",
|
||||
...(showDesktop
|
||||
? [
|
||||
row.desktop.position ?? "Not ranking",
|
||||
row.desktop.position ?? "",
|
||||
csvChange(row.desktop.position, row.desktop.previousPosition),
|
||||
row.desktop.rankingUrl ?? "",
|
||||
row.desktop.serpFeatures.join(", "),
|
||||
@ -222,13 +220,51 @@ export function exportRankTrackingCsv(
|
||||
: []),
|
||||
...(showMobile
|
||||
? [
|
||||
row.mobile.position ?? "Not ranking",
|
||||
row.mobile.position ?? "",
|
||||
csvChange(row.mobile.position, row.mobile.previousPosition),
|
||||
row.mobile.rankingUrl ?? "",
|
||||
row.mobile.serpFeatures.join(", "),
|
||||
]
|
||||
: []),
|
||||
]);
|
||||
return { headers, rows };
|
||||
}
|
||||
|
||||
export function exportRankTrackingToSheets(
|
||||
sorted: RankTrackingRow[],
|
||||
showDesktop: boolean,
|
||||
showMobile: boolean,
|
||||
) {
|
||||
const { headers, rows } = buildRankTrackingExport(
|
||||
sorted,
|
||||
showDesktop,
|
||||
showMobile,
|
||||
);
|
||||
void exportTableToSheets({ headers, rows, feature: "rank_tracking" });
|
||||
}
|
||||
|
||||
export function exportRankTrackingCsv(
|
||||
sorted: RankTrackingRow[],
|
||||
showDesktop: boolean,
|
||||
showMobile: boolean,
|
||||
domain: string,
|
||||
) {
|
||||
if (sorted.length === 0) {
|
||||
toast.error("No data to export");
|
||||
return;
|
||||
}
|
||||
const { headers, rows } = buildRankTrackingExport(
|
||||
sorted,
|
||||
showDesktop,
|
||||
showMobile,
|
||||
);
|
||||
// CSV file download keeps cents-formatted CPC for human readability;
|
||||
// clipboard/Sheets export uses raw numbers (see buildRankTrackingExport).
|
||||
const csvRows = rows.map((row) =>
|
||||
row.map((cell, idx) =>
|
||||
idx === 3 && typeof cell === "number" ? cell.toFixed(2) : cell,
|
||||
),
|
||||
);
|
||||
downloadCsv(`rank-tracking-${domain}.csv`, buildCsv(headers, csvRows));
|
||||
captureClientEvent("rank_tracking:export_csv");
|
||||
}
|
||||
|
||||
95
src/client/lib/clipboard.test.ts
Normal file
95
src/client/lib/clipboard.test.ts
Normal 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("<script>");
|
||||
});
|
||||
|
||||
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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
72
src/client/lib/clipboard.ts
Normal file
72
src/client/lib/clipboard.ts
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
import Papa from "papaparse";
|
||||
|
||||
type CsvValue = string | number | boolean | null | undefined;
|
||||
export type CsvValue = string | number | boolean | null | undefined;
|
||||
|
||||
export function buildCsv(headers: string[], rows: CsvValue[][]): string {
|
||||
const normalizedRows = rows.map((row) =>
|
||||
@ -19,10 +19,10 @@ export function buildCsv(headers: string[], rows: CsvValue[][]): string {
|
||||
);
|
||||
}
|
||||
|
||||
// Prevent CSV injection (formula injection) by prefixing dangerous characters
|
||||
// with a single quote. See OWASP guidance:
|
||||
// Prevent CSV/TSV injection (formula injection) by prefixing dangerous
|
||||
// characters with a single quote. See OWASP guidance:
|
||||
// https://owasp.org/www-community/attacks/CSV_Injection
|
||||
function sanitizeCsvValue(
|
||||
export function sanitizeCsvValue(
|
||||
value: string | number | boolean,
|
||||
): string | number | boolean {
|
||||
if (typeof value !== "string" || value.length === 0) {
|
||||
|
||||
69
src/client/lib/exportToSheets.ts
Normal file
69
src/client/lib/exportToSheets.ts
Normal 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"));
|
||||
}
|
||||
}
|
||||
@ -11,6 +11,7 @@ import { TanStackDevtools } from "@tanstack/react-devtools";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import * as React from "react";
|
||||
import { DefaultCatchBoundary } from "@/client/components/DefaultCatchBoundary";
|
||||
import { ExportToSheetsModal } from "@/client/components/table/ExportToSheetsModal";
|
||||
import { themePreferenceInitScript } from "@/client/lib/theme";
|
||||
import {
|
||||
identifyAnalyticsUser,
|
||||
@ -128,6 +129,7 @@ function RootDocument({ children }: { children: React.ReactNode }) {
|
||||
<>
|
||||
<PostHogBootstrap />
|
||||
{children}
|
||||
<ExportToSheetsModal />
|
||||
<Toaster position="bottom-right" mobileOffset={{ bottom: 100 }} />
|
||||
{showDevtools ? (
|
||||
<TanStackDevtools
|
||||
|
||||
@ -14,7 +14,9 @@ import {
|
||||
Trash2,
|
||||
Copy,
|
||||
} from "lucide-react";
|
||||
import { buildCsv, downloadCsv } from "@/client/lib/csv";
|
||||
import { ExportToSheetsButton } from "@/client/components/table/ExportToSheetsButton";
|
||||
import { KEYWORD_RESEARCH_HEADERS } from "@/client/features/keywords/state/keywordControllerActions";
|
||||
import { buildCsv, type CsvValue, downloadCsv } from "@/client/lib/csv";
|
||||
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||
import { captureClientEvent } from "@/client/lib/posthog";
|
||||
|
||||
@ -106,34 +108,34 @@ function SavedKeywordsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const 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 = () => {
|
||||
if (savedKeywords.length === 0) {
|
||||
if (sheetsExportRows.length === 0) {
|
||||
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.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);
|
||||
// CSV file keeps cents-formatted CPC/competition for human readability.
|
||||
const csvRows = sheetsExportRows.map((row) =>
|
||||
row.map((cell, idx) =>
|
||||
(idx === 2 || idx === 3) && typeof cell === "number"
|
||||
? cell.toFixed(2)
|
||||
: cell,
|
||||
),
|
||||
);
|
||||
downloadCsv("saved-keywords.csv", buildCsv(savedHeaders, csvRows));
|
||||
captureClientEvent("data:export", {
|
||||
source_feature: "saved_keywords",
|
||||
result_count: savedKeywords.length,
|
||||
result_count: sheetsExportRows.length,
|
||||
});
|
||||
};
|
||||
|
||||
@ -148,9 +150,17 @@ function SavedKeywordsPage() {
|
||||
</p>
|
||||
</div>
|
||||
{savedKeywords.length > 0 && (
|
||||
<button className="btn btn-sm" onClick={exportCsv}>
|
||||
<Download className="size-4" /> Export CSV
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<ExportToSheetsButton
|
||||
headers={savedHeaders}
|
||||
rows={sheetsExportRows}
|
||||
feature="saved_keywords"
|
||||
className="btn-sm"
|
||||
/>
|
||||
<button className="btn btn-sm" onClick={exportCsv}>
|
||||
<Download className="size-4" /> Export CSV
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user