← 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),
  );
}