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,
|
type SortingState,
|
||||||
} from "@tanstack/react-table";
|
} from "@tanstack/react-table";
|
||||||
import { Download, Info, SlidersHorizontal } from "lucide-react";
|
import { Download, Info, SlidersHorizontal } from "lucide-react";
|
||||||
import { buildCsv, downloadCsv } from "@/client/lib/csv";
|
import { ExportToSheetsButton } from "@/client/components/table/ExportToSheetsButton";
|
||||||
|
import {
|
||||||
|
buildBrandLookupExport,
|
||||||
|
downloadBrandLookupCsv,
|
||||||
|
} from "@/client/features/ai-search/components/brandLookupExport";
|
||||||
import { BrandLookupMentionTrendCard } from "@/client/features/ai-search/components/BrandLookupMentionTrendCard";
|
import { BrandLookupMentionTrendCard } from "@/client/features/ai-search/components/BrandLookupMentionTrendCard";
|
||||||
import { BrandLookupFilterPanel } from "@/client/features/ai-search/components/BrandLookupFilterPanel";
|
import { BrandLookupFilterPanel } from "@/client/features/ai-search/components/BrandLookupFilterPanel";
|
||||||
import {
|
import {
|
||||||
@ -264,49 +268,19 @@ function CitationTabsCard({ result }: { result: BrandLookupResult }) {
|
|||||||
getSortedRowModel: getSortedRowModel(),
|
getSortedRowModel: getSortedRowModel(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleExport = () => {
|
// Not memoized: TanStack's `getSortedRowModel()` is internally cached, and
|
||||||
if (activeTab === "pages") {
|
// memoing on the table refs alone (which are stable across renders) would
|
||||||
const sortedPages = pagesTable
|
// serve stale data when sort or filters change.
|
||||||
.getSortedRowModel()
|
const exportTable = buildBrandLookupExport(
|
||||||
.rows.map((row) => row.original);
|
activeTab,
|
||||||
const csv = buildCsv(
|
pagesTable.getSortedRowModel().rows.map((row) => row.original),
|
||||||
["URL", "Domain", "Platform", "Mentions"],
|
queriesTable.getSortedRowModel().rows.map((row) => row.original),
|
||||||
sortedPages.map((row) => [
|
);
|
||||||
row.url,
|
|
||||||
row.domain ?? "",
|
|
||||||
formatPlatformLabel(row.platform),
|
|
||||||
row.mentions ?? "",
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
downloadCsv(
|
|
||||||
`ai-brand-lookup-pages-${slugify(result.resolvedTarget)}.csv`,
|
|
||||||
csv,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const sortedQueries = queriesTable
|
|
||||||
.getSortedRowModel()
|
|
||||||
.rows.map((row) => row.original);
|
|
||||||
const csv = buildCsv(
|
|
||||||
["Query", "Platform", "AI search volume", "First seen", "Last seen"],
|
|
||||||
sortedQueries.map((row) => [
|
|
||||||
row.question,
|
|
||||||
formatPlatformLabel(row.platform),
|
|
||||||
row.aiSearchVolume ?? "",
|
|
||||||
row.firstSeenAt ?? "",
|
|
||||||
row.lastSeenAt ?? "",
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
downloadCsv(
|
|
||||||
`ai-brand-lookup-queries-${slugify(result.resolvedTarget)}.csv`,
|
|
||||||
csv,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const canExport =
|
const handleExport = () =>
|
||||||
activeTab === "pages"
|
downloadBrandLookupCsv(activeTab, result.resolvedTarget, exportTable);
|
||||||
? filteredPages.length > 0
|
|
||||||
: filteredQueries.length > 0;
|
const canExport = exportTable.rows.length > 0;
|
||||||
|
|
||||||
const currentFilterCount = filters[activeTab].activeFilterCount;
|
const currentFilterCount = filters[activeTab].activeFilterCount;
|
||||||
|
|
||||||
@ -332,16 +306,24 @@ function CitationTabsCard({ result }: { result: BrandLookupResult }) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<div className="flex items-center gap-2">
|
||||||
type="button"
|
<ExportToSheetsButton
|
||||||
className="btn btn-ghost btn-sm gap-1.5"
|
headers={exportTable.headers}
|
||||||
onClick={handleExport}
|
rows={exportTable.rows}
|
||||||
disabled={!canExport}
|
feature={`brand_lookup_${activeTab}`}
|
||||||
aria-label="Export current tab as CSV"
|
className="btn-sm"
|
||||||
>
|
/>
|
||||||
<Download className="size-3.5" />
|
<button
|
||||||
Export CSV
|
type="button"
|
||||||
</button>
|
className="btn btn-ghost btn-sm gap-1.5"
|
||||||
|
onClick={handleExport}
|
||||||
|
disabled={!canExport}
|
||||||
|
aria-label="Export current tab as CSV"
|
||||||
|
>
|
||||||
|
<Download className="size-3.5" />
|
||||||
|
Export CSV
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 border-b border-base-300 px-4 py-2">
|
<div className="flex items-center gap-2 border-b border-base-300 px-4 py-2">
|
||||||
@ -410,11 +392,3 @@ function formatRelative(iso: string): string {
|
|||||||
const diffDay = Math.floor(diffHr / 24);
|
const diffDay = Math.floor(diffHr / 24);
|
||||||
return `${diffDay}d ago`;
|
return `${diffDay}d ago`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function slugify(value: string): string {
|
|
||||||
return value
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9]+/g, "-")
|
|
||||||
.replace(/^-+|-+$/g, "")
|
|
||||||
.slice(0, 60);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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({
|
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>
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import type { AuditResultsData } from "@/client/features/audit/results/types";
|
import type { AuditResultsData } from "@/client/features/audit/results/types";
|
||||||
import { buildCsv, downloadCsv } from "@/client/lib/csv";
|
import { buildCsv, type CsvValue, downloadCsv } from "@/client/lib/csv";
|
||||||
|
import { exportTableToSheets } from "@/client/lib/exportToSheets";
|
||||||
|
|
||||||
function downloadFile(content: string, filename: string, mime: string) {
|
function downloadFile(content: string, filename: string, mime: string) {
|
||||||
const blob = new Blob([content], { type: `${mime};charset=utf-8;` });
|
const blob = new Blob([content], { type: `${mime};charset=utf-8;` });
|
||||||
@ -11,22 +12,77 @@ function downloadFile(content: string, filename: string, mime: string) {
|
|||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PAGES_HEADERS = [
|
||||||
|
"URL",
|
||||||
|
"Status",
|
||||||
|
"Title",
|
||||||
|
"H1",
|
||||||
|
"Words",
|
||||||
|
"Images",
|
||||||
|
"Missing Alt",
|
||||||
|
"Response Time (ms)",
|
||||||
|
];
|
||||||
|
|
||||||
|
function pagesRows(pages: AuditResultsData["pages"]): CsvValue[][] {
|
||||||
|
return pages.map((page) => [
|
||||||
|
page.url,
|
||||||
|
page.statusCode,
|
||||||
|
page.title ?? "",
|
||||||
|
page.h1Count,
|
||||||
|
page.wordCount,
|
||||||
|
page.imagesTotal,
|
||||||
|
page.imagesMissingAlt,
|
||||||
|
page.responseTimeMs,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const PERFORMANCE_HEADERS = [
|
||||||
|
"URL",
|
||||||
|
"Device",
|
||||||
|
"Performance",
|
||||||
|
"Accessibility",
|
||||||
|
"SEO",
|
||||||
|
"LCP (ms)",
|
||||||
|
"CLS",
|
||||||
|
"INP (ms)",
|
||||||
|
"TTFB (ms)",
|
||||||
|
];
|
||||||
|
|
||||||
|
function performanceRows(
|
||||||
|
lighthouse: AuditResultsData["lighthouse"],
|
||||||
|
pages: AuditResultsData["pages"],
|
||||||
|
): CsvValue[][] {
|
||||||
|
return lighthouse.map((result) => {
|
||||||
|
const page = pages.find((candidate) => candidate.id === result.pageId);
|
||||||
|
return [
|
||||||
|
page?.url ?? "",
|
||||||
|
result.strategy,
|
||||||
|
result.performanceScore,
|
||||||
|
result.accessibilityScore,
|
||||||
|
result.seoScore,
|
||||||
|
result.lcpMs,
|
||||||
|
result.cls,
|
||||||
|
result.inpMs,
|
||||||
|
result.ttfbMs,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function exportPages(
|
export function exportPages(
|
||||||
pages: AuditResultsData["pages"],
|
pages: AuditResultsData["pages"],
|
||||||
format: "csv" | "json",
|
format: "csv" | "json" | "sheets",
|
||||||
) {
|
) {
|
||||||
const rows = pages.map((page: AuditResultsData["pages"][number]) => ({
|
|
||||||
url: page.url,
|
|
||||||
statusCode: page.statusCode,
|
|
||||||
title: page.title ?? "",
|
|
||||||
h1Count: page.h1Count,
|
|
||||||
wordCount: page.wordCount,
|
|
||||||
imagesTotal: page.imagesTotal,
|
|
||||||
imagesMissingAlt: page.imagesMissingAlt,
|
|
||||||
responseTimeMs: page.responseTimeMs,
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (format === "json") {
|
if (format === "json") {
|
||||||
|
const rows = pages.map((page) => ({
|
||||||
|
url: page.url,
|
||||||
|
statusCode: page.statusCode,
|
||||||
|
title: page.title ?? "",
|
||||||
|
h1Count: page.h1Count,
|
||||||
|
wordCount: page.wordCount,
|
||||||
|
imagesTotal: page.imagesTotal,
|
||||||
|
imagesMissingAlt: page.imagesMissingAlt,
|
||||||
|
responseTimeMs: page.responseTimeMs,
|
||||||
|
}));
|
||||||
downloadFile(
|
downloadFile(
|
||||||
JSON.stringify(rows, null, 2),
|
JSON.stringify(rows, null, 2),
|
||||||
"audit-pages.json",
|
"audit-pages.json",
|
||||||
@ -35,41 +91,26 @@ export function exportPages(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const headers = [
|
if (format === "sheets") {
|
||||||
"URL",
|
void exportTableToSheets({
|
||||||
"Status",
|
headers: PAGES_HEADERS,
|
||||||
"Title",
|
rows: pagesRows(pages),
|
||||||
"H1",
|
feature: "audit_pages",
|
||||||
"Words",
|
});
|
||||||
"Images",
|
return;
|
||||||
"Missing Alt",
|
}
|
||||||
"Response Time (ms)",
|
|
||||||
];
|
|
||||||
const lines = rows.map((row: (typeof rows)[number]) => [
|
|
||||||
row.url,
|
|
||||||
row.statusCode,
|
|
||||||
row.title,
|
|
||||||
row.h1Count,
|
|
||||||
row.wordCount,
|
|
||||||
row.imagesTotal,
|
|
||||||
row.imagesMissingAlt,
|
|
||||||
row.responseTimeMs,
|
|
||||||
]);
|
|
||||||
|
|
||||||
downloadCsv("audit-pages.csv", buildCsv(headers, lines));
|
downloadCsv("audit-pages.csv", buildCsv(PAGES_HEADERS, pagesRows(pages)));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function exportPerformance(
|
export function exportPerformance(
|
||||||
lighthouse: AuditResultsData["lighthouse"],
|
lighthouse: AuditResultsData["lighthouse"],
|
||||||
pages: AuditResultsData["pages"],
|
pages: AuditResultsData["pages"],
|
||||||
format: "csv" | "json",
|
format: "csv" | "json" | "sheets",
|
||||||
) {
|
) {
|
||||||
const rows = lighthouse.map(
|
if (format === "json") {
|
||||||
(result: AuditResultsData["lighthouse"][number]) => {
|
const rows = lighthouse.map((result) => {
|
||||||
const page = pages.find(
|
const page = pages.find((candidate) => candidate.id === result.pageId);
|
||||||
(candidate: AuditResultsData["pages"][number]) =>
|
|
||||||
candidate.id === result.pageId,
|
|
||||||
);
|
|
||||||
return {
|
return {
|
||||||
url: page?.url ?? "",
|
url: page?.url ?? "",
|
||||||
strategy: result.strategy,
|
strategy: result.strategy,
|
||||||
@ -81,10 +122,7 @@ export function exportPerformance(
|
|||||||
inpMs: result.inpMs,
|
inpMs: result.inpMs,
|
||||||
ttfbMs: result.ttfbMs,
|
ttfbMs: result.ttfbMs,
|
||||||
};
|
};
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
if (format === "json") {
|
|
||||||
downloadFile(
|
downloadFile(
|
||||||
JSON.stringify(rows, null, 2),
|
JSON.stringify(rows, null, 2),
|
||||||
"audit-performance.json",
|
"audit-performance.json",
|
||||||
@ -93,28 +131,16 @@ export function exportPerformance(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const headers = [
|
const rows = performanceRows(lighthouse, pages);
|
||||||
"URL",
|
|
||||||
"Device",
|
|
||||||
"Performance",
|
|
||||||
"Accessibility",
|
|
||||||
"SEO",
|
|
||||||
"LCP (ms)",
|
|
||||||
"CLS",
|
|
||||||
"INP (ms)",
|
|
||||||
"TTFB (ms)",
|
|
||||||
];
|
|
||||||
const lines = rows.map((row: (typeof rows)[number]) => [
|
|
||||||
row.url,
|
|
||||||
row.strategy,
|
|
||||||
row.performance,
|
|
||||||
row.accessibility,
|
|
||||||
row.seo,
|
|
||||||
row.lcpMs,
|
|
||||||
row.cls,
|
|
||||||
row.inpMs,
|
|
||||||
row.ttfbMs,
|
|
||||||
]);
|
|
||||||
|
|
||||||
downloadCsv("audit-performance.csv", buildCsv(headers, lines));
|
if (format === "sheets") {
|
||||||
|
void exportTableToSheets({
|
||||||
|
headers: PERFORMANCE_HEADERS,
|
||||||
|
rows,
|
||||||
|
feature: "audit_performance",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadCsv("audit-performance.csv", buildCsv(PERFORMANCE_HEADERS, rows));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
import { HeaderHelpLabel } from "@/client/features/keywords/components";
|
import { HeaderHelpLabel } from "@/client/features/keywords/components";
|
||||||
import { ArrowLeft, Download, SlidersHorizontal } from "lucide-react";
|
import { ArrowLeft, Download, SlidersHorizontal } from "lucide-react";
|
||||||
|
import { ExportToSheetsButton } from "@/client/components/table/ExportToSheetsButton";
|
||||||
import {
|
import {
|
||||||
BacklinksNewLostChart,
|
BacklinksNewLostChart,
|
||||||
BacklinksTrendChart,
|
BacklinksTrendChart,
|
||||||
@ -16,7 +18,7 @@ import {
|
|||||||
TAB_DESCRIPTIONS,
|
TAB_DESCRIPTIONS,
|
||||||
formatRelativeTimestamp,
|
formatRelativeTimestamp,
|
||||||
} from "./backlinksPageUtils";
|
} from "./backlinksPageUtils";
|
||||||
import { exportBacklinksTabCsv } from "./export";
|
import { buildBacklinksTabExport, exportBacklinksTabCsv } from "./export";
|
||||||
import type { BacklinksFiltersState } from "./useBacklinksFilters";
|
import type { BacklinksFiltersState } from "./useBacklinksFilters";
|
||||||
|
|
||||||
export function BacklinksOverviewPanels({
|
export function BacklinksOverviewPanels({
|
||||||
@ -82,6 +84,10 @@ export function BacklinksResultsCard({
|
|||||||
exportTarget: string;
|
exportTarget: string;
|
||||||
}) {
|
}) {
|
||||||
const currentFilterCount = filters[activeTab].activeFilterCount;
|
const currentFilterCount = filters[activeTab].activeFilterCount;
|
||||||
|
const exportTable = useMemo(
|
||||||
|
() => buildBacklinksTabExport({ tab: activeTab, rows: filteredData }),
|
||||||
|
[activeTab, filteredData],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border border-base-300 rounded-xl bg-base-100 overflow-hidden">
|
<div className="border border-base-300 rounded-xl bg-base-100 overflow-hidden">
|
||||||
@ -115,19 +121,27 @@ export function BacklinksResultsCard({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
className="btn btn-sm btn-ghost justify-start lg:justify-center"
|
<ExportToSheetsButton
|
||||||
onClick={() =>
|
headers={exportTable.headers}
|
||||||
exportBacklinksTabCsv({
|
rows={exportTable.rows}
|
||||||
tab: activeTab,
|
feature={`backlinks_${activeTab}`}
|
||||||
target: exportTarget,
|
className="btn-sm"
|
||||||
rows: filteredData,
|
/>
|
||||||
})
|
<button
|
||||||
}
|
className="btn btn-sm btn-ghost justify-start lg:justify-center"
|
||||||
>
|
onClick={() =>
|
||||||
<Download className="size-4" />
|
exportBacklinksTabCsv({
|
||||||
Export CSV
|
tab: activeTab,
|
||||||
</button>
|
target: exportTarget,
|
||||||
|
rows: filteredData,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Download className="size-4" />
|
||||||
|
Export CSV
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-base-300">
|
<div className="flex items-center gap-2 px-4 py-2 border-b border-base-300">
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { buildCsv, downloadCsv } from "@/client/lib/csv";
|
import { buildCsv, type CsvValue, downloadCsv } from "@/client/lib/csv";
|
||||||
import type {
|
import type {
|
||||||
BacklinksOverviewData,
|
BacklinksOverviewData,
|
||||||
BacklinksSearchState,
|
BacklinksSearchState,
|
||||||
@ -10,103 +10,115 @@ type BacklinksFilteredData = {
|
|||||||
topPages: BacklinksOverviewData["topPages"];
|
topPages: BacklinksOverviewData["topPages"];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function buildBacklinksTabExport(args: {
|
||||||
|
tab: BacklinksSearchState["tab"];
|
||||||
|
rows: BacklinksFilteredData;
|
||||||
|
}): { headers: string[]; rows: CsvValue[][] } {
|
||||||
|
const { tab, rows } = args;
|
||||||
|
|
||||||
|
if (tab === "backlinks") {
|
||||||
|
return {
|
||||||
|
headers: [
|
||||||
|
"Domain",
|
||||||
|
"Source URL",
|
||||||
|
"Target URL",
|
||||||
|
"Anchor",
|
||||||
|
"Type",
|
||||||
|
"Dofollow",
|
||||||
|
"Rel Attributes",
|
||||||
|
"Domain Rank",
|
||||||
|
"Source Page Rank",
|
||||||
|
"Target Rank",
|
||||||
|
"Spam Score",
|
||||||
|
"First Seen",
|
||||||
|
"Last Seen",
|
||||||
|
"Lost",
|
||||||
|
"Broken",
|
||||||
|
"Links Count",
|
||||||
|
],
|
||||||
|
rows: rows.backlinks.map((row) => [
|
||||||
|
row.domainFrom,
|
||||||
|
row.urlFrom,
|
||||||
|
row.urlTo,
|
||||||
|
row.anchor,
|
||||||
|
row.itemType,
|
||||||
|
row.isDofollow,
|
||||||
|
row.relAttributes.join(", "),
|
||||||
|
row.domainFromRank,
|
||||||
|
row.pageFromRank,
|
||||||
|
row.rank,
|
||||||
|
row.spamScore,
|
||||||
|
row.firstSeen,
|
||||||
|
row.lastSeen,
|
||||||
|
row.isLost,
|
||||||
|
row.isBroken,
|
||||||
|
row.linksCount,
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tab === "domains") {
|
||||||
|
return {
|
||||||
|
headers: [
|
||||||
|
"Domain",
|
||||||
|
"Backlinks",
|
||||||
|
"Referring Pages",
|
||||||
|
"Rank",
|
||||||
|
"Spam Score",
|
||||||
|
"First Seen",
|
||||||
|
"Broken Backlinks",
|
||||||
|
"Broken Pages",
|
||||||
|
],
|
||||||
|
rows: rows.referringDomains.map((row) => [
|
||||||
|
row.domain,
|
||||||
|
row.backlinks,
|
||||||
|
row.referringPages,
|
||||||
|
row.rank,
|
||||||
|
row.spamScore,
|
||||||
|
row.firstSeen,
|
||||||
|
row.brokenBacklinks,
|
||||||
|
row.brokenPages,
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
headers: [
|
||||||
|
"Page",
|
||||||
|
"Backlinks",
|
||||||
|
"Referring Domains",
|
||||||
|
"Rank",
|
||||||
|
"Broken Backlinks",
|
||||||
|
],
|
||||||
|
rows: rows.topPages.map((row) => [
|
||||||
|
row.page,
|
||||||
|
row.backlinks,
|
||||||
|
row.referringDomains,
|
||||||
|
row.rank,
|
||||||
|
row.brokenBacklinks,
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function buildBacklinksTabCsvFile(args: {
|
export function buildBacklinksTabCsvFile(args: {
|
||||||
tab: BacklinksSearchState["tab"];
|
tab: BacklinksSearchState["tab"];
|
||||||
target: string;
|
target: string;
|
||||||
rows: BacklinksFilteredData;
|
rows: BacklinksFilteredData;
|
||||||
}) {
|
}) {
|
||||||
const { tab, target, rows } = args;
|
const { headers, rows } = buildBacklinksTabExport({
|
||||||
|
tab: args.tab,
|
||||||
if (tab === "backlinks") {
|
rows: args.rows,
|
||||||
const headers = [
|
});
|
||||||
"Domain",
|
const filenamePrefix =
|
||||||
"Source URL",
|
args.tab === "backlinks"
|
||||||
"Target URL",
|
? "backlinks"
|
||||||
"Anchor",
|
: args.tab === "domains"
|
||||||
"Type",
|
? "referring-domains"
|
||||||
"Dofollow",
|
: "top-pages";
|
||||||
"Rel Attributes",
|
|
||||||
"Domain Rank",
|
|
||||||
"Source Page Rank",
|
|
||||||
"Target Rank",
|
|
||||||
"Spam Score",
|
|
||||||
"First Seen",
|
|
||||||
"Last Seen",
|
|
||||||
"Lost",
|
|
||||||
"Broken",
|
|
||||||
"Links Count",
|
|
||||||
];
|
|
||||||
const lines = rows.backlinks.map((row) => [
|
|
||||||
row.domainFrom,
|
|
||||||
row.urlFrom,
|
|
||||||
row.urlTo,
|
|
||||||
row.anchor,
|
|
||||||
row.itemType,
|
|
||||||
row.isDofollow,
|
|
||||||
row.relAttributes.join(", "),
|
|
||||||
row.domainFromRank,
|
|
||||||
row.pageFromRank,
|
|
||||||
row.rank,
|
|
||||||
row.spamScore,
|
|
||||||
row.firstSeen,
|
|
||||||
row.lastSeen,
|
|
||||||
row.isLost,
|
|
||||||
row.isBroken,
|
|
||||||
row.linksCount,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
filename: buildFilename("backlinks", target),
|
|
||||||
content: buildCsv(headers, lines),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tab === "domains") {
|
|
||||||
const headers = [
|
|
||||||
"Domain",
|
|
||||||
"Backlinks",
|
|
||||||
"Referring Pages",
|
|
||||||
"Rank",
|
|
||||||
"Spam Score",
|
|
||||||
"First Seen",
|
|
||||||
"Broken Backlinks",
|
|
||||||
"Broken Pages",
|
|
||||||
];
|
|
||||||
const lines = rows.referringDomains.map((row) => [
|
|
||||||
row.domain,
|
|
||||||
row.backlinks,
|
|
||||||
row.referringPages,
|
|
||||||
row.rank,
|
|
||||||
row.spamScore,
|
|
||||||
row.firstSeen,
|
|
||||||
row.brokenBacklinks,
|
|
||||||
row.brokenPages,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
filename: buildFilename("referring-domains", target),
|
|
||||||
content: buildCsv(headers, lines),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const headers = [
|
|
||||||
"Page",
|
|
||||||
"Backlinks",
|
|
||||||
"Referring Domains",
|
|
||||||
"Rank",
|
|
||||||
"Broken Backlinks",
|
|
||||||
];
|
|
||||||
const lines = rows.topPages.map((row) => [
|
|
||||||
row.page,
|
|
||||||
row.backlinks,
|
|
||||||
row.referringDomains,
|
|
||||||
row.rank,
|
|
||||||
row.brokenBacklinks,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
filename: buildFilename("top-pages", target),
|
filename: buildFilename(filenamePrefix, args.target),
|
||||||
content: buildCsv(headers, lines),
|
content: buildCsv(headers, rows),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -4,7 +4,6 @@ import type {
|
|||||||
PageRow,
|
PageRow,
|
||||||
SortOrder,
|
SortOrder,
|
||||||
} from "@/client/features/domain/types";
|
} from "@/client/features/domain/types";
|
||||||
import { buildCsv, downloadCsv as downloadCsvFile } from "@/client/lib/csv";
|
|
||||||
|
|
||||||
export function toSortMode(value: string | null): DomainSortMode | undefined {
|
export function toSortMode(value: string | null): DomainSortMode | undefined {
|
||||||
if (
|
if (
|
||||||
@ -101,40 +100,32 @@ export function formatMetric(
|
|||||||
return formatNumber(value);
|
return formatNumber(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function keywordsToCsv(rows: KeywordRow[]): string {
|
type ExportTable = { headers: string[]; rows: (string | number | null)[][] };
|
||||||
const headers = [
|
|
||||||
"Keyword",
|
export function keywordsToTable(rows: KeywordRow[]): ExportTable {
|
||||||
"Rank",
|
return {
|
||||||
"Volume",
|
headers: ["Keyword", "Rank", "Volume", "Traffic", "CPC", "URL", "Score"],
|
||||||
"Traffic",
|
rows: rows.map((row) => [
|
||||||
"CPC",
|
row.keyword,
|
||||||
"URL",
|
row.position,
|
||||||
"Score",
|
row.searchVolume,
|
||||||
];
|
row.traffic,
|
||||||
const lines = rows.map((row) => [
|
row.cpc,
|
||||||
row.keyword,
|
row.relativeUrl ?? row.url,
|
||||||
row.position,
|
row.keywordDifficulty,
|
||||||
row.searchVolume,
|
]),
|
||||||
row.traffic,
|
};
|
||||||
row.cpc,
|
|
||||||
row.relativeUrl ?? row.url,
|
|
||||||
row.keywordDifficulty,
|
|
||||||
]);
|
|
||||||
return buildCsv(headers, lines);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function pagesToCsv(rows: PageRow[]): string {
|
export function pagesToTable(rows: PageRow[]): ExportTable {
|
||||||
const headers = ["Page", "Organic Traffic", "Keywords"];
|
return {
|
||||||
const lines = rows.map((row) => [
|
headers: ["Page", "Organic Traffic", "Keywords"],
|
||||||
row.relativePath ?? row.page,
|
rows: rows.map((row) => [
|
||||||
row.organicTraffic,
|
row.relativePath ?? row.page,
|
||||||
row.keywords,
|
row.organicTraffic,
|
||||||
]);
|
row.keywords,
|
||||||
return buildCsv(headers, lines);
|
]),
|
||||||
}
|
};
|
||||||
|
|
||||||
export function downloadCsv(content: string, filename: string) {
|
|
||||||
downloadCsvFile(filename, content);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveDomainPageHref(
|
export function resolveDomainPageHref(
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import {
|
|||||||
TrendingDown,
|
TrendingDown,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { ExportToSheetsButton } from "@/client/components/table/ExportToSheetsButton";
|
||||||
import type { SerpResultItem } from "@/types/keywords";
|
import type { SerpResultItem } from "@/types/keywords";
|
||||||
import { formatNumber } from "../utils";
|
import { formatNumber } from "../utils";
|
||||||
|
|
||||||
@ -48,8 +49,33 @@ export function SerpAnalysisCard({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs text-base-content/50 mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
{items.length} organic results
|
<div className="text-xs text-base-content/50">
|
||||||
|
{items.length} organic results
|
||||||
|
</div>
|
||||||
|
<ExportToSheetsButton
|
||||||
|
headers={[
|
||||||
|
"Rank",
|
||||||
|
"Title",
|
||||||
|
"URL",
|
||||||
|
"Domain",
|
||||||
|
"Traffic",
|
||||||
|
"Referring Domains",
|
||||||
|
"Backlinks",
|
||||||
|
"Rank Change",
|
||||||
|
]}
|
||||||
|
rows={items.map((item) => [
|
||||||
|
item.rank,
|
||||||
|
item.title ?? "",
|
||||||
|
item.url,
|
||||||
|
item.domain,
|
||||||
|
item.etv ?? "",
|
||||||
|
item.referringDomains ?? "",
|
||||||
|
item.backlinks ?? "",
|
||||||
|
item.isNew ? "new" : (item.rankChange ?? ""),
|
||||||
|
])}
|
||||||
|
feature="serp_analysis"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<SerpAnalysisTable items={pageItems} />
|
<SerpAnalysisTable items={pageItems} />
|
||||||
<SerpAnalysisPagination
|
<SerpAnalysisPagination
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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={() => {
|
||||||
|
|||||||
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 { 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}
|
||||||
|
|||||||
@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
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";
|
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) {
|
||||||
|
|||||||
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 { 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
|
||||||
|
|||||||
@ -14,7 +14,9 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
Copy,
|
Copy,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { buildCsv, downloadCsv } from "@/client/lib/csv";
|
import { ExportToSheetsButton } from "@/client/components/table/ExportToSheetsButton";
|
||||||
|
import { KEYWORD_RESEARCH_HEADERS } from "@/client/features/keywords/state/keywordControllerActions";
|
||||||
|
import { buildCsv, type CsvValue, downloadCsv } from "@/client/lib/csv";
|
||||||
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||||
import { captureClientEvent } from "@/client/lib/posthog";
|
import { captureClientEvent } from "@/client/lib/posthog";
|
||||||
|
|
||||||
@ -106,34 +108,34 @@ function SavedKeywordsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const savedHeaders = [...KEYWORD_RESEARCH_HEADERS, "Fetched At"];
|
||||||
|
const sheetsExportRows: CsvValue[][] = savedKeywords.map((kw) => [
|
||||||
|
kw.keyword,
|
||||||
|
kw.searchVolume ?? "",
|
||||||
|
kw.cpc ?? "",
|
||||||
|
kw.competition ?? "",
|
||||||
|
kw.keywordDifficulty ?? "",
|
||||||
|
kw.intent ?? "",
|
||||||
|
kw.fetchedAt ?? "",
|
||||||
|
]);
|
||||||
|
|
||||||
const exportCsv = () => {
|
const exportCsv = () => {
|
||||||
if (savedKeywords.length === 0) {
|
if (sheetsExportRows.length === 0) {
|
||||||
toast.error("No keywords to export");
|
toast.error("No keywords to export");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const headers = [
|
// CSV file keeps cents-formatted CPC/competition for human readability.
|
||||||
"Keyword",
|
const csvRows = sheetsExportRows.map((row) =>
|
||||||
"Volume",
|
row.map((cell, idx) =>
|
||||||
"CPC",
|
(idx === 2 || idx === 3) && typeof cell === "number"
|
||||||
"Competition",
|
? cell.toFixed(2)
|
||||||
"Difficulty",
|
: cell,
|
||||||
"Intent",
|
),
|
||||||
"Fetched At",
|
);
|
||||||
];
|
downloadCsv("saved-keywords.csv", buildCsv(savedHeaders, csvRows));
|
||||||
const csvRows = savedKeywords.map((kw) => [
|
|
||||||
kw.keyword,
|
|
||||||
kw.searchVolume ?? "",
|
|
||||||
kw.cpc?.toFixed(2) ?? "",
|
|
||||||
kw.competition?.toFixed(2) ?? "",
|
|
||||||
kw.keywordDifficulty ?? "",
|
|
||||||
kw.intent ?? "",
|
|
||||||
kw.fetchedAt ?? "",
|
|
||||||
]);
|
|
||||||
const csv = buildCsv(headers, csvRows);
|
|
||||||
downloadCsv("saved-keywords.csv", csv);
|
|
||||||
captureClientEvent("data:export", {
|
captureClientEvent("data:export", {
|
||||||
source_feature: "saved_keywords",
|
source_feature: "saved_keywords",
|
||||||
result_count: savedKeywords.length,
|
result_count: sheetsExportRows.length,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -148,9 +150,17 @@ function SavedKeywordsPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{savedKeywords.length > 0 && (
|
{savedKeywords.length > 0 && (
|
||||||
<button className="btn btn-sm" onClick={exportCsv}>
|
<div className="flex items-center gap-2">
|
||||||
<Download className="size-4" /> Export CSV
|
<ExportToSheetsButton
|
||||||
</button>
|
headers={savedHeaders}
|
||||||
|
rows={sheetsExportRows}
|
||||||
|
feature="saved_keywords"
|
||||||
|
className="btn-sm"
|
||||||
|
/>
|
||||||
|
<button className="btn btn-sm" onClick={exportCsv}>
|
||||||
|
<Download className="size-4" /> Export CSV
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user