diff --git a/src/client/components/table/TableBulkActionBar.tsx b/src/client/components/table/TableBulkActionBar.tsx new file mode 100644 index 0000000..2890d0e --- /dev/null +++ b/src/client/components/table/TableBulkActionBar.tsx @@ -0,0 +1,129 @@ +import { ChevronDown, Download, Loader2, X } from "lucide-react"; +import type { ReactNode } from "react"; + +export function TableBulkActionBar({ + selectedCount, + selectedLabel = "selected", + actions, + onClear, + placement = "fixed", +}: { + selectedCount: number; + selectedLabel?: string; + actions: ReactNode; + onClear: () => void; + placement?: "fixed" | "inline"; +}) { + if (selectedCount === 0) return null; + + const wrapperClass = + placement === "fixed" + ? "pointer-events-none fixed inset-x-0 bottom-6 z-30 flex justify-center px-4" + : "flex justify-center"; + const toolbarClass = + placement === "fixed" + ? "pointer-events-auto flex items-stretch overflow-visible rounded-xl border border-base-content/15 bg-base-300/85 shadow-2xl backdrop-blur" + : "flex items-stretch overflow-visible rounded-xl border border-base-content/15 bg-base-200"; + + return ( +
+
+
+ + {selectedCount} + {selectedLabel} +
+ {actions} +
+
+ ); +} + +export function TableBulkActionButton({ + icon, + children, + onClick, + disabled, + variant = "default", +}: { + icon?: ReactNode; + children: ReactNode; + onClick: () => void; + disabled?: boolean; + variant?: "default" | "danger"; +}) { + const color = + variant === "danger" + ? "text-error hover:bg-error/10" + : "text-base-content/85 hover:bg-base-content/10"; + + return ( + + ); +} + +export function TableBulkExportMenu({ + actions, + busy, +}: { + actions: Array<{ + label: ReactNode; + icon?: ReactNode; + onClick: () => void; + disabled?: boolean; + }>; + busy?: boolean; +}) { + return ( +
+ + +
+ ); +} diff --git a/src/client/features/domain/DomainOverviewPage.tsx b/src/client/features/domain/DomainOverviewPage.tsx index a03132e..b7dcbb4 100644 --- a/src/client/features/domain/DomainOverviewPage.tsx +++ b/src/client/features/domain/DomainOverviewPage.tsx @@ -132,6 +132,7 @@ export function DomainOverviewPage({ currentSortOrder={state.currentSortOrder} searchDraft={state.searchDraft} selectedKeywords={state.selectedKeywords} + setSelectedKeywords={state.setSelectedKeywords} visibleKeywords={state.visibleKeywords} filteredKeywords={state.filteredKeywords} pagedPages={state.pagedPages} diff --git a/src/client/features/domain/components/DomainResultsCard.tsx b/src/client/features/domain/components/DomainResultsCard.tsx index 943f0e7..aa28ef0 100644 --- a/src/client/features/domain/components/DomainResultsCard.tsx +++ b/src/client/features/domain/components/DomainResultsCard.tsx @@ -24,6 +24,11 @@ import { import { buildCsv, downloadCsv } from "@/client/lib/csv"; import { exportTableToSheets } from "@/client/lib/exportToSheets"; import { captureClientEvent } from "@/client/lib/posthog"; +import { + TableBulkActionBar, + TableBulkActionButton, + TableBulkExportMenu, +} from "@/client/components/table/TableBulkActionBar"; import type { DomainActiveTab, DomainOverviewData, @@ -41,6 +46,7 @@ type Props = { currentSortOrder: SortOrder; searchDraft: string; selectedKeywords: Set; + setSelectedKeywords: Dispatch>>; visibleKeywords: string[]; filteredKeywords: KeywordRow[]; pagedPages: PageRow[]; @@ -85,6 +91,7 @@ export function DomainResultsCard({ currentSortOrder, searchDraft, selectedKeywords, + setSelectedKeywords, visibleKeywords, filteredKeywords, pagedPages, @@ -119,6 +126,10 @@ export function DomainResultsCard({ const exportTable = isKeywordsTab ? keywordsToTable(filteredKeywords) : pagesToTable(pagedPages); + const selectedKeywordRows = filteredKeywords.filter((row) => + selectedKeywords.has(row.keyword), + ); + const selectedKeywordExportTable = keywordsToTable(selectedKeywordRows); const handleCopy = async () => { const text = JSON.stringify(currentRows, null, 2); @@ -147,6 +158,28 @@ export function DomainResultsCard({ }); } }; + const handleExportSelectionToSheets = () => { + void exportTableToSheets({ + headers: selectedKeywordExportTable.headers, + rows: selectedKeywordExportTable.rows, + feature: "domain_overview", + }); + }; + const handleDownloadSelectionCsv = () => { + downloadCsv( + `${overview.domain}-selected-keywords.csv`, + buildCsv( + selectedKeywordExportTable.headers, + selectedKeywordExportTable.rows, + ), + ); + + captureClientEvent("data:export", { + source_feature: "domain_overview", + result_count: selectedKeywordRows.length, + scope: "selection", + }); + }; return (
@@ -190,20 +223,6 @@ export function DomainResultsCard({
- {activeTab === "keywords" ? ( - - ) : null}
@@ -243,6 +262,38 @@ export function DomainResultsCard({
+ {activeTab === "keywords" ? ( + setSelectedKeywords(new Set())} + actions={ +
+ } + onClick={onSaveKeywords} + disabled={!canSaveKeywords} + > + Save Keywords + + , + onClick: handleExportSelectionToSheets, + }, + { + label: "Download CSV", + icon: , + onClick: handleDownloadSelectionCsv, + }, + ]} + /> +
+ } + /> + ) : null} +
{isKeywordsTab ? (
+ controller.setSelectedRows(new Set())} + actions={ +
+ } + onClick={controller.handleSaveKeywords} + > + Save Keywords + + , + onClick: handleExportSelectionToSheets, + }, + { + label: "Export CSV", + icon: , + onClick: handleExportSelectionCsv, + }, + ]} + /> +
+ } + /> + {showFilters ? : null} 0; + const selectedExportRows = filteredRows + .filter((row) => selectedRows.has(row.keyword)) + .map(keywordResearchExportRow); const handleExportToSheets = () => { void exportTableToSheets({ headers: KEYWORD_RESEARCH_HEADERS, @@ -96,6 +109,21 @@ function MobileKeywordResults({ controller }: Props) { feature: "keyword_research", }); }; + const handleExportSelectionToSheets = () => { + void exportTableToSheets({ + headers: KEYWORD_RESEARCH_HEADERS, + rows: selectedExportRows, + feature: "keyword_research", + }); + }; + const handleExportSelectionCsv = () => { + downloadKeywordResearchCsv(selectedExportRows); + captureClientEvent("data:export", { + source_feature: "keyword_research", + result_count: selectedExportRows.length, + scope: "selection", + }); + }; return (
@@ -127,13 +155,6 @@ function MobileKeywordResults({ controller }: Props) { {keywordCountLabel}
-
+ controller.setSelectedRows(new Set())} + actions={ +
+ } + onClick={controller.handleSaveKeywords} + > + Save + + , + onClick: handleExportSelectionToSheets, + }, + { + label: "Export CSV", + icon: , + onClick: handleExportSelectionCsv, + }, + ]} + /> +
+ } + /> + {showFilters ? : null} - (selectedRows.size > 0 - ? filteredRows.filter((row) => selectedRows.has(row.keyword)) - : filteredRows - ).map(keywordResearchRow), - [filteredRows, selectedRows], + () => filteredRows.map(keywordResearchExportRow), + [filteredRows], ); const exportCsv = () => { @@ -158,18 +154,7 @@ export function useSaveAndExportActions(params: SaveExportActionParams) { toast.error("No data to export"); return; } - // CSV file keeps cents-formatted CPC/competition for human readability. - const csvRows = sheetsExportRows.map((row) => - row.map((cell, idx) => - (idx === 2 || idx === 3) && typeof cell === "number" - ? cell.toFixed(2) - : cell, - ), - ); - downloadCsv( - "keyword-research.csv", - buildCsv(KEYWORD_RESEARCH_HEADERS, csvRows), - ); + downloadKeywordResearchCsv(sheetsExportRows); captureClientEvent("data:export", { source_feature: "keyword_research", result_count: sheetsExportRows.length, @@ -178,3 +163,18 @@ export function useSaveAndExportActions(params: SaveExportActionParams) { return { handleSaveKeywords, confirmSave, exportCsv, sheetsExportRows }; } + +export function downloadKeywordResearchCsv(rows: CsvValue[][]) { + // CSV file keeps cents-formatted CPC/competition for human readability. + const csvRows = rows.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), + ); +} diff --git a/src/client/features/rank-tracking/KeywordSuggestionStep.tsx b/src/client/features/rank-tracking/KeywordSuggestionStep.tsx index 18767a7..447e5db 100644 --- a/src/client/features/rank-tracking/KeywordSuggestionStep.tsx +++ b/src/client/features/rank-tracking/KeywordSuggestionStep.tsx @@ -5,8 +5,11 @@ import { type RowSelectionState, type SortingState, } from "@tanstack/react-table"; -import { Loader2, AlertCircle, X } from "lucide-react"; +import { Loader2, AlertCircle, FileDown, Sheet, X } 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 { getDomainKeywordSuggestions } from "@/serverFunctions/domain"; import { addTrackingKeywords } from "@/serverFunctions/rank-tracking"; import { getStandardErrorMessage } from "@/client/lib/error-messages"; @@ -15,6 +18,11 @@ import { makeSelectionColumn, useAppTable, } from "@/client/components/table/AppDataTable"; +import { + TableBulkActionBar, + TableBulkActionButton, + TableBulkExportMenu, +} from "@/client/components/table/TableBulkActionBar"; import { SortableHeader } from "./RankTrackingColumns"; import { applyShiftRangeSelection, @@ -29,6 +37,12 @@ type SuggestedKeyword = { }; const PRE_SELECT_COUNT = 20; +const SUGGESTED_KEYWORD_EXPORT_HEADERS = [ + "Keyword", + "Position", + "Volume", + "Traffic", +]; const baseColumns: ColumnDef[] = [ { @@ -228,6 +242,32 @@ export function KeywordSuggestionStep({ addMutation.mutate(selectedKeywords); } }; + const selectedSuggestionRows = table + .getSelectedRowModel() + .rows.map((row) => row.original); + const selectedSuggestionExportRows = selectedSuggestionRows.map((row) => [ + row.keyword, + row.position ?? "", + row.searchVolume ?? "", + row.traffic ?? "", + ]); + const handleExportSelectionToSheets = () => { + void exportTableToSheets({ + headers: SUGGESTED_KEYWORD_EXPORT_HEADERS, + rows: selectedSuggestionExportRows, + feature: "rank_tracking", + }); + }; + const handleExportSelectionCsv = () => { + downloadCsv( + `rank-tracking-suggestions-${domain}.csv`, + buildCsv(SUGGESTED_KEYWORD_EXPORT_HEADERS, selectedSuggestionExportRows), + ); + captureClientEvent("rank_tracking:suggestions_export_csv", { + result_count: selectedSuggestionRows.length, + scope: "selection", + }); + }; const sectionHeader = (title: string) => (
@@ -307,9 +347,6 @@ export function KeywordSuggestionStep({

We found {data.length} keywords {domain} ranks for.

- - {selectedCount} of {data.length} selected -
-
+
- + table.resetRowSelection()} + placement="inline" + actions={ +
+ + ) : undefined + } + onClick={handleAdd} + disabled={addMutation.isPending} + > + Add Keyword{selectedCount !== 1 ? "s" : ""} + + , + onClick: handleExportSelectionToSheets, + }, + { + label: "Export CSV", + icon: , + onClick: handleExportSelectionCsv, + }, + ]} + /> +
+ } + />
); diff --git a/src/client/features/rank-tracking/RankTrackingTable.tsx b/src/client/features/rank-tracking/RankTrackingTable.tsx index 623e737..a2181f4 100644 --- a/src/client/features/rank-tracking/RankTrackingTable.tsx +++ b/src/client/features/rank-tracking/RankTrackingTable.tsx @@ -1,16 +1,26 @@ import { useRef, useState } from "react"; import { toast } from "sonner"; -import { Loader2, Trash2 } from "lucide-react"; +import { FileDown, Loader2, Sheet, Trash2 } from "lucide-react"; import { Modal } from "@/client/components/Modal"; import { AppDataTable, useAppTable, } from "@/client/components/table/AppDataTable"; +import { + TableBulkActionBar, + TableBulkActionButton, + TableBulkExportMenu, +} from "@/client/components/table/TableBulkActionBar"; +import { buildCsv } from "@/client/lib/csv"; +import { downloadCsv } from "@/client/lib/csv"; +import { exportTableToSheets } from "@/client/lib/exportToSheets"; +import { captureClientEvent } from "@/client/lib/posthog"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { removeTrackingKeywords } from "@/serverFunctions/rank-tracking"; import { getStandardErrorMessage } from "@/client/lib/error-messages"; import type { RankTrackingRow } from "@/types/schemas/rank-tracking"; import { useRankTrackingColumns } from "./RankTrackingColumns"; +import { buildRankTrackingExport } from "./RankTrackingTableParts"; import type { SelectionAnchor } from "@/client/components/table/tableSelection"; export function RankTrackingTable({ @@ -59,6 +69,38 @@ export function RankTrackingTable({ // Only includes rows that are in the current data (respects parent filtering) const selectedRows = table.getSelectedRowModel().rows; const selectedCount = selectedRows.length; + const selectedRankRows = selectedRows.map((row) => row.original); + + const exportSelectionToSheets = () => { + const { headers, rows: exportRows } = buildRankTrackingExport( + selectedRankRows, + showDesktop, + showMobile, + ); + void exportTableToSheets({ + headers, + rows: exportRows, + feature: "rank_tracking", + }); + }; + + const exportSelectionCsv = () => { + const { headers, rows: exportRows } = buildRankTrackingExport( + selectedRankRows, + showDesktop, + showMobile, + ); + const csvRows = exportRows.map((row) => + row.map((cell, idx) => + idx === 3 && typeof cell === "number" ? cell.toFixed(2) : cell, + ), + ); + downloadCsv( + `rank-tracking-${domain}-selected.csv`, + buildCsv(headers, csvRows), + ); + captureClientEvent("rank_tracking:export_csv", { scope: "selection" }); + }; const removeMutation = useMutation({ mutationFn: (keywordIds: string[]) => @@ -101,28 +143,35 @@ export function RankTrackingTable({ return ( <> - {/* Bulk action bar */} - {selectedCount > 0 && ( -
- - {selectedCount} keyword - {selectedCount !== 1 ? "s" : ""} selected - - - -
- )} + table.resetRowSelection()} + actions={ +
+ } + onClick={() => setShowConfirm(true)} + variant="danger" + > + Remove + + , + onClick: exportSelectionToSheets, + }, + { + label: "Export CSV", + icon: , + onClick: exportSelectionCsv, + }, + ]} + /> +
+ } + /> {/* Confirm modal */} {showConfirm && ( diff --git a/src/client/features/rank-tracking/RankTrackingTableParts.tsx b/src/client/features/rank-tracking/RankTrackingTableParts.tsx index 9c14be8..d1df669 100644 --- a/src/client/features/rank-tracking/RankTrackingTableParts.tsx +++ b/src/client/features/rank-tracking/RankTrackingTableParts.tsx @@ -176,7 +176,7 @@ function csvChange( return previous - current; } -function buildRankTrackingExport( +export function buildRankTrackingExport( sorted: RankTrackingRow[], showDesktop: boolean, showMobile: boolean, diff --git a/src/client/features/saved-keywords/SavedKeywordsBulkActionBar.tsx b/src/client/features/saved-keywords/SavedKeywordsBulkActionBar.tsx index c52ba70..03ddf6f 100644 --- a/src/client/features/saved-keywords/SavedKeywordsBulkActionBar.tsx +++ b/src/client/features/saved-keywords/SavedKeywordsBulkActionBar.tsx @@ -1,15 +1,9 @@ +import { Copy, FileDown, Sheet, Tags, Trash2 } from "lucide-react"; import { - ChevronDown, - Copy, - Download, - FileDown, - Loader2, - Sheet, - Tags, - Trash2, - X, -} from "lucide-react"; -import type { ReactNode } from "react"; + TableBulkActionBar, + TableBulkActionButton, + TableBulkExportMenu, +} from "@/client/components/table/TableBulkActionBar"; export function SavedKeywordsBulkActionBar({ selectedCount, @@ -34,119 +28,52 @@ export function SavedKeywordsBulkActionBar({ const exportBusy = exportingSelection != null; return ( -
-
-
- - {selectedCount} - selected -
- -
- } - onClick={onOpenTags} - > - Tag - - -
- -
    -
  • - -
  • -
  • - -
  • -
  • - -
  • -
+ Tag + + + , + onClick: onCopy, + }, + { + label: "Export to Sheets", + icon: , + onClick: onExportSheets, + }, + { + label: "Export CSV", + icon: , + onClick: onExportCsv, + }, + ]} + />
-
-
- -
-
-
- ); -} - -function ActionButton({ - icon, - children, - onClick, - disabled, -}: { - icon: ReactNode; - children: ReactNode; - onClick: () => void; - disabled?: boolean; -}) { - return ( - +
+ } + onClick={onDelete} + variant="danger" + > + Delete + +
+ + } + /> ); }