181 lines
4.7 KiB
TypeScript
181 lines
4.7 KiB
TypeScript
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),
|
||
);
|
||
}
|