* fix: cap per-user audit capacity Block new audits before a user exceeds the temporary 100k audit footprint cap. Surface a clear delete-old-audits message in the audit launch UI. * fix: reserve audit capacity with Drizzle * fix: add Ireland keyword location support * feat: expand keyword country defaults * test: add keyword location smoke test * test: load keyword smoke test env locally * chore: remove keyword location smoke test * refactor: simplify keyword location state * fix: preserve explicit keyword location params
244 lines
6.3 KiB
TypeScript
244 lines
6.3 KiB
TypeScript
import { type FormEvent } from "react";
|
|
import { toast } from "sonner";
|
|
import { DEFAULT_LOCATION_CODE } from "@/client/features/keywords/locations";
|
|
import { buildCsv, downloadCsv } from "@/client/lib/csv";
|
|
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
|
import type {
|
|
KeywordMode,
|
|
ResultLimit,
|
|
} from "@/client/features/keywords/keywordResearchTypes";
|
|
import { getLanguageCode } from "@/client/features/keywords/utils";
|
|
import type { KeywordResearchRow } from "@/types/keywords";
|
|
import type { SortDir, SortField } from "@/client/features/keywords/components";
|
|
import type { KeywordResearchControllerInput } from "./useKeywordResearchController";
|
|
|
|
type ControlsFormLike = {
|
|
state: {
|
|
values: {
|
|
keyword: string;
|
|
locationCode: number;
|
|
resultLimit: ResultLimit;
|
|
mode: KeywordMode;
|
|
};
|
|
};
|
|
};
|
|
|
|
type RunSearchLike = (
|
|
args: {
|
|
projectId: string;
|
|
keywords: string[];
|
|
locationCode: number;
|
|
resultLimit: ResultLimit;
|
|
mode: KeywordMode;
|
|
},
|
|
options: {
|
|
onSuccess: (seedKeyword: string, nextRows: KeywordResearchRow[]) => void;
|
|
},
|
|
) => void;
|
|
|
|
type SearchActionParams = {
|
|
controlsForm: ControlsFormLike;
|
|
input: KeywordResearchControllerInput;
|
|
beginSearch: (seedKeyword: string, locationCode: number) => void;
|
|
runSearch: RunSearchLike;
|
|
clearSelection: () => void;
|
|
setSelectedKeyword: (keyword: KeywordResearchRow | null) => void;
|
|
setSerpKeyword: (keyword: string | null) => void;
|
|
setSerpPage: (page: number) => void;
|
|
setSearchInputError: (error: string | null) => void;
|
|
setSearchParams: (
|
|
updates: Record<string, string | number | boolean | undefined>,
|
|
) => void;
|
|
setPreferredLocationCode: (locationCode: number) => void;
|
|
};
|
|
|
|
type SaveExportActionParams = {
|
|
selectedRows: Set<string>;
|
|
filteredRows: KeywordResearchRow[];
|
|
input: KeywordResearchControllerInput;
|
|
saveKeywordsMutate: (
|
|
variables: {
|
|
projectId: string;
|
|
keywords: string[];
|
|
locationCode: number;
|
|
languageCode: string;
|
|
},
|
|
options: {
|
|
onSuccess: () => void;
|
|
onError: (error: unknown) => void;
|
|
},
|
|
) => void;
|
|
setShowSaveDialog: (show: boolean) => void;
|
|
};
|
|
|
|
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 useSearchActions(params: SearchActionParams) {
|
|
const {
|
|
controlsForm,
|
|
input,
|
|
beginSearch,
|
|
runSearch,
|
|
clearSelection,
|
|
setSelectedKeyword,
|
|
setSerpKeyword,
|
|
setSerpPage,
|
|
setSearchInputError,
|
|
setSearchParams,
|
|
setPreferredLocationCode,
|
|
} = params;
|
|
|
|
const onSearch = (
|
|
overrides?: Partial<{
|
|
keyword: string;
|
|
locationCode: number;
|
|
}>,
|
|
) => {
|
|
const values = controlsForm.state.values;
|
|
const inputKeyword = overrides?.keyword ?? values.keyword;
|
|
const activeLocation = overrides?.locationCode ?? values.locationCode;
|
|
const activeResultLimit = values.resultLimit;
|
|
const activeMode = values.mode;
|
|
const keywords = inputKeyword
|
|
.split(/[\n,]/)
|
|
.map((keyword) => keyword.trim())
|
|
.filter(Boolean);
|
|
|
|
if (keywords.length === 0) {
|
|
setSearchInputError("Please enter at least one keyword.");
|
|
return;
|
|
}
|
|
|
|
setSearchInputError(null);
|
|
setPreferredLocationCode(activeLocation);
|
|
setSearchParams({
|
|
q: inputKeyword,
|
|
loc:
|
|
input.hasExplicitLocationCode ||
|
|
activeLocation !== DEFAULT_LOCATION_CODE
|
|
? activeLocation
|
|
: undefined,
|
|
kLimit: activeResultLimit === 150 ? undefined : activeResultLimit,
|
|
mode: activeMode === "auto" ? undefined : activeMode,
|
|
});
|
|
|
|
setSelectedKeyword(null);
|
|
clearSelection();
|
|
setSerpKeyword(null);
|
|
beginSearch(keywords[0], activeLocation);
|
|
|
|
runSearch(
|
|
{
|
|
projectId: input.projectId,
|
|
keywords,
|
|
locationCode: activeLocation,
|
|
resultLimit: activeResultLimit,
|
|
mode: activeMode,
|
|
},
|
|
{
|
|
onSuccess: (seedKeyword, nextRows) => {
|
|
if (nextRows.length === 0) {
|
|
setSerpKeyword(null);
|
|
return;
|
|
}
|
|
setSerpKeyword(seedKeyword);
|
|
setSerpPage(0);
|
|
},
|
|
},
|
|
);
|
|
};
|
|
|
|
const handleSearchSubmit = (event: FormEvent) => {
|
|
event.preventDefault();
|
|
onSearch();
|
|
};
|
|
|
|
const toggleSort = (field: SortField) => {
|
|
setSearchParams(getNextSortParams(input.sortField, input.sortDir, field));
|
|
};
|
|
|
|
return { onSearch, handleSearchSubmit, toggleSort };
|
|
}
|
|
|
|
export function useSaveAndExportActions(params: SaveExportActionParams) {
|
|
const {
|
|
selectedRows,
|
|
filteredRows,
|
|
input,
|
|
saveKeywordsMutate,
|
|
setShowSaveDialog,
|
|
} = params;
|
|
|
|
const handleSaveKeywords = () => {
|
|
if (selectedRows.size === 0) {
|
|
toast.error("Select at least one keyword first");
|
|
return;
|
|
}
|
|
setShowSaveDialog(true);
|
|
};
|
|
|
|
const confirmSave = () => {
|
|
saveKeywordsMutate(
|
|
{
|
|
projectId: input.projectId,
|
|
keywords: [...selectedRows],
|
|
locationCode: input.locationCode,
|
|
languageCode: getLanguageCode(input.locationCode),
|
|
},
|
|
{
|
|
onSuccess: () => {
|
|
toast.success(`Saved ${selectedRows.size} keywords`);
|
|
setShowSaveDialog(false);
|
|
},
|
|
onError: (error: unknown) => {
|
|
toast.error(getStandardErrorMessage(error, "Save failed."));
|
|
},
|
|
},
|
|
);
|
|
};
|
|
|
|
const exportCsv = () => {
|
|
const source =
|
|
selectedRows.size > 0
|
|
? filteredRows.filter((row) => selectedRows.has(row.keyword))
|
|
: filteredRows;
|
|
if (source.length === 0) {
|
|
toast.error("No data to export");
|
|
return;
|
|
}
|
|
const headers = [
|
|
"Keyword",
|
|
"Volume",
|
|
"CPC",
|
|
"Competition",
|
|
"Difficulty",
|
|
"Intent",
|
|
];
|
|
const csvRows = source.map((row) => [
|
|
row.keyword,
|
|
row.searchVolume ?? "",
|
|
row.cpc?.toFixed(2) ?? "",
|
|
row.competition?.toFixed(2) ?? "",
|
|
row.keywordDifficulty ?? "",
|
|
row.intent,
|
|
]);
|
|
const csv = buildCsv(headers, csvRows);
|
|
downloadCsv("keyword-research.csv", csv);
|
|
};
|
|
|
|
return { handleSaveKeywords, confirmSave, exportCsv };
|
|
}
|