feat: add tabs for keyword research (#185)
This commit is contained in:
parent
ae2031bfad
commit
0bfdc56c45
@ -11,7 +11,7 @@ import {
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import type { MonthlySearch } from "@/types/keywords";
|
||||
import { formatNumber } from "../utils";
|
||||
import { formatCompactNumber } from "../utils";
|
||||
import { FloatingTooltip, useFloatingTooltip } from "./FloatingTooltip";
|
||||
|
||||
export type SortField =
|
||||
@ -148,10 +148,10 @@ export function AreaTrendChart({ trend }: { trend: MonthlySearch[] }) {
|
||||
/>
|
||||
<YAxis
|
||||
tickFormatter={(value: number | string) =>
|
||||
formatNumber(Number(value))
|
||||
formatCompactNumber(Number(value))
|
||||
}
|
||||
tick={{ fill: "var(--trend-axis-color)", fontSize: 11 }}
|
||||
width={56}
|
||||
width={44}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
|
||||
@ -5,19 +5,29 @@ import {
|
||||
shouldValidateFieldOnChange,
|
||||
} from "@/client/lib/forms";
|
||||
import {
|
||||
MAX_KEYWORDS_PER_SUBMIT,
|
||||
type KeywordMode,
|
||||
type ResultLimit,
|
||||
} from "@/client/features/keywords/keywordResearchTypes";
|
||||
import { parseKeywordInput } from "@/client/features/keywords/state/keywordControllerActions";
|
||||
|
||||
type KeywordTabValidationInput = {
|
||||
keyword: string;
|
||||
locationCode: number;
|
||||
resultLimit: ResultLimit;
|
||||
mode: KeywordMode;
|
||||
};
|
||||
|
||||
type UseKeywordControlsFormInput = {
|
||||
keywordInput: string;
|
||||
locationCode: number;
|
||||
resultLimit: ResultLimit;
|
||||
keywordMode: KeywordMode;
|
||||
getOpenKeywordTabs?: () => readonly KeywordTabValidationInput[];
|
||||
keywordTabsLimit?: number;
|
||||
};
|
||||
|
||||
type KeywordControlsValues = {
|
||||
export type KeywordControlsValues = {
|
||||
keyword: string;
|
||||
locationCode: number;
|
||||
resultLimit: ResultLimit;
|
||||
@ -27,22 +37,86 @@ type KeywordControlsValues = {
|
||||
function getKeywordSearchValidationErrors(
|
||||
value: KeywordControlsValues,
|
||||
shouldValidateUntouchedField: boolean,
|
||||
validateEmptyKeyword: boolean,
|
||||
) {
|
||||
if (parseKeywordInput(value.keyword).length > 0) {
|
||||
return null;
|
||||
const keywords = parseKeywordInput(value.keyword);
|
||||
|
||||
if (keywords.length === 0) {
|
||||
if (!validateEmptyKeyword) return null;
|
||||
return createFormValidationErrors({
|
||||
fields: {
|
||||
keyword: "Please enter at least one keyword.",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!shouldValidateUntouchedField) {
|
||||
return null;
|
||||
if (!shouldValidateUntouchedField) 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({
|
||||
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(
|
||||
input: UseKeywordControlsFormInput,
|
||||
onSubmit: (value: KeywordControlsValues) => void,
|
||||
@ -59,8 +133,15 @@ export function useKeywordControlsForm(
|
||||
getKeywordSearchValidationErrors(
|
||||
value,
|
||||
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);
|
||||
|
||||
@ -4,6 +4,7 @@ import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||
import { captureClientEvent } from "@/client/lib/posthog";
|
||||
import { LOCATIONS, getLanguageCode } from "@/client/features/keywords/utils";
|
||||
import { DEFAULT_LOCATION_CODE } from "@/client/features/keywords/locations";
|
||||
import { parseKeywordInput } from "@/client/features/keywords/state/keywordControllerActions";
|
||||
import { researchKeywords } from "@/serverFunctions/keywords";
|
||||
import type {
|
||||
KeywordMode,
|
||||
@ -35,16 +36,29 @@ type KeywordResearchRequest = {
|
||||
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) {
|
||||
return value
|
||||
.split(/[\n,]/)
|
||||
.map((keyword) => keyword.trim())
|
||||
.filter(Boolean);
|
||||
export function buildKeywordResearchRequest(
|
||||
input: KeywordResearchQueryInput,
|
||||
): KeywordResearchRequest | null {
|
||||
const keywords = parseKeywordInput(input.keywordInput);
|
||||
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
|
||||
? [
|
||||
"keywordResearch",
|
||||
@ -58,34 +72,35 @@ function buildKeywordResearchQueryKey(request: KeywordResearchRequest | null) {
|
||||
: ["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(
|
||||
input: KeywordResearchQueryInput,
|
||||
addSearch: AddSearchFn,
|
||||
) {
|
||||
const keywords = useMemo(
|
||||
() => parseSearchKeywords(input.keywordInput),
|
||||
[input.keywordInput],
|
||||
const { keywordInput, locationCode, mode, projectId, resultLimit } = input;
|
||||
const request = useMemo<KeywordResearchRequest | null>(
|
||||
() =>
|
||||
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(
|
||||
() => buildKeywordResearchQueryKey(request),
|
||||
[request],
|
||||
@ -99,16 +114,7 @@ export function useKeywordResearchData(
|
||||
throw new Error("Keyword research query ran without request params");
|
||||
}
|
||||
|
||||
return researchKeywords({
|
||||
data: {
|
||||
projectId: request.projectId,
|
||||
keywords: request.keywords,
|
||||
locationCode: request.locationCode,
|
||||
languageCode: request.languageCode,
|
||||
resultLimit: request.resultLimit,
|
||||
mode: request.mode,
|
||||
},
|
||||
});
|
||||
return keywordResearchQueryFn(request);
|
||||
},
|
||||
enabled: request !== null,
|
||||
staleTime: KEYWORD_RESEARCH_STALE_TIME_MS,
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
export const MAX_KEYWORDS_PER_SUBMIT = 5;
|
||||
|
||||
export type ResultLimit = 150 | 300 | 500;
|
||||
export const RESULT_LIMITS: ResultLimit[] = [150, 300, 500];
|
||||
|
||||
|
||||
@ -22,6 +22,10 @@ import {
|
||||
FilterTextInput,
|
||||
} from "./keywordResearchDesktopFilters";
|
||||
import { KeywordResearchDesktopTable } from "./KeywordResearchDesktopTable";
|
||||
import {
|
||||
KeywordResearchPagination,
|
||||
useKeywordResearchPagination,
|
||||
} from "./KeywordResearchPagination";
|
||||
|
||||
const MONTH_SHORT_LABELS = [
|
||||
"Jan",
|
||||
@ -64,7 +68,7 @@ type Props = {
|
||||
|
||||
export function KeywordResearchDesktopResults({ controller }: Props) {
|
||||
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} />
|
||||
<DesktopSerpPanel controller={controller} />
|
||||
</div>
|
||||
@ -114,6 +118,8 @@ function DesktopTableCard({ controller }: Props) {
|
||||
sheetsExportRows,
|
||||
showFilters,
|
||||
} = controller;
|
||||
const { page, pageSize, pageRows, setPage, setPageSize } =
|
||||
useKeywordResearchPagination(filteredRows);
|
||||
|
||||
const keywordCountLabel =
|
||||
selectedRows.size > 0
|
||||
@ -192,7 +198,7 @@ function DesktopTableCard({ controller }: Props) {
|
||||
{showFilters ? <DesktopFilters controller={controller} /> : null}
|
||||
<KeywordResearchDesktopTable
|
||||
activeFilterCount={controller.activeFilterCount}
|
||||
filteredRows={controller.filteredRows}
|
||||
filteredRows={pageRows}
|
||||
overviewKeyword={controller.overviewKeyword}
|
||||
selectedRows={controller.selectedRows}
|
||||
setSelectedRows={controller.setSelectedRows}
|
||||
@ -202,6 +208,15 @@ function DesktopTableCard({ controller }: Props) {
|
||||
resetFilters={controller.resetFilters}
|
||||
handleRowClick={controller.handleRowClick}
|
||||
/>
|
||||
{filteredRows.length > 0 ? (
|
||||
<KeywordResearchPagination
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
totalCount={filteredRows.length}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={setPageSize}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
export function KeywordResearchLoadingState() {
|
||||
return (
|
||||
<div className="flex-1 w-full pt-1">
|
||||
<div className="hidden md:flex h-full gap-4 mt-2">
|
||||
<div className="flex-1 w-full">
|
||||
<div className="hidden md:flex h-full gap-4">
|
||||
<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="skeleton h-5 w-56" />
|
||||
@ -46,7 +46,7 @@ export function KeywordResearchLoadingState() {
|
||||
</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="skeleton h-8 w-full" />
|
||||
<div className="skeleton h-8 w-2/3" />
|
||||
|
||||
@ -11,6 +11,10 @@ import { KEYWORD_RESEARCH_HEADERS } from "@/client/features/keywords/state/keywo
|
||||
import { exportTableToSheets } from "@/client/lib/exportToSheets";
|
||||
import { SerpAnalysisCard } from "@/client/features/keywords/components";
|
||||
import { KeywordResearchDesktopTable } from "./KeywordResearchDesktopTable";
|
||||
import {
|
||||
KeywordResearchPagination,
|
||||
useKeywordResearchPagination,
|
||||
} from "./KeywordResearchPagination";
|
||||
import type { KeywordResearchControllerState } from "./types";
|
||||
|
||||
type Props = {
|
||||
@ -74,6 +78,8 @@ function MobileKeywordResults({ controller }: Props) {
|
||||
sheetsExportRows,
|
||||
showFilters,
|
||||
} = controller;
|
||||
const { page, pageSize, pageRows, setPage, setPageSize } =
|
||||
useKeywordResearchPagination(filteredRows);
|
||||
|
||||
const keywordCountLabel =
|
||||
selectedRows.size > 0
|
||||
@ -162,7 +168,7 @@ function MobileKeywordResults({ controller }: Props) {
|
||||
|
||||
<KeywordResearchDesktopTable
|
||||
activeFilterCount={controller.activeFilterCount}
|
||||
filteredRows={controller.filteredRows}
|
||||
filteredRows={pageRows}
|
||||
overviewKeyword={controller.overviewKeyword}
|
||||
selectedRows={controller.selectedRows}
|
||||
setSelectedRows={controller.setSelectedRows}
|
||||
@ -172,6 +178,15 @@ function MobileKeywordResults({ controller }: Props) {
|
||||
resetFilters={controller.resetFilters}
|
||||
handleRowClick={controller.handleRowClick}
|
||||
/>
|
||||
{filteredRows.length > 0 ? (
|
||||
<KeywordResearchPagination
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
totalCount={filteredRows.length}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={setPageSize}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,23 +1,274 @@
|
||||
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 { getErrorCode } from "@/client/lib/error-messages";
|
||||
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 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 { KeywordResearchLoadingState } from "./KeywordResearchLoadingState";
|
||||
import { KeywordResearchResults } from "./KeywordResearchResults";
|
||||
import { KeywordResearchSearchBar } from "./KeywordResearchSearchBar";
|
||||
import { KeywordResearchTabStrip } from "./KeywordResearchTabStrip";
|
||||
import type { KeywordResearchControllerState } from "./types";
|
||||
|
||||
type Props = KeywordResearchControllerInput;
|
||||
type Props = Omit<KeywordResearchControllerInput, "onFormSubmit">;
|
||||
|
||||
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 (
|
||||
<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>
|
||||
<h1 className="text-2xl font-semibold">Keyword Research</h1>
|
||||
<p className="text-sm text-base-content/70">
|
||||
@ -26,6 +277,30 @@ export function KeywordResearchPage(input: Props) {
|
||||
</div>
|
||||
|
||||
<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
|
||||
controller={controller}
|
||||
projectId={input.projectId}
|
||||
@ -43,22 +318,6 @@ function KeywordResearchContent({
|
||||
controller: KeywordResearchControllerState;
|
||||
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) {
|
||||
return <KeywordResearchLoadingState />;
|
||||
}
|
||||
@ -68,24 +327,21 @@ function KeywordResearchContent({
|
||||
getErrorCode(controller.researchMutationError) === "INSUFFICIENT_CREDITS";
|
||||
|
||||
return (
|
||||
<div className="space-y-4 pt-1">
|
||||
{recentSearchesButton}
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<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 items-start gap-2">
|
||||
<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 className="flex-1 flex items-center justify-center pt-1">
|
||||
<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 items-start gap-2">
|
||||
<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>
|
||||
);
|
||||
@ -93,22 +349,14 @@ function KeywordResearchContent({
|
||||
|
||||
if (controller.rows.length === 0) {
|
||||
return (
|
||||
<div className="space-y-4 pt-1">
|
||||
{recentSearchesButton}
|
||||
<KeywordResearchEmptyState
|
||||
controller={controller}
|
||||
projectId={projectId}
|
||||
/>
|
||||
</div>
|
||||
<KeywordResearchEmptyState
|
||||
controller={controller}
|
||||
projectId={projectId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 pt-1">
|
||||
{recentSearchesButton}
|
||||
<KeywordResearchResults controller={controller} />
|
||||
</div>
|
||||
);
|
||||
return <KeywordResearchResults controller={controller} />;
|
||||
}
|
||||
|
||||
function KeywordSaveDialog({
|
||||
|
||||
150
src/client/features/keywords/page/KeywordResearchPagination.tsx
Normal file
150
src/client/features/keywords/page/KeywordResearchPagination.tsx
Normal 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.
|
||||
}
|
||||
}
|
||||
@ -8,7 +8,7 @@ type Props = {
|
||||
|
||||
export function KeywordResearchResults({ controller }: Props) {
|
||||
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} />
|
||||
<KeywordResearchMobileResults controller={controller} />
|
||||
</div>
|
||||
|
||||
@ -4,7 +4,10 @@ import {
|
||||
isResultLimit,
|
||||
normalizeKeywordMode,
|
||||
} 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 type { KeywordResearchControllerState } from "./types";
|
||||
|
||||
@ -12,30 +15,52 @@ type Props = {
|
||||
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) {
|
||||
const { controlsForm, handleSearchSubmit, isLoading } = controller;
|
||||
const { controlsForm, handleSearchSubmit } = controller;
|
||||
|
||||
return (
|
||||
<div className="card border border-base-300 bg-base-100">
|
||||
<div className="card-body gap-2">
|
||||
<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}
|
||||
>
|
||||
<controlsForm.Field name="keyword">
|
||||
{(field) => {
|
||||
const keywordError = getFieldError(field.state.meta.errors);
|
||||
const rows = getTextareaRows(field.state.value);
|
||||
|
||||
return (
|
||||
<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" />
|
||||
<input
|
||||
className="grow min-w-0"
|
||||
placeholder="Enter keyword"
|
||||
<Search className="mt-0.5 size-4 shrink-0 text-base-content/60" />
|
||||
<textarea
|
||||
className="grow min-w-0 resize-none bg-transparent text-sm leading-6 outline-none placeholder:text-base-content/40"
|
||||
rows={rows}
|
||||
placeholder="Enter keywords, one per line"
|
||||
value={field.state.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>
|
||||
);
|
||||
@ -100,9 +125,8 @@ export function KeywordResearchSearchBar({ controller }: Props) {
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary w-full px-6 font-semibold lg:w-auto lg:shrink-0"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? "Searching..." : "Search"}
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
132
src/client/features/keywords/page/KeywordResearchTabStrip.tsx
Normal file
132
src/client/features/keywords/page/KeywordResearchTabStrip.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
@ -1,6 +1,6 @@
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
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 { saveKeywords } from "@/serverFunctions/keywords";
|
||||
import type { SaveKeywordsInput } from "@/types/schemas/keywords";
|
||||
@ -42,12 +42,15 @@ export function useKeywordUiState(initialShowFilters: boolean) {
|
||||
export function useKeywordSearchParams() {
|
||||
const navigate = useNavigate({ from: "/p/$projectId/keywords" });
|
||||
|
||||
return (updates: Record<string, string | number | boolean | undefined>) => {
|
||||
void navigate({
|
||||
search: (prev) => ({ ...prev, ...updates }),
|
||||
replace: true,
|
||||
});
|
||||
};
|
||||
return useCallback(
|
||||
(updates: Record<string, string | number | boolean | undefined>) => {
|
||||
void navigate({
|
||||
search: (prev) => ({ ...prev, ...updates }),
|
||||
replace: true,
|
||||
});
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
}
|
||||
|
||||
export function useKeywordSaveMutation(projectId: string) {
|
||||
|
||||
291
src/client/features/keywords/state/keywordTabsStore.ts
Normal file
291
src/client/features/keywords/state/keywordTabsStore.ts
Normal 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 };
|
||||
});
|
||||
}
|
||||
@ -1,5 +1,8 @@
|
||||
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 { useLocalKeywordFilters } from "@/client/features/keywords/hooks/useLocalKeywordFilters";
|
||||
import { useKeywordResearchData } from "@/client/features/keywords/hooks/useKeywordResearchData";
|
||||
@ -11,7 +14,7 @@ import {
|
||||
type KeywordMode,
|
||||
type ResultLimit,
|
||||
} 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 { SortDir, SortField } from "@/client/features/keywords/components";
|
||||
import {
|
||||
@ -36,6 +39,14 @@ export type KeywordResearchControllerInput = {
|
||||
keywordMode: KeywordMode;
|
||||
sortField: SortField;
|
||||
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(
|
||||
@ -238,23 +249,17 @@ function useKeywordControllerState(input: KeywordResearchControllerInput) {
|
||||
setSerpPage(0);
|
||||
}, [clearSelection, setSerpKeyword, setSerpPage, uiState]);
|
||||
|
||||
const onFormSubmit = input.onFormSubmit;
|
||||
const controlsForm = useKeywordControlsForm(
|
||||
{
|
||||
...input,
|
||||
locationCode,
|
||||
getOpenKeywordTabs: input.getOpenKeywordTabs,
|
||||
keywordTabsLimit: input.keywordTabsLimit,
|
||||
},
|
||||
(value) => {
|
||||
setPreferredLocationCode(value.locationCode);
|
||||
setSearchParams({
|
||||
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,
|
||||
});
|
||||
onFormSubmit(value);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
81
src/client/features/keywords/state/useKeywordTabs.ts
Normal file
81
src/client/features/keywords/state/useKeywordTabs.ts
Normal 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 };
|
||||
@ -22,3 +22,11 @@ export function formatNumber(value: number | null | undefined): string {
|
||||
if (value == null) return "-";
|
||||
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);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user