← back to Open Seo
src/client/features/rank-tracking/KeywordSuggestionStep.tsx
360 lines
import { useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
type ColumnDef,
type RowSelectionState,
type SortingState,
} from "@tanstack/react-table";
import { Loader2, AlertCircle, X } from "lucide-react";
import { toast } from "sonner";
import { getDomainKeywordSuggestions } from "@/serverFunctions/domain";
import { addTrackingKeywords } from "@/serverFunctions/rank-tracking";
import { isLabsLocationCode } from "@/client/features/keywords/locations";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
import {
AppDataTable,
makeSelectionColumn,
useAppTable,
} from "@/client/components/table/AppDataTable";
import { SortableHeader } from "./RankTrackingColumns";
import {
applyShiftRangeSelection,
type SelectionAnchor,
} from "@/client/components/table/tableSelection";
type SuggestedKeyword = {
keyword: string;
position: number | null;
searchVolume: number | null;
traffic: number | null;
};
const PRE_SELECT_COUNT = 20;
const baseColumns: ColumnDef<SuggestedKeyword>[] = [
{
id: "keyword",
accessorKey: "keyword",
header: ({ column }) => (
<SortableHeader
column={column}
label="Keyword"
id="keyword"
tooltip="The search term this domain ranks for"
/>
),
cell: ({ getValue }) => (
<span className="font-medium">{getValue<string>()}</span>
),
sortingFn: "alphanumeric",
},
{
id: "position",
accessorKey: "position",
header: ({ column }) => (
<SortableHeader
column={column}
label="Position"
id="position"
tooltip="Current Google ranking position"
/>
),
cell: ({ getValue }) => {
const pos = getValue<number | null>();
return pos != null ? (
pos
) : (
<span className="text-base-content/40">—</span>
);
},
sortingFn: (rowA, rowB) => {
const a = rowA.original.position ?? 999;
const b = rowB.original.position ?? 999;
return a - b;
},
},
{
id: "searchVolume",
accessorKey: "searchVolume",
header: ({ column }) => (
<SortableHeader
column={column}
label="Volume"
id="searchVolume"
tooltip="Monthly search volume"
/>
),
cell: ({ getValue }) => {
const vol = getValue<number | null>();
return vol != null ? (
vol.toLocaleString()
) : (
<span className="text-base-content/40">—</span>
);
},
sortingFn: (rowA, rowB) => {
const a = rowA.original.searchVolume ?? 0;
const b = rowB.original.searchVolume ?? 0;
return a - b;
},
},
{
id: "traffic",
accessorKey: "traffic",
header: ({ column }) => (
<SortableHeader
column={column}
label="Traffic"
id="traffic"
tooltip="Estimated monthly organic traffic"
/>
),
cell: ({ getValue }) => {
const traffic = getValue<number | null>();
return traffic != null ? (
Math.round(traffic).toLocaleString()
) : (
<span className="text-base-content/40">—</span>
);
},
sortingFn: (rowA, rowB) => {
const a = rowA.original.traffic ?? 0;
const b = rowB.original.traffic ?? 0;
return a - b;
},
},
];
type Props = {
configId: string;
projectId: string;
domain: string;
locationCode: number;
languageCode: string;
onDone: (configId: string) => void;
onClose: () => void;
};
export function KeywordSuggestionStep({
configId,
projectId,
domain,
locationCode,
onDone,
onClose,
}: Props) {
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [hasInitialized, setHasInitialized] = useState(false);
const [sorting, setSorting] = useState<SortingState>([
{ id: "traffic", desc: true },
]);
const selectAnchorRef = useRef<SelectionAnchor | null>(null);
const columns = useMemo<ColumnDef<SuggestedKeyword>[]>(
() => [
makeSelectionColumn<SuggestedKeyword>(selectAnchorRef),
...baseColumns,
],
[],
);
// Ranked-keyword suggestions are Labs-backed; countries served from Google
// Ads keyword data (e.g. Iceland) have no ranking data to suggest from.
const labsSupported = isLabsLocationCode(locationCode);
const suggestionsQuery = useQuery({
queryKey: ["domainKeywordSuggestions", projectId, domain, locationCode],
queryFn: () =>
getDomainKeywordSuggestions({
data: { projectId, domain, locationCode },
}),
enabled: labsSupported,
});
const data = suggestionsQuery.data ?? [];
// Pre-select top 20 by traffic once data loads.
useEffect(() => {
const items = suggestionsQuery.data;
if (items && items.length > 0 && !hasInitialized) {
const indexed = items.map((item, i) => ({
index: i,
traffic: item.traffic ?? 0,
}));
indexed.sort((a, b) => b.traffic - a.traffic);
const initial: RowSelectionState = {};
for (let i = 0; i < Math.min(PRE_SELECT_COUNT, indexed.length); i++) {
initial[indexed[i].index] = true;
}
setRowSelection(initial);
setHasInitialized(true);
}
}, [suggestionsQuery.data, hasInitialized]);
const table = useAppTable({
data,
columns,
state: { rowSelection, sorting },
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
withSorting: true,
enableRowSelection: true,
});
const selectedCount = Object.keys(rowSelection).filter(
(k) => rowSelection[k],
).length;
const addMutation = useMutation({
mutationFn: (keywords: string[]) =>
addTrackingKeywords({ data: { projectId, configId, keywords } }),
onSuccess: (result) => {
toast.success(`Added ${result.added} keywords for tracking`);
onDone(configId);
},
onError: (error) => {
toast.error(getStandardErrorMessage(error, "Failed to add keywords"));
},
});
const handleAdd = () => {
const selectedKeywords = table
.getSelectedRowModel()
.rows.map((row) => row.original.keyword);
if (selectedKeywords.length > 0) {
addMutation.mutate(selectedKeywords);
}
};
const sectionHeader = (title: string) => (
<div className="flex items-center justify-between">
<h2 id="keyword-suggestions-title" className="text-lg font-semibold">
{title}
</h2>
<button className="btn btn-ghost btn-sm btn-square" onClick={onClose}>
<X className="size-4" />
</button>
</div>
);
if (!labsSupported) {
return (
<>
{sectionHeader("Add keywords manually")}
<div className="flex flex-col items-center justify-center gap-3 py-16">
<p className="text-xs text-base-content/50">
Ranked-keyword suggestions aren't available for this country.
Continue and add the keywords you want to track manually.
</p>
<button className="btn btn-primary btn-sm mt-2" onClick={onClose}>
Continue
</button>
</div>
</>
);
}
// Loading state
if (suggestionsQuery.isLoading) {
return (
<>
{sectionHeader("Finding your top keywords...")}
<div className="flex flex-col items-center justify-center gap-3 py-16">
<Loader2 className="size-8 animate-spin text-primary" />
<p className="text-xs text-base-content/50">
This usually takes a few seconds
</p>
</div>
</>
);
}
// Error state
if (suggestionsQuery.isError) {
return (
<>
{sectionHeader("Couldn't fetch keywords")}
<div className="flex flex-col items-center justify-center gap-3 py-16">
<AlertCircle className="size-8 text-error" />
<p className="text-xs text-base-content/50">
You can skip this step and add keywords manually later.
</p>
<div className="flex gap-2 mt-2">
<button className="btn btn-primary btn-sm" onClick={onClose}>
Skip
</button>
</div>
</div>
</>
);
}
// Empty state
if (data.length === 0) {
return (
<>
{sectionHeader("No rankings found")}
<div className="flex flex-col items-center justify-center gap-3 py-16">
<p className="text-xs text-base-content/50">
We couldn't find any keywords {domain} currently ranks for. You can
add keywords manually.
</p>
<button className="btn btn-primary btn-sm mt-2" onClick={onClose}>
Skip
</button>
</div>
</>
);
}
// Data loaded
return (
<div className="flex flex-col gap-3">
{sectionHeader("Choose keywords to track")}
<div className="flex items-center justify-between">
<p className="text-sm text-base-content/60">
We found {data.length} keywords {domain} ranks for.
</p>
</div>
<AppDataTable
table={table}
className="table table-xs table-pin-rows w-full"
wrapperClassName="overflow-y-auto max-h-[400px] border border-base-300 rounded-lg"
stickyHeader
getRowProps={(row) => ({
className: "hover:bg-base-200/50 cursor-pointer",
onClick: (event) => {
if (applyShiftRangeSelection(event, row, table, selectAnchorRef)) {
return;
}
row.toggleSelected();
},
})}
/>
<div className="flex items-center justify-between gap-3 pt-1">
<p className="text-xs text-base-content/60">
{selectedCount} of {data.length} selected
</p>
<div className="flex items-center gap-2">
<button className="btn btn-ghost btn-sm" onClick={onClose}>
Skip
</button>
<button
type="button"
className="btn btn-primary btn-sm"
onClick={handleAdd}
disabled={addMutation.isPending || selectedCount === 0}
>
{addMutation.isPending && (
<Loader2 className="size-3.5 animate-spin" />
)}
Save Keyword{selectedCount !== 1 ? "s" : ""}
</button>
</div>
</div>
</div>
);
}