← back to Open Seo
src/client/features/keywords/state/keywordControllerActions.ts
181 lines
import { useMemo } from "react";
import { toast } from "sonner";
import { buildCsv, type CsvValue, downloadCsv } from "@/client/lib/csv";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { captureClientEvent } from "@/client/lib/posthog";
import type { KeywordResearchRow } from "@/types/keywords";
import type { SaveKeywordsInput } from "@/types/schemas/keywords";
import type { SortDir, SortField } from "@/client/features/keywords/components";
import type {
KeywordMode,
ResultLimit,
} from "@/client/features/keywords/keywordResearchTypes";
import type { KeywordResearchControllerInput } from "./useKeywordResearchController";
export const KEYWORD_RESEARCH_HEADERS = [
"Keyword",
"Volume",
"CPC",
"Competition",
"Score",
"Intent",
];
export function keywordResearchExportRow(row: KeywordResearchRow): CsvValue[] {
return [
row.keyword,
row.searchVolume ?? "",
row.cpc ?? "",
row.competition ?? "",
row.keywordDifficulty ?? "",
row.intent,
];
}
type SaveExportActionParams = {
selectedRows: Set<string>;
rows: KeywordResearchRow[];
filteredRows: KeywordResearchRow[];
input: KeywordResearchControllerInput;
saveKeywordsMutate: (
variables: SaveKeywordsInput,
options: {
onSuccess: () => void;
onError: (error: unknown) => void;
},
) => void;
setShowSaveDialog: (show: boolean) => void;
};
export function parseKeywordInput(value: string) {
return value
.split(/[\n,]/)
.map((keyword) => keyword.trim())
.filter(Boolean);
}
/**
* Stable identity for a keyword-research request. Used to dedup the
* URL-driven search trigger against the form-submit path so the same
* params don't fire two requests back-to-back.
*/
export function buildKeywordSearchKey(params: {
keyword: string;
locationCode: number | undefined;
resultLimit: ResultLimit;
mode: KeywordMode;
clickstream: boolean;
}) {
return [
parseKeywordInput(params.keyword).join(""),
params.locationCode,
params.resultLimit,
params.mode,
params.clickstream ? "cs" : "",
].join("|");
}
export function getNextSortParams(
currentField: SortField,
currentDirection: SortDir,
targetField: SortField,
): { sort: SortField; order: SortDir } {
if (currentField !== targetField) {
return { sort: targetField, order: "desc" };
}
return {
sort: currentField,
order: currentDirection === "asc" ? "desc" : "asc",
};
}
export function useSaveAndExportActions(params: SaveExportActionParams) {
const {
selectedRows,
rows,
filteredRows,
input,
saveKeywordsMutate,
setShowSaveDialog,
} = params;
const handleSaveKeywords = () => {
if (selectedRows.size === 0) {
toast.error("Select at least one keyword first");
return;
}
setShowSaveDialog(true);
};
const confirmSave = () => {
const metrics = rows
.filter((row) => selectedRows.has(row.keyword))
.map((row) => ({
keyword: row.keyword,
searchVolume: row.searchVolume,
cpc: row.cpc,
competition: row.competition,
keywordDifficulty: row.keywordDifficulty,
intent: row.intent,
monthlySearches: row.trend,
}));
saveKeywordsMutate(
{
projectId: input.projectId,
keywords: [...selectedRows],
locationCode: input.locationCode,
metrics,
},
{
onSuccess: () => {
captureClientEvent("keyword:save", {
source_feature: "keyword_research",
keyword_count: selectedRows.size,
});
toast.success(`Saved ${selectedRows.size} keywords`);
setShowSaveDialog(false);
},
onError: (error: unknown) => {
toast.error(getStandardErrorMessage(error, "Save failed."));
},
},
);
};
const sheetsExportRows: CsvValue[][] = useMemo(
() => filteredRows.map(keywordResearchExportRow),
[filteredRows],
);
const exportCsv = () => {
if (sheetsExportRows.length === 0) {
toast.error("No data to export");
return;
}
downloadKeywordResearchCsv(sheetsExportRows);
captureClientEvent("data:export", {
source_feature: "keyword_research",
result_count: sheetsExportRows.length,
});
};
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),
);
}