feat: add tabs for keyword research (#185)

This commit is contained in:
Ben Senescu 2026-05-12 19:30:19 -04:00 committed by GitHub
parent ae2031bfad
commit 0bfdc56c45
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 1196 additions and 135 deletions

View File

@ -11,7 +11,7 @@ import {
YAxis, YAxis,
} from "recharts"; } from "recharts";
import type { MonthlySearch } from "@/types/keywords"; import type { MonthlySearch } from "@/types/keywords";
import { formatNumber } from "../utils"; import { formatCompactNumber } from "../utils";
import { FloatingTooltip, useFloatingTooltip } from "./FloatingTooltip"; import { FloatingTooltip, useFloatingTooltip } from "./FloatingTooltip";
export type SortField = export type SortField =
@ -148,10 +148,10 @@ export function AreaTrendChart({ trend }: { trend: MonthlySearch[] }) {
/> />
<YAxis <YAxis
tickFormatter={(value: number | string) => tickFormatter={(value: number | string) =>
formatNumber(Number(value)) formatCompactNumber(Number(value))
} }
tick={{ fill: "var(--trend-axis-color)", fontSize: 11 }} tick={{ fill: "var(--trend-axis-color)", fontSize: 11 }}
width={56} width={44}
axisLine={false} axisLine={false}
tickLine={false} tickLine={false}
/> />

View File

@ -5,19 +5,29 @@ import {
shouldValidateFieldOnChange, shouldValidateFieldOnChange,
} from "@/client/lib/forms"; } from "@/client/lib/forms";
import { import {
MAX_KEYWORDS_PER_SUBMIT,
type KeywordMode, type KeywordMode,
type ResultLimit, type ResultLimit,
} from "@/client/features/keywords/keywordResearchTypes"; } from "@/client/features/keywords/keywordResearchTypes";
import { parseKeywordInput } from "@/client/features/keywords/state/keywordControllerActions"; import { parseKeywordInput } from "@/client/features/keywords/state/keywordControllerActions";
type KeywordTabValidationInput = {
keyword: string;
locationCode: number;
resultLimit: ResultLimit;
mode: KeywordMode;
};
type UseKeywordControlsFormInput = { type UseKeywordControlsFormInput = {
keywordInput: string; keywordInput: string;
locationCode: number; locationCode: number;
resultLimit: ResultLimit; resultLimit: ResultLimit;
keywordMode: KeywordMode; keywordMode: KeywordMode;
getOpenKeywordTabs?: () => readonly KeywordTabValidationInput[];
keywordTabsLimit?: number;
}; };
type KeywordControlsValues = { export type KeywordControlsValues = {
keyword: string; keyword: string;
locationCode: number; locationCode: number;
resultLimit: ResultLimit; resultLimit: ResultLimit;
@ -27,22 +37,86 @@ type KeywordControlsValues = {
function getKeywordSearchValidationErrors( function getKeywordSearchValidationErrors(
value: KeywordControlsValues, value: KeywordControlsValues,
shouldValidateUntouchedField: boolean, shouldValidateUntouchedField: boolean,
validateEmptyKeyword: boolean,
) { ) {
if (parseKeywordInput(value.keyword).length > 0) { const keywords = parseKeywordInput(value.keyword);
return null;
if (keywords.length === 0) {
if (!validateEmptyKeyword) return null;
return createFormValidationErrors({
fields: {
keyword: "Please enter at least one keyword.",
},
});
} }
if (!shouldValidateUntouchedField) { if (!shouldValidateUntouchedField) return null;
return null;
if (keywords.length > MAX_KEYWORDS_PER_SUBMIT) {
return createFormValidationErrors({
fields: {
keyword: `Please enter no more than ${MAX_KEYWORDS_PER_SUBMIT} keywords (one per line).`,
},
});
} }
return null;
}
function getKeywordTabCapacityError(
value: KeywordControlsValues,
openKeywordTabs: readonly KeywordTabValidationInput[] | undefined,
keywordTabsLimit: number | undefined,
) {
if (!openKeywordTabs || keywordTabsLimit == null) return null;
const keywords = parseKeywordInput(value.keyword);
if (keywords.length === 0) return null;
let simulatedOpenTabs = [...openKeywordTabs];
let skippedCount = 0;
for (const keyword of keywords) {
const input = {
keyword,
locationCode: value.locationCode,
resultLimit: value.resultLimit,
mode: value.mode,
};
const alreadyOpen = simulatedOpenTabs.some((tab) =>
keywordTabMatches(tab, input),
);
if (alreadyOpen) continue;
if (simulatedOpenTabs.length >= keywordTabsLimit) {
skippedCount += 1;
continue;
}
simulatedOpenTabs = [...simulatedOpenTabs, input];
}
if (skippedCount === 0) return null;
return createFormValidationErrors({ return createFormValidationErrors({
fields: { fields: {
keyword: "Please enter at least one keyword.", keyword: `${skippedCount} keyword${skippedCount === 1 ? "" : "s"} skipped - close a tab to open more (max ${keywordTabsLimit}).`,
}, },
}); });
} }
function keywordTabMatches(
tab: KeywordTabValidationInput,
input: KeywordTabValidationInput,
) {
return (
tab.keyword === input.keyword &&
tab.locationCode === input.locationCode &&
tab.resultLimit === input.resultLimit &&
tab.mode === input.mode
);
}
export function useKeywordControlsForm( export function useKeywordControlsForm(
input: UseKeywordControlsFormInput, input: UseKeywordControlsFormInput,
onSubmit: (value: KeywordControlsValues) => void, onSubmit: (value: KeywordControlsValues) => void,
@ -59,8 +133,15 @@ export function useKeywordControlsForm(
getKeywordSearchValidationErrors( getKeywordSearchValidationErrors(
value, value,
shouldValidateFieldOnChange(formApi, "keyword"), shouldValidateFieldOnChange(formApi, "keyword"),
false,
),
onSubmit: ({ value }) =>
getKeywordSearchValidationErrors(value, true, true) ??
getKeywordTabCapacityError(
value,
input.getOpenKeywordTabs?.(),
input.keywordTabsLimit,
), ),
onSubmit: ({ value }) => getKeywordSearchValidationErrors(value, true),
}, },
onSubmit: ({ value }) => { onSubmit: ({ value }) => {
onSubmit(value); onSubmit(value);

View File

@ -4,6 +4,7 @@ import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { captureClientEvent } from "@/client/lib/posthog"; import { captureClientEvent } from "@/client/lib/posthog";
import { LOCATIONS, getLanguageCode } from "@/client/features/keywords/utils"; import { LOCATIONS, getLanguageCode } from "@/client/features/keywords/utils";
import { DEFAULT_LOCATION_CODE } from "@/client/features/keywords/locations"; import { DEFAULT_LOCATION_CODE } from "@/client/features/keywords/locations";
import { parseKeywordInput } from "@/client/features/keywords/state/keywordControllerActions";
import { researchKeywords } from "@/serverFunctions/keywords"; import { researchKeywords } from "@/serverFunctions/keywords";
import type { import type {
KeywordMode, KeywordMode,
@ -35,16 +36,29 @@ type KeywordResearchRequest = {
mode: KeywordMode; mode: KeywordMode;
}; };
const KEYWORD_RESEARCH_STALE_TIME_MS = 24 * 60 * 60 * 1000; export const KEYWORD_RESEARCH_STALE_TIME_MS = 24 * 60 * 60 * 1000;
function parseSearchKeywords(value: string) { export function buildKeywordResearchRequest(
return value input: KeywordResearchQueryInput,
.split(/[\n,]/) ): KeywordResearchRequest | null {
.map((keyword) => keyword.trim()) const keywords = parseKeywordInput(input.keywordInput);
.filter(Boolean); const seedKeyword = keywords[0] ?? "";
if (!seedKeyword) return null;
return {
projectId: input.projectId,
keywords,
seedKeyword,
locationCode: input.locationCode,
languageCode: getLanguageCode(input.locationCode),
resultLimit: input.resultLimit,
mode: input.mode,
};
} }
function buildKeywordResearchQueryKey(request: KeywordResearchRequest | null) { export function buildKeywordResearchQueryKey(
request: KeywordResearchRequest | null,
) {
return request return request
? [ ? [
"keywordResearch", "keywordResearch",
@ -58,34 +72,35 @@ function buildKeywordResearchQueryKey(request: KeywordResearchRequest | null) {
: ["keywordResearch", "idle"]; : ["keywordResearch", "idle"];
} }
export function keywordResearchQueryFn(request: KeywordResearchRequest) {
return researchKeywords({
data: {
projectId: request.projectId,
keywords: request.keywords,
locationCode: request.locationCode,
languageCode: request.languageCode,
resultLimit: request.resultLimit,
mode: request.mode,
},
});
}
export function useKeywordResearchData( export function useKeywordResearchData(
input: KeywordResearchQueryInput, input: KeywordResearchQueryInput,
addSearch: AddSearchFn, addSearch: AddSearchFn,
) { ) {
const keywords = useMemo( const { keywordInput, locationCode, mode, projectId, resultLimit } = input;
() => parseSearchKeywords(input.keywordInput), const request = useMemo<KeywordResearchRequest | null>(
[input.keywordInput], () =>
buildKeywordResearchRequest({
keywordInput,
locationCode,
mode,
projectId,
resultLimit,
}),
[keywordInput, locationCode, mode, projectId, resultLimit],
); );
const request = useMemo<KeywordResearchRequest | null>(() => {
const seedKeyword = keywords[0] ?? "";
if (!seedKeyword) return null;
return {
projectId: input.projectId,
keywords,
seedKeyword,
locationCode: input.locationCode,
languageCode: getLanguageCode(input.locationCode),
resultLimit: input.resultLimit,
mode: input.mode,
};
}, [
input.locationCode,
input.mode,
input.projectId,
input.resultLimit,
keywords,
]);
const queryKey = useMemo( const queryKey = useMemo(
() => buildKeywordResearchQueryKey(request), () => buildKeywordResearchQueryKey(request),
[request], [request],
@ -99,16 +114,7 @@ export function useKeywordResearchData(
throw new Error("Keyword research query ran without request params"); throw new Error("Keyword research query ran without request params");
} }
return researchKeywords({ return keywordResearchQueryFn(request);
data: {
projectId: request.projectId,
keywords: request.keywords,
locationCode: request.locationCode,
languageCode: request.languageCode,
resultLimit: request.resultLimit,
mode: request.mode,
},
});
}, },
enabled: request !== null, enabled: request !== null,
staleTime: KEYWORD_RESEARCH_STALE_TIME_MS, staleTime: KEYWORD_RESEARCH_STALE_TIME_MS,

View File

@ -1,3 +1,5 @@
export const MAX_KEYWORDS_PER_SUBMIT = 5;
export type ResultLimit = 150 | 300 | 500; export type ResultLimit = 150 | 300 | 500;
export const RESULT_LIMITS: ResultLimit[] = [150, 300, 500]; export const RESULT_LIMITS: ResultLimit[] = [150, 300, 500];

View File

@ -22,6 +22,10 @@ import {
FilterTextInput, FilterTextInput,
} from "./keywordResearchDesktopFilters"; } from "./keywordResearchDesktopFilters";
import { KeywordResearchDesktopTable } from "./KeywordResearchDesktopTable"; import { KeywordResearchDesktopTable } from "./KeywordResearchDesktopTable";
import {
KeywordResearchPagination,
useKeywordResearchPagination,
} from "./KeywordResearchPagination";
const MONTH_SHORT_LABELS = [ const MONTH_SHORT_LABELS = [
"Jan", "Jan",
@ -64,7 +68,7 @@ type Props = {
export function KeywordResearchDesktopResults({ controller }: Props) { export function KeywordResearchDesktopResults({ controller }: Props) {
return ( return (
<div className="flex-1 hidden md:flex flex-col xl:flex-row overflow-y-auto xl:overflow-hidden gap-4 mt-2"> <div className="flex-1 hidden md:flex flex-col xl:flex-row overflow-y-auto xl:overflow-hidden gap-4">
<DesktopKeywordPanel controller={controller} /> <DesktopKeywordPanel controller={controller} />
<DesktopSerpPanel controller={controller} /> <DesktopSerpPanel controller={controller} />
</div> </div>
@ -114,6 +118,8 @@ function DesktopTableCard({ controller }: Props) {
sheetsExportRows, sheetsExportRows,
showFilters, showFilters,
} = controller; } = controller;
const { page, pageSize, pageRows, setPage, setPageSize } =
useKeywordResearchPagination(filteredRows);
const keywordCountLabel = const keywordCountLabel =
selectedRows.size > 0 selectedRows.size > 0
@ -192,7 +198,7 @@ function DesktopTableCard({ controller }: Props) {
{showFilters ? <DesktopFilters controller={controller} /> : null} {showFilters ? <DesktopFilters controller={controller} /> : null}
<KeywordResearchDesktopTable <KeywordResearchDesktopTable
activeFilterCount={controller.activeFilterCount} activeFilterCount={controller.activeFilterCount}
filteredRows={controller.filteredRows} filteredRows={pageRows}
overviewKeyword={controller.overviewKeyword} overviewKeyword={controller.overviewKeyword}
selectedRows={controller.selectedRows} selectedRows={controller.selectedRows}
setSelectedRows={controller.setSelectedRows} setSelectedRows={controller.setSelectedRows}
@ -202,6 +208,15 @@ function DesktopTableCard({ controller }: Props) {
resetFilters={controller.resetFilters} resetFilters={controller.resetFilters}
handleRowClick={controller.handleRowClick} handleRowClick={controller.handleRowClick}
/> />
{filteredRows.length > 0 ? (
<KeywordResearchPagination
page={page}
pageSize={pageSize}
totalCount={filteredRows.length}
onPageChange={setPage}
onPageSizeChange={setPageSize}
/>
) : null}
</div> </div>
); );
} }

View File

@ -1,7 +1,7 @@
export function KeywordResearchLoadingState() { export function KeywordResearchLoadingState() {
return ( return (
<div className="flex-1 w-full pt-1"> <div className="flex-1 w-full">
<div className="hidden md:flex h-full gap-4 mt-2"> <div className="hidden md:flex h-full gap-4">
<div className="flex-1 flex flex-col min-w-0 gap-2"> <div className="flex-1 flex flex-col min-w-0 gap-2">
<div className="rounded-xl border border-base-300 bg-base-100 p-4"> <div className="rounded-xl border border-base-300 bg-base-100 p-4">
<div className="skeleton h-5 w-56" /> <div className="skeleton h-5 w-56" />
@ -46,7 +46,7 @@ export function KeywordResearchLoadingState() {
</div> </div>
</div> </div>
<div className="md:hidden mt-2 space-y-3"> <div className="md:hidden space-y-3">
<div className="rounded-xl border border-base-300 bg-base-100 p-4 space-y-3"> <div className="rounded-xl border border-base-300 bg-base-100 p-4 space-y-3">
<div className="skeleton h-8 w-full" /> <div className="skeleton h-8 w-full" />
<div className="skeleton h-8 w-2/3" /> <div className="skeleton h-8 w-2/3" />

View File

@ -11,6 +11,10 @@ import { KEYWORD_RESEARCH_HEADERS } from "@/client/features/keywords/state/keywo
import { exportTableToSheets } from "@/client/lib/exportToSheets"; import { exportTableToSheets } from "@/client/lib/exportToSheets";
import { SerpAnalysisCard } from "@/client/features/keywords/components"; import { SerpAnalysisCard } from "@/client/features/keywords/components";
import { KeywordResearchDesktopTable } from "./KeywordResearchDesktopTable"; import { KeywordResearchDesktopTable } from "./KeywordResearchDesktopTable";
import {
KeywordResearchPagination,
useKeywordResearchPagination,
} from "./KeywordResearchPagination";
import type { KeywordResearchControllerState } from "./types"; import type { KeywordResearchControllerState } from "./types";
type Props = { type Props = {
@ -74,6 +78,8 @@ function MobileKeywordResults({ controller }: Props) {
sheetsExportRows, sheetsExportRows,
showFilters, showFilters,
} = controller; } = controller;
const { page, pageSize, pageRows, setPage, setPageSize } =
useKeywordResearchPagination(filteredRows);
const keywordCountLabel = const keywordCountLabel =
selectedRows.size > 0 selectedRows.size > 0
@ -162,7 +168,7 @@ function MobileKeywordResults({ controller }: Props) {
<KeywordResearchDesktopTable <KeywordResearchDesktopTable
activeFilterCount={controller.activeFilterCount} activeFilterCount={controller.activeFilterCount}
filteredRows={controller.filteredRows} filteredRows={pageRows}
overviewKeyword={controller.overviewKeyword} overviewKeyword={controller.overviewKeyword}
selectedRows={controller.selectedRows} selectedRows={controller.selectedRows}
setSelectedRows={controller.setSelectedRows} setSelectedRows={controller.setSelectedRows}
@ -172,6 +178,15 @@ function MobileKeywordResults({ controller }: Props) {
resetFilters={controller.resetFilters} resetFilters={controller.resetFilters}
handleRowClick={controller.handleRowClick} handleRowClick={controller.handleRowClick}
/> />
{filteredRows.length > 0 ? (
<KeywordResearchPagination
page={page}
pageSize={pageSize}
totalCount={filteredRows.length}
onPageChange={setPage}
onPageSizeChange={setPageSize}
/>
) : null}
</div> </div>
); );
} }

View File

@ -1,23 +1,274 @@
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import { useCallback, useEffect, useMemo } from "react";
import { AlertCircle, ArrowLeft } from "lucide-react"; import { AlertCircle, ArrowLeft } from "lucide-react";
import { getErrorCode } from "@/client/lib/error-messages"; import { getErrorCode } from "@/client/lib/error-messages";
import { BILLING_ROUTE } from "@/shared/billing"; import { BILLING_ROUTE } from "@/shared/billing";
import {
KEYWORD_RESEARCH_STALE_TIME_MS,
buildKeywordResearchQueryKey,
buildKeywordResearchRequest,
keywordResearchQueryFn,
} from "@/client/features/keywords/hooks/useKeywordResearchData";
import { useKeywordResearchController } from "@/client/features/keywords/state/useKeywordResearchController"; import { useKeywordResearchController } from "@/client/features/keywords/state/useKeywordResearchController";
import type { KeywordResearchControllerInput } from "@/client/features/keywords/state/useKeywordResearchController"; import type { KeywordResearchControllerInput } from "@/client/features/keywords/state/useKeywordResearchController";
import type { KeywordControlsValues } from "@/client/features/keywords/hooks/useKeywordControlsForm";
import {
parseKeywordInput,
buildKeywordSearchKey,
} from "@/client/features/keywords/state/keywordControllerActions";
import { useKeywordSearchParams } from "@/client/features/keywords/state/keywordControllerInternals";
import { useKeywordTabs } from "@/client/features/keywords/state/useKeywordTabs";
import {
getKeywordTabsSnapshot,
type OpenTabInput,
} from "@/client/features/keywords/state/keywordTabsStore";
import { DEFAULT_LOCATION_CODE } from "@/client/features/keywords/locations";
import { KeywordResearchEmptyState } from "./KeywordResearchEmptyState"; import { KeywordResearchEmptyState } from "./KeywordResearchEmptyState";
import { KeywordResearchLoadingState } from "./KeywordResearchLoadingState"; import { KeywordResearchLoadingState } from "./KeywordResearchLoadingState";
import { KeywordResearchResults } from "./KeywordResearchResults"; import { KeywordResearchResults } from "./KeywordResearchResults";
import { KeywordResearchSearchBar } from "./KeywordResearchSearchBar"; import { KeywordResearchSearchBar } from "./KeywordResearchSearchBar";
import { KeywordResearchTabStrip } from "./KeywordResearchTabStrip";
import type { KeywordResearchControllerState } from "./types"; import type { KeywordResearchControllerState } from "./types";
type Props = KeywordResearchControllerInput; type Props = Omit<KeywordResearchControllerInput, "onFormSubmit">;
export function KeywordResearchPage(input: Props) { export function KeywordResearchPage(input: Props) {
const controller = useKeywordResearchController(input); const tabs = useKeywordTabs(input.projectId);
const { openTabs, setActiveTab, findMatchingTab } = tabs;
const setSearchParams = useKeywordSearchParams();
const projectId = input.projectId;
const setSearchParamsForTab = useCallback(
(tab: OpenTabInput | null) => {
if (!tab) {
setSearchParams({
q: undefined,
loc: undefined,
kLimit: undefined,
mode: undefined,
});
return;
}
setSearchParams({
q: tab.keyword,
loc:
tab.locationCode === DEFAULT_LOCATION_CODE
? undefined
: tab.locationCode,
kLimit: tab.resultLimit === 150 ? undefined : tab.resultLimit,
mode: tab.mode === "auto" ? undefined : tab.mode,
});
},
[setSearchParams],
);
const urlInput = useMemo<OpenTabInput | null>(() => {
const keywords = parseKeywordInput(input.keywordInput);
const keyword = keywords[0];
if (!keyword) return null;
return {
keyword,
locationCode: input.locationCode,
resultLimit: input.resultLimit,
mode: input.keywordMode,
};
}, [
input.keywordInput,
input.keywordMode,
input.locationCode,
input.resultLimit,
]);
const currentUrlKey = useMemo(
() =>
buildKeywordSearchKey({
keyword: input.keywordInput,
locationCode: input.locationCode,
resultLimit: input.resultLimit,
mode: input.keywordMode,
}),
[
input.keywordInput,
input.keywordMode,
input.locationCode,
input.resultLimit,
],
);
// Effect: URL → activeTab. When the URL params resolve to a tab we already
// have, focus it. Otherwise create one matching the URL (handles deep links
// and back/forward navigation).
useEffect(() => {
if (!urlInput) {
if (getKeywordTabsSnapshot(projectId).activeTabId !== null) {
setActiveTab(null);
}
return;
}
const existing = findMatchingTab(urlInput);
if (existing) {
if (getKeywordTabsSnapshot(projectId).activeTabId !== existing.id) {
setActiveTab(existing.id);
}
return;
}
openTabs([urlInput]);
}, [urlInput, projectId, openTabs, setActiveTab, findMatchingTab]);
// Effect: activeTab → URL. After user actions (click tab, close tab, open
// tabs from a multi-keyword submit) the active tab can diverge from the URL.
// Re-align the URL so the controller below keeps reading the right query.
const activeTab = tabs.activeTab;
const activeTabUrlKey = useMemo(
() =>
activeTab
? buildKeywordSearchKey({
keyword: activeTab.keyword,
locationCode: activeTab.locationCode,
resultLimit: activeTab.resultLimit,
mode: activeTab.mode,
})
: null,
[activeTab],
);
useEffect(() => {
if (!activeTab) return;
if (currentUrlKey === activeTabUrlKey) return;
setSearchParamsForTab(activeTab);
}, [
activeTab,
activeTab?.id,
activeTab?.keyword,
activeTab?.locationCode,
activeTab?.resultLimit,
activeTab?.mode,
activeTabUrlKey,
currentUrlKey,
setSearchParamsForTab,
]);
const onFormSubmit = useCallback(
(value: KeywordControlsValues) => {
const keywords = parseKeywordInput(value.keyword);
if (keywords.length === 0) return;
const inputs: OpenTabInput[] = keywords.map((keyword) => ({
keyword,
locationCode: value.locationCode,
resultLimit: value.resultLimit,
mode: value.mode,
}));
const result = openTabs(inputs);
if (result.activeTab) setSearchParamsForTab(result.activeTab);
},
[openTabs, setSearchParamsForTab],
);
const closeTab = useCallback(
(tabId: string) => {
const result = tabs.closeTab(tabId);
if (result.closedActive) {
setSearchParamsForTab(result.nextActiveTab);
}
},
[setSearchParamsForTab, tabs],
);
const getOpenKeywordTabs = useCallback(
() => getKeywordTabsSnapshot(projectId).tabs,
[projectId],
);
const controllerInput = useMemo<Props>(
() =>
activeTab
? {
...input,
keywordInput: activeTab.keyword,
locationCode: activeTab.locationCode,
hasExplicitLocationCode: true,
resultLimit: activeTab.resultLimit,
keywordMode: activeTab.mode,
getOpenKeywordTabs,
keywordTabsLimit: tabs.limit,
}
: {
...input,
getOpenKeywordTabs,
keywordTabsLimit: tabs.limit,
},
[activeTab, getOpenKeywordTabs, input, tabs.limit],
);
const controller = useKeywordResearchController({
...controllerInput,
onFormSubmit,
});
useEffect(() => {
controller.controlsForm.setErrorMap({ onSubmit: undefined });
controller.controlsForm.setFieldMeta("keyword", (meta) => ({
...meta,
errorMap: {
...meta.errorMap,
onSubmit: undefined,
},
errorSourceMap: {
...meta.errorSourceMap,
onSubmit: undefined,
},
}));
}, [controller.controlsForm, tabs.tabs]);
// Mark the active tab as viewed once its data lands. Reads cache state via
// the same query key the controller uses, so this catches both fresh fetches
// and warm-cache loads on tab switch.
const activeRequest = useMemo(
() =>
activeTab
? buildKeywordResearchRequest({
projectId,
keywordInput: activeTab.keyword,
locationCode: activeTab.locationCode,
resultLimit: activeTab.resultLimit,
mode: activeTab.mode,
})
: null,
[activeTab, projectId],
);
const activeTabQuery = useQuery({
queryKey: buildKeywordResearchQueryKey(activeRequest),
queryFn: () => {
if (!activeRequest) throw new Error("Active tab missing request");
return keywordResearchQueryFn(activeRequest);
},
enabled: false,
staleTime: KEYWORD_RESEARCH_STALE_TIME_MS,
gcTime: KEYWORD_RESEARCH_STALE_TIME_MS,
});
const markTabViewed = tabs.markTabViewed;
useEffect(() => {
if (!activeTab) return;
if (!activeTabQuery.isSuccess) return;
const dataUpdatedAt = activeTabQuery.dataUpdatedAt;
if (dataUpdatedAt <= 0) return;
if (activeTab.viewedAt !== null && activeTab.viewedAt >= dataUpdatedAt) {
return;
}
markTabViewed(activeTab.id, dataUpdatedAt);
}, [
activeTab,
activeTabQuery.dataUpdatedAt,
activeTabQuery.isSuccess,
markTabViewed,
]);
return ( return (
<div className="px-4 py-4 md:px-6 md:py-6 pb-24 md:pb-8 overflow-auto"> <div className="px-4 py-4 md:px-6 md:py-6 pb-24 md:pb-8 overflow-auto">
<div className="mx-auto max-w-7xl space-y-4"> <div className="mx-auto flex max-w-7xl flex-col gap-5">
<div> <div>
<h1 className="text-2xl font-semibold">Keyword Research</h1> <h1 className="text-2xl font-semibold">Keyword Research</h1>
<p className="text-sm text-base-content/70"> <p className="text-sm text-base-content/70">
@ -26,6 +277,30 @@ export function KeywordResearchPage(input: Props) {
</div> </div>
<KeywordResearchSearchBar controller={controller} /> <KeywordResearchSearchBar controller={controller} />
{controller.hasSearched ? (
<div className="flex flex-col gap-2">
<Link
from="/p/$projectId/keywords"
to="/p/$projectId/keywords"
params={{ projectId }}
search={{}}
replace
className="btn btn-ghost btn-sm w-fit gap-2 px-0 text-base-content/70 hover:bg-transparent"
onClick={() => {
setActiveTab(null);
setSearchParamsForTab(null);
}}
>
<ArrowLeft className="size-4" />
Recent searches
</Link>
<KeywordResearchTabStrip
projectId={projectId}
tabs={tabs}
closeTab={closeTab}
/>
</div>
) : null}
<KeywordResearchContent <KeywordResearchContent
controller={controller} controller={controller}
projectId={input.projectId} projectId={input.projectId}
@ -43,22 +318,6 @@ function KeywordResearchContent({
controller: KeywordResearchControllerState; controller: KeywordResearchControllerState;
projectId: string; projectId: string;
}) { }) {
const recentSearchesButton = controller.hasSearched ? (
<div>
<Link
from="/p/$projectId/keywords"
to="/p/$projectId/keywords"
params={{ projectId }}
search={{}}
replace
className="btn btn-ghost btn-sm gap-2 px-0 text-base-content/70 hover:bg-transparent"
>
<ArrowLeft className="size-4" />
Recent searches
</Link>
</div>
) : null;
if (controller.isLoading) { if (controller.isLoading) {
return <KeywordResearchLoadingState />; return <KeywordResearchLoadingState />;
} }
@ -68,24 +327,21 @@ function KeywordResearchContent({
getErrorCode(controller.researchMutationError) === "INSUFFICIENT_CREDITS"; getErrorCode(controller.researchMutationError) === "INSUFFICIENT_CREDITS";
return ( return (
<div className="space-y-4 pt-1"> <div className="flex-1 flex items-center justify-center pt-1">
{recentSearchesButton} <div className="w-full max-w-xl rounded-xl border border-error/30 bg-error/10 p-5 text-error space-y-3">
<div className="flex-1 flex items-center justify-center"> <div className="flex items-start gap-2">
<div className="w-full max-w-xl rounded-xl border border-error/30 bg-error/10 p-5 text-error space-y-3"> <AlertCircle className="mt-0.5 size-4 shrink-0" />
<div className="flex items-start gap-2"> <p className="text-sm">{controller.researchError}</p>
<AlertCircle className="mt-0.5 size-4 shrink-0" />
<p className="text-sm">{controller.researchError}</p>
</div>
{isCreditsError ? (
<Link to={BILLING_ROUTE} className="btn btn-sm">
Go to Billing
</Link>
) : (
<button className="btn btn-sm" onClick={controller.retrySearch}>
Try again
</button>
)}
</div> </div>
{isCreditsError ? (
<Link to={BILLING_ROUTE} className="btn btn-sm">
Go to Billing
</Link>
) : (
<button className="btn btn-sm" onClick={controller.retrySearch}>
Try again
</button>
)}
</div> </div>
</div> </div>
); );
@ -93,22 +349,14 @@ function KeywordResearchContent({
if (controller.rows.length === 0) { if (controller.rows.length === 0) {
return ( return (
<div className="space-y-4 pt-1"> <KeywordResearchEmptyState
{recentSearchesButton} controller={controller}
<KeywordResearchEmptyState projectId={projectId}
controller={controller} />
projectId={projectId}
/>
</div>
); );
} }
return ( return <KeywordResearchResults controller={controller} />;
<div className="space-y-4 pt-1">
{recentSearchesButton}
<KeywordResearchResults controller={controller} />
</div>
);
} }
function KeywordSaveDialog({ function KeywordSaveDialog({

View File

@ -0,0 +1,150 @@
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import type { KeywordResearchRow } from "@/types/keywords";
const KEYWORD_RESEARCH_PAGE_SIZES = [50, 100, 300, 500] as const;
const DEFAULT_KEYWORD_RESEARCH_PAGE_SIZE = 50;
const KEYWORD_RESEARCH_PAGE_SIZE_STORAGE_KEY =
"keyword-research-table-page-size";
type KeywordResearchPageSize = (typeof KEYWORD_RESEARCH_PAGE_SIZES)[number];
type Props = {
page: number;
pageSize: KeywordResearchPageSize;
totalCount: number;
onPageChange: (page: number) => void;
onPageSizeChange: (pageSize: KeywordResearchPageSize) => void;
};
export function KeywordResearchPagination({
page,
pageSize,
totalCount,
onPageChange,
onPageSizeChange,
}: Props) {
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
const start = totalCount === 0 ? 0 : (page - 1) * pageSize + 1;
const end = Math.min(totalCount, page * pageSize);
return (
<div className="flex flex-col gap-3 border-t border-base-300 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
<div className="text-sm tabular-nums text-base-content/70">
{start.toLocaleString()}-{end.toLocaleString()} of{" "}
{totalCount.toLocaleString()}
</div>
<div className="flex items-center gap-6">
<label className="flex items-center gap-2 text-sm text-base-content/70">
<span className="whitespace-nowrap">Rows per page</span>
<select
className="select select-bordered select-sm w-20"
value={pageSize}
onChange={(event) =>
onPageSizeChange(parseKeywordResearchPageSize(event.target.value))
}
>
{KEYWORD_RESEARCH_PAGE_SIZES.map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
</label>
<div className="flex items-center gap-2">
<span className="whitespace-nowrap text-sm tabular-nums text-base-content/70">
Page {page.toLocaleString()} of {totalPages.toLocaleString()}
</span>
<div className="flex items-center gap-1">
<button
type="button"
className="btn btn-ghost btn-sm btn-square"
disabled={page <= 1}
onClick={() => onPageChange(page - 1)}
aria-label="Previous page"
>
<ChevronLeft className="size-4" />
</button>
<button
type="button"
className="btn btn-ghost btn-sm btn-square"
disabled={page >= totalPages}
onClick={() => onPageChange(page + 1)}
aria-label="Next page"
>
<ChevronRight className="size-4" />
</button>
</div>
</div>
</div>
</div>
);
}
function parseKeywordResearchPageSize(value: string): KeywordResearchPageSize {
const parsed = Number(value);
return (
KEYWORD_RESEARCH_PAGE_SIZES.find((size) => size === parsed) ??
DEFAULT_KEYWORD_RESEARCH_PAGE_SIZE
);
}
export function useKeywordResearchPagination(rows: KeywordResearchRow[]) {
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState<KeywordResearchPageSize>(() =>
getStoredKeywordResearchPageSize(),
);
const totalPages = Math.max(1, Math.ceil(rows.length / pageSize));
useEffect(() => {
setPage(1);
}, [rows]);
useEffect(() => {
setPage((current) => Math.min(current, totalPages));
}, [totalPages]);
const pageRows = useMemo(() => {
const start = (page - 1) * pageSize;
return rows.slice(start, start + pageSize);
}, [page, pageSize, rows]);
return {
page,
pageSize,
pageRows,
setPage,
setPageSize: (nextPageSize: KeywordResearchPageSize) => {
setPageSize(nextPageSize);
persistKeywordResearchPageSize(nextPageSize);
setPage(1);
},
totalPages,
};
}
function getStoredKeywordResearchPageSize(): KeywordResearchPageSize {
if (typeof window === "undefined") return DEFAULT_KEYWORD_RESEARCH_PAGE_SIZE;
try {
const stored = window.localStorage.getItem(
KEYWORD_RESEARCH_PAGE_SIZE_STORAGE_KEY,
);
return stored
? parseKeywordResearchPageSize(stored)
: DEFAULT_KEYWORD_RESEARCH_PAGE_SIZE;
} catch {
return DEFAULT_KEYWORD_RESEARCH_PAGE_SIZE;
}
}
function persistKeywordResearchPageSize(pageSize: KeywordResearchPageSize) {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(
KEYWORD_RESEARCH_PAGE_SIZE_STORAGE_KEY,
String(pageSize),
);
} catch {
// localStorage can be unavailable; keep the in-memory selection working.
}
}

View File

@ -8,7 +8,7 @@ type Props = {
export function KeywordResearchResults({ controller }: Props) { export function KeywordResearchResults({ controller }: Props) {
return ( return (
<div className="flex-1 flex flex-col overflow-hidden w-full pt-1"> <div className="flex-1 flex flex-col overflow-hidden w-full">
<KeywordResearchDesktopResults controller={controller} /> <KeywordResearchDesktopResults controller={controller} />
<KeywordResearchMobileResults controller={controller} /> <KeywordResearchMobileResults controller={controller} />
</div> </div>

View File

@ -4,7 +4,10 @@ import {
isResultLimit, isResultLimit,
normalizeKeywordMode, normalizeKeywordMode,
} from "@/client/features/keywords/keywordSearchParams"; } from "@/client/features/keywords/keywordSearchParams";
import { RESULT_LIMITS } from "@/client/features/keywords/keywordResearchTypes"; import {
MAX_KEYWORDS_PER_SUBMIT,
RESULT_LIMITS,
} from "@/client/features/keywords/keywordResearchTypes";
import { LOCATION_OPTIONS } from "@/client/features/keywords/locations"; import { LOCATION_OPTIONS } from "@/client/features/keywords/locations";
import type { KeywordResearchControllerState } from "./types"; import type { KeywordResearchControllerState } from "./types";
@ -12,30 +15,52 @@ type Props = {
controller: KeywordResearchControllerState; controller: KeywordResearchControllerState;
}; };
function getTextareaRows(value: string): number {
const newlines = (value.match(/\n/g) ?? []).length;
const lines = newlines + 1;
return Math.min(MAX_KEYWORDS_PER_SUBMIT, Math.max(1, lines));
}
export function KeywordResearchSearchBar({ controller }: Props) { export function KeywordResearchSearchBar({ controller }: Props) {
const { controlsForm, handleSearchSubmit, isLoading } = controller; const { controlsForm, handleSearchSubmit } = controller;
return ( return (
<div className="card border border-base-300 bg-base-100"> <div className="card border border-base-300 bg-base-100">
<div className="card-body gap-2"> <div className="card-body gap-2">
<form <form
className="flex flex-col gap-3 lg:flex-row lg:flex-wrap lg:items-center lg:gap-2" className="flex flex-col gap-3 lg:flex-row lg:flex-wrap lg:items-start lg:gap-2"
onSubmit={handleSearchSubmit} onSubmit={handleSearchSubmit}
> >
<controlsForm.Field name="keyword"> <controlsForm.Field name="keyword">
{(field) => { {(field) => {
const keywordError = getFieldError(field.state.meta.errors); const keywordError = getFieldError(field.state.meta.errors);
const rows = getTextareaRows(field.state.value);
return ( return (
<label <label
className={`input input-bordered flex items-center gap-2 w-full lg:flex-1 lg:min-w-0 lg:max-w-md ${keywordError ? "input-error" : ""}`} className={`flex w-full lg:flex-1 lg:min-w-0 lg:max-w-md items-start gap-2 rounded-lg border bg-base-100 px-4 py-3 transition-colors focus-within:border-primary ${
keywordError ? "border-error" : "border-base-300"
}`}
> >
<Search className="size-4 shrink-0 text-base-content/60" /> <Search className="mt-0.5 size-4 shrink-0 text-base-content/60" />
<input <textarea
className="grow min-w-0" className="grow min-w-0 resize-none bg-transparent text-sm leading-6 outline-none placeholder:text-base-content/40"
placeholder="Enter keyword" rows={rows}
placeholder="Enter keywords, one per line"
value={field.state.value} value={field.state.value}
onChange={(event) => field.handleChange(event.target.value)} onChange={(event) => field.handleChange(event.target.value)}
onKeyDown={(event) => {
// Cmd/Ctrl+Enter submits without leaving a stray newline.
// Bare Enter stays as the textarea default (insert newline)
// so multi-keyword input remains discoverable.
if (
event.key === "Enter" &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault();
void controlsForm.handleSubmit();
}
}}
/> />
</label> </label>
); );
@ -100,9 +125,8 @@ export function KeywordResearchSearchBar({ controller }: Props) {
<button <button
type="submit" type="submit"
className="btn btn-primary w-full px-6 font-semibold lg:w-auto lg:shrink-0" className="btn btn-primary w-full px-6 font-semibold lg:w-auto lg:shrink-0"
disabled={isLoading}
> >
{isLoading ? "Searching..." : "Search"} Search
</button> </button>
</div> </div>
</form> </form>

View File

@ -0,0 +1,132 @@
import { useQuery } from "@tanstack/react-query";
import { X } from "lucide-react";
import { memo } from "react";
import {
KEYWORD_RESEARCH_STALE_TIME_MS,
buildKeywordResearchQueryKey,
buildKeywordResearchRequest,
keywordResearchQueryFn,
} from "@/client/features/keywords/hooks/useKeywordResearchData";
import type {
KeywordTab,
UseKeywordTabsReturn,
} from "@/client/features/keywords/state/useKeywordTabs";
type Props = {
projectId: string;
tabs: UseKeywordTabsReturn;
closeTab: (tabId: string) => void;
};
export function KeywordResearchTabStrip({ projectId, tabs, closeTab }: Props) {
if (tabs.tabs.length === 0) return null;
return (
<div className="rounded-xl border border-base-300 bg-base-100 p-1">
<div
role="tablist"
className="flex min-w-0 items-stretch gap-1 overflow-x-auto"
>
{tabs.tabs.map((tab) => (
<TabPill
key={tab.id}
tab={tab}
projectId={projectId}
active={tab.id === tabs.activeTabId}
setActiveTab={tabs.setActiveTab}
closeTab={closeTab}
/>
))}
</div>
</div>
);
}
const TabPill = memo(function TabPill({
tab,
projectId,
active,
setActiveTab,
closeTab,
}: {
tab: KeywordTab;
projectId: string;
active: boolean;
setActiveTab: (tabId: string | null) => void;
closeTab: (tabId: string) => void;
}) {
const request = buildKeywordResearchRequest({
projectId,
keywordInput: tab.keyword,
locationCode: tab.locationCode,
resultLimit: tab.resultLimit,
mode: tab.mode,
});
const queryKey = buildKeywordResearchQueryKey(request);
// enabled: false — observer only. The active tab's controller owns fetching.
const query = useQuery({
queryKey,
queryFn: () => {
if (!request) throw new Error("Tab is missing a research request");
return keywordResearchQueryFn(request);
},
enabled: false,
select: () => null,
notifyOnChangeProps: ["dataUpdatedAt", "errorUpdatedAt"],
staleTime: KEYWORD_RESEARCH_STALE_TIME_MS,
gcTime: KEYWORD_RESEARCH_STALE_TIME_MS,
});
const hasResult = query.dataUpdatedAt > 0;
const unviewed =
!active &&
hasResult &&
(tab.viewedAt === null || tab.viewedAt < query.dataUpdatedAt);
const isError = query.isError;
return (
<div
role="tab"
aria-selected={active}
tabIndex={0}
onClick={() => setActiveTab(tab.id)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
setActiveTab(tab.id);
}
}}
className={`group flex shrink-0 cursor-pointer items-center gap-1.5 rounded-md px-2.5 py-1.5 text-sm transition ${
active
? "bg-base-300 text-base-content shadow-sm"
: "text-base-content/80 hover:bg-base-200"
}`}
>
<span
className="flex w-3.5 shrink-0 items-center justify-center"
aria-hidden
>
{isError ? (
<span className="size-2 rounded-full bg-error" />
) : unviewed ? (
<span className="size-2 rounded-full bg-primary" />
) : null}
</span>
<span className="max-w-[10rem] truncate font-medium" title={tab.keyword}>
{tab.keyword}
</span>
<button
type="button"
className="rounded p-0.5 text-base-content/50 opacity-60 transition hover:bg-base-content/10 hover:text-base-content hover:opacity-100 group-hover:opacity-100"
onClick={(event) => {
event.stopPropagation();
closeTab(tab.id);
}}
aria-label={`Close ${tab.keyword} tab`}
>
<X className="size-3.5" />
</button>
</div>
);
});

View File

@ -1,6 +1,6 @@
import { useNavigate } from "@tanstack/react-router"; import { useNavigate } from "@tanstack/react-router";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useState } from "react"; import { useCallback, useState } from "react";
import { usePreferredKeywordLocation } from "@/client/features/keywords/hooks/usePreferredKeywordLocation"; import { usePreferredKeywordLocation } from "@/client/features/keywords/hooks/usePreferredKeywordLocation";
import { saveKeywords } from "@/serverFunctions/keywords"; import { saveKeywords } from "@/serverFunctions/keywords";
import type { SaveKeywordsInput } from "@/types/schemas/keywords"; import type { SaveKeywordsInput } from "@/types/schemas/keywords";
@ -42,12 +42,15 @@ export function useKeywordUiState(initialShowFilters: boolean) {
export function useKeywordSearchParams() { export function useKeywordSearchParams() {
const navigate = useNavigate({ from: "/p/$projectId/keywords" }); const navigate = useNavigate({ from: "/p/$projectId/keywords" });
return (updates: Record<string, string | number | boolean | undefined>) => { return useCallback(
void navigate({ (updates: Record<string, string | number | boolean | undefined>) => {
search: (prev) => ({ ...prev, ...updates }), void navigate({
replace: true, search: (prev) => ({ ...prev, ...updates }),
}); replace: true,
}; });
},
[navigate],
);
} }
export function useKeywordSaveMutation(projectId: string) { export function useKeywordSaveMutation(projectId: string) {

View File

@ -0,0 +1,291 @@
import type {
KeywordMode,
ResultLimit,
} from "@/client/features/keywords/keywordResearchTypes";
export type KeywordTab = {
id: string;
keyword: string;
locationCode: number;
resultLimit: ResultLimit;
mode: KeywordMode;
createdAt: number;
viewedAt: number | null;
};
export type ProjectTabsState = {
tabs: KeywordTab[];
activeTabId: string | null;
};
export type OpenTabInput = {
keyword: string;
locationCode: number;
resultLimit: ResultLimit;
mode: KeywordMode;
};
type OpenTabsResult = {
opened: KeywordTab[];
focused: KeywordTab[];
activeTab: KeywordTab | null;
dropped: OpenTabInput[];
};
export const KEYWORD_TABS_LIMIT = 8;
export const EMPTY_TABS_STATE: ProjectTabsState = {
tabs: [],
activeTabId: null,
};
const STORAGE_KEY_PREFIX = "keyword-tabs:";
const CHANGE_EVENT = "keyword-tabs-change";
// Module-level cache. Returning stable references keeps useSyncExternalStore
// from infinite-looping on Object.is equality checks.
const projectStates = new Map<string, ProjectTabsState>();
function storageKey(projectId: string): string {
return `${STORAGE_KEY_PREFIX}${projectId}`;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function isResultLimit(value: unknown): value is ResultLimit {
return value === 150 || value === 300 || value === 500;
}
function isKeywordMode(value: unknown): value is KeywordMode {
return (
value === "auto" ||
value === "related" ||
value === "suggestions" ||
value === "ideas"
);
}
function parseTab(value: unknown): KeywordTab | null {
if (!isRecord(value)) return null;
if (typeof value.id !== "string" || value.id === "") return null;
if (typeof value.keyword !== "string" || value.keyword === "") return null;
if (typeof value.locationCode !== "number") return null;
if (!isResultLimit(value.resultLimit)) return null;
if (!isKeywordMode(value.mode)) return null;
if (typeof value.createdAt !== "number") return null;
const viewedAt =
value.viewedAt === null
? null
: typeof value.viewedAt === "number"
? value.viewedAt
: null;
return {
id: value.id,
keyword: value.keyword,
locationCode: value.locationCode,
resultLimit: value.resultLimit,
mode: value.mode,
createdAt: value.createdAt,
viewedAt,
};
}
function parseStoredState(value: unknown): ProjectTabsState {
if (!isRecord(value)) return EMPTY_TABS_STATE;
if (!Array.isArray(value.tabs)) return EMPTY_TABS_STATE;
const tabs: KeywordTab[] = [];
for (const raw of value.tabs) {
const parsed = parseTab(raw);
if (parsed) tabs.push(parsed);
}
const activeTabId =
typeof value.activeTabId === "string" &&
tabs.some((tab) => tab.id === value.activeTabId)
? value.activeTabId
: null;
return { tabs, activeTabId };
}
function loadFromStorage(projectId: string): ProjectTabsState {
if (typeof window === "undefined") return EMPTY_TABS_STATE;
try {
const raw = window.sessionStorage.getItem(storageKey(projectId));
if (!raw) return EMPTY_TABS_STATE;
const parsed: unknown = JSON.parse(raw);
return parseStoredState(parsed);
} catch {
return EMPTY_TABS_STATE;
}
}
function persist(projectId: string, state: ProjectTabsState) {
if (typeof window === "undefined") return;
try {
window.sessionStorage.setItem(storageKey(projectId), JSON.stringify(state));
} catch {
// sessionStorage unavailable or full; in-memory cache still works.
}
}
function notify() {
if (typeof window === "undefined") return;
window.dispatchEvent(new Event(CHANGE_EVENT));
}
export function getKeywordTabsSnapshot(projectId: string): ProjectTabsState {
let state = projectStates.get(projectId);
if (!state) {
state = loadFromStorage(projectId);
projectStates.set(projectId, state);
}
return state;
}
export function subscribeKeywordTabsStore(onChange: () => void): () => void {
if (typeof window === "undefined") return () => {};
window.addEventListener(CHANGE_EVENT, onChange);
return () => window.removeEventListener(CHANGE_EVENT, onChange);
}
function update(
projectId: string,
updater: (current: ProjectTabsState) => ProjectTabsState,
) {
const current = getKeywordTabsSnapshot(projectId);
const next = updater(current);
if (next === current) return;
projectStates.set(projectId, next);
persist(projectId, next);
notify();
}
function generateTabId(): string {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return `tab_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
}
function tabMatches(tab: KeywordTab, input: OpenTabInput): boolean {
return (
tab.keyword === input.keyword &&
tab.locationCode === input.locationCode &&
tab.resultLimit === input.resultLimit &&
tab.mode === input.mode
);
}
export function findMatchingTab(
state: ProjectTabsState,
input: OpenTabInput,
): KeywordTab | null {
return state.tabs.find((tab) => tabMatches(tab, input)) ?? null;
}
export function openTabs(
projectId: string,
inputs: OpenTabInput[],
): OpenTabsResult {
const opened: KeywordTab[] = [];
const focused: KeywordTab[] = [];
const dropped: OpenTabInput[] = [];
let resultActiveTab: KeywordTab | null = null;
update(projectId, (current) => {
let tabs = current.tabs;
let activeTabId = current.activeTabId;
for (const input of inputs) {
const existing = tabs.find((tab) => tabMatches(tab, input));
if (existing) {
focused.push(existing);
activeTabId = existing.id;
resultActiveTab = existing;
continue;
}
if (tabs.length >= KEYWORD_TABS_LIMIT) {
dropped.push(input);
continue;
}
const next: KeywordTab = {
id: generateTabId(),
keyword: input.keyword,
locationCode: input.locationCode,
resultLimit: input.resultLimit,
mode: input.mode,
createdAt: Date.now(),
viewedAt: null,
};
tabs = [...tabs, next];
opened.push(next);
activeTabId = next.id;
resultActiveTab = next;
}
if (tabs === current.tabs && activeTabId === current.activeTabId) {
return current;
}
return { tabs, activeTabId };
});
return { opened, focused, activeTab: resultActiveTab, dropped };
}
export function setActiveTab(projectId: string, tabId: string | null) {
update(projectId, (current) => {
if (current.activeTabId === tabId) return current;
if (tabId !== null && !current.tabs.some((tab) => tab.id === tabId)) {
return current;
}
return { ...current, activeTabId: tabId };
});
}
export function closeTab(
projectId: string,
tabId: string,
): { nextActiveTab: KeywordTab | null; closedActive: boolean } {
let nextActiveTab: KeywordTab | null = null;
let closedActive = false;
update(projectId, (current) => {
const index = current.tabs.findIndex((tab) => tab.id === tabId);
if (index === -1) return current;
const tabs = current.tabs.filter((tab) => tab.id !== tabId);
let activeTabId = current.activeTabId;
if (current.activeTabId === tabId) {
closedActive = true;
// Activate the right neighbor, fall back to the left, fall back to null.
const neighbor = tabs[index] ?? tabs[index - 1] ?? null;
activeTabId = neighbor?.id ?? null;
nextActiveTab = neighbor;
}
return { tabs, activeTabId };
});
return { nextActiveTab, closedActive };
}
export function markTabViewed(
projectId: string,
tabId: string,
when = Date.now(),
) {
update(projectId, (current) => {
let changed = false;
const tabs = current.tabs.map((tab) => {
if (tab.id !== tabId) return tab;
if (tab.viewedAt !== null && tab.viewedAt >= when) return tab;
changed = true;
return { ...tab, viewedAt: when };
});
if (!changed) return current;
return { ...current, tabs };
});
}

View File

@ -1,5 +1,8 @@
import { useCallback, useEffect, useRef, type FormEvent } from "react"; import { useCallback, useEffect, useRef, type FormEvent } from "react";
import { useKeywordControlsForm } from "@/client/features/keywords/hooks/useKeywordControlsForm"; import {
useKeywordControlsForm,
type KeywordControlsValues,
} from "@/client/features/keywords/hooks/useKeywordControlsForm";
import { useKeywordFiltering } from "@/client/features/keywords/hooks/useKeywordFiltering"; import { useKeywordFiltering } from "@/client/features/keywords/hooks/useKeywordFiltering";
import { useLocalKeywordFilters } from "@/client/features/keywords/hooks/useLocalKeywordFilters"; import { useLocalKeywordFilters } from "@/client/features/keywords/hooks/useLocalKeywordFilters";
import { useKeywordResearchData } from "@/client/features/keywords/hooks/useKeywordResearchData"; import { useKeywordResearchData } from "@/client/features/keywords/hooks/useKeywordResearchData";
@ -11,7 +14,7 @@ import {
type KeywordMode, type KeywordMode,
type ResultLimit, type ResultLimit,
} from "@/client/features/keywords/keywordResearchTypes"; } from "@/client/features/keywords/keywordResearchTypes";
import { DEFAULT_LOCATION_CODE } from "@/client/features/keywords/locations"; import type { OpenTabInput } from "@/client/features/keywords/state/keywordTabsStore";
import type { KeywordResearchRow } from "@/types/keywords"; import type { KeywordResearchRow } from "@/types/keywords";
import type { SortDir, SortField } from "@/client/features/keywords/components"; import type { SortDir, SortField } from "@/client/features/keywords/components";
import { import {
@ -36,6 +39,14 @@ export type KeywordResearchControllerInput = {
keywordMode: KeywordMode; keywordMode: KeywordMode;
sortField: SortField; sortField: SortField;
sortDir: SortDir; sortDir: SortDir;
getOpenKeywordTabs?: () => readonly OpenTabInput[];
keywordTabsLimit?: number;
/**
* Called when the user submits the search form. Lets the caller decide
* whether the submission opens tabs or just rewrites the URL the
* controller stays agnostic.
*/
onFormSubmit: (value: KeywordControlsValues) => void;
}; };
export function useKeywordResearchController( export function useKeywordResearchController(
@ -238,23 +249,17 @@ function useKeywordControllerState(input: KeywordResearchControllerInput) {
setSerpPage(0); setSerpPage(0);
}, [clearSelection, setSerpKeyword, setSerpPage, uiState]); }, [clearSelection, setSerpKeyword, setSerpPage, uiState]);
const onFormSubmit = input.onFormSubmit;
const controlsForm = useKeywordControlsForm( const controlsForm = useKeywordControlsForm(
{ {
...input, ...input,
locationCode, locationCode,
getOpenKeywordTabs: input.getOpenKeywordTabs,
keywordTabsLimit: input.keywordTabsLimit,
}, },
(value) => { (value) => {
setPreferredLocationCode(value.locationCode); setPreferredLocationCode(value.locationCode);
setSearchParams({ onFormSubmit(value);
q: value.keyword,
loc:
input.hasExplicitLocationCode ||
value.locationCode !== DEFAULT_LOCATION_CODE
? value.locationCode
: undefined,
kLimit: value.resultLimit === 150 ? undefined : value.resultLimit,
mode: value.mode === "auto" ? undefined : value.mode,
});
}, },
); );

View File

@ -0,0 +1,81 @@
import { useCallback, useMemo, useSyncExternalStore } from "react";
import {
EMPTY_TABS_STATE,
KEYWORD_TABS_LIMIT,
closeTab as closeTabAction,
findMatchingTab,
getKeywordTabsSnapshot,
markTabViewed as markTabViewedAction,
openTabs as openTabsAction,
setActiveTab as setActiveTabAction,
subscribeKeywordTabsStore,
type KeywordTab,
type OpenTabInput,
type ProjectTabsState,
} from "./keywordTabsStore";
function useKeywordTabsSnapshot(projectId: string): ProjectTabsState {
const getSnapshot = useCallback(
() => getKeywordTabsSnapshot(projectId),
[projectId],
);
return useSyncExternalStore(
subscribeKeywordTabsStore,
getSnapshot,
() => EMPTY_TABS_STATE,
);
}
export function useKeywordTabs(projectId: string) {
const state = useKeywordTabsSnapshot(projectId);
const openTabs = useCallback(
(inputs: OpenTabInput[]) => openTabsAction(projectId, inputs),
[projectId],
);
const closeTab = useCallback(
(tabId: string) => closeTabAction(projectId, tabId),
[projectId],
);
const setActiveTab = useCallback(
(tabId: string | null) => setActiveTabAction(projectId, tabId),
[projectId],
);
const markTabViewed = useCallback(
(tabId: string, when?: number) =>
markTabViewedAction(projectId, tabId, when),
[projectId],
);
// Reads the latest module snapshot directly so this callback is safe to use
// in effect dependency arrays — its reference is stable across renders.
const findMatching = useCallback(
(input: OpenTabInput) =>
findMatchingTab(getKeywordTabsSnapshot(projectId), input),
[projectId],
);
const activeTab = useMemo(
() => state.tabs.find((tab) => tab.id === state.activeTabId) ?? null,
[state],
);
return {
tabs: state.tabs,
activeTabId: state.activeTabId,
activeTab,
isAtCap: state.tabs.length >= KEYWORD_TABS_LIMIT,
limit: KEYWORD_TABS_LIMIT,
openTabs,
closeTab,
setActiveTab,
markTabViewed,
findMatchingTab: findMatching,
};
}
export type UseKeywordTabsReturn = ReturnType<typeof useKeywordTabs>;
export type { KeywordTab };

View File

@ -22,3 +22,11 @@ export function formatNumber(value: number | null | undefined): string {
if (value == null) return "-"; if (value == null) return "-";
return new Intl.NumberFormat().format(value); return new Intl.NumberFormat().format(value);
} }
export function formatCompactNumber(value: number | null | undefined): string {
if (value == null) return "-";
return new Intl.NumberFormat(undefined, {
notation: "compact",
maximumFractionDigits: 1,
}).format(value);
}