diff --git a/src/client/features/keywords/components/DisplayPrimitives.tsx b/src/client/features/keywords/components/DisplayPrimitives.tsx index c7a551a..cd51db4 100644 --- a/src/client/features/keywords/components/DisplayPrimitives.tsx +++ b/src/client/features/keywords/components/DisplayPrimitives.tsx @@ -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[] }) { /> - formatNumber(Number(value)) + formatCompactNumber(Number(value)) } tick={{ fill: "var(--trend-axis-color)", fontSize: 11 }} - width={56} + width={44} axisLine={false} tickLine={false} /> diff --git a/src/client/features/keywords/hooks/useKeywordControlsForm.ts b/src/client/features/keywords/hooks/useKeywordControlsForm.ts index 5ad1dac..5b1db08 100644 --- a/src/client/features/keywords/hooks/useKeywordControlsForm.ts +++ b/src/client/features/keywords/hooks/useKeywordControlsForm.ts @@ -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); diff --git a/src/client/features/keywords/hooks/useKeywordResearchData.ts b/src/client/features/keywords/hooks/useKeywordResearchData.ts index 381d0f7..d4ec9a3 100644 --- a/src/client/features/keywords/hooks/useKeywordResearchData.ts +++ b/src/client/features/keywords/hooks/useKeywordResearchData.ts @@ -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( + () => + buildKeywordResearchRequest({ + keywordInput, + locationCode, + mode, + projectId, + resultLimit, + }), + [keywordInput, locationCode, mode, projectId, resultLimit], ); - const request = useMemo(() => { - 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, diff --git a/src/client/features/keywords/keywordResearchTypes.ts b/src/client/features/keywords/keywordResearchTypes.ts index 72853d4..37578f5 100644 --- a/src/client/features/keywords/keywordResearchTypes.ts +++ b/src/client/features/keywords/keywordResearchTypes.ts @@ -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]; diff --git a/src/client/features/keywords/page/KeywordResearchDesktopResults.tsx b/src/client/features/keywords/page/KeywordResearchDesktopResults.tsx index 01b1d0e..6d48cd8 100644 --- a/src/client/features/keywords/page/KeywordResearchDesktopResults.tsx +++ b/src/client/features/keywords/page/KeywordResearchDesktopResults.tsx @@ -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 ( -
+
@@ -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 ? : null} + {filteredRows.length > 0 ? ( + + ) : null}
); } diff --git a/src/client/features/keywords/page/KeywordResearchLoadingState.tsx b/src/client/features/keywords/page/KeywordResearchLoadingState.tsx index 26bec0a..c666019 100644 --- a/src/client/features/keywords/page/KeywordResearchLoadingState.tsx +++ b/src/client/features/keywords/page/KeywordResearchLoadingState.tsx @@ -1,7 +1,7 @@ export function KeywordResearchLoadingState() { return ( -
-
+
+
@@ -46,7 +46,7 @@ export function KeywordResearchLoadingState() {
-
+
diff --git a/src/client/features/keywords/page/KeywordResearchMobileResults.tsx b/src/client/features/keywords/page/KeywordResearchMobileResults.tsx index 861720f..a9eb0d1 100644 --- a/src/client/features/keywords/page/KeywordResearchMobileResults.tsx +++ b/src/client/features/keywords/page/KeywordResearchMobileResults.tsx @@ -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) { + {filteredRows.length > 0 ? ( + + ) : null}
); } diff --git a/src/client/features/keywords/page/KeywordResearchPage.tsx b/src/client/features/keywords/page/KeywordResearchPage.tsx index 3cd8278..cb3b136 100644 --- a/src/client/features/keywords/page/KeywordResearchPage.tsx +++ b/src/client/features/keywords/page/KeywordResearchPage.tsx @@ -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; 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(() => { + 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( + () => + 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 (
-
+

Keyword Research

@@ -26,6 +277,30 @@ export function KeywordResearchPage(input: Props) {

+ {controller.hasSearched ? ( +
+ { + setActiveTab(null); + setSearchParamsForTab(null); + }} + > + + Recent searches + + +
+ ) : null} - - - Recent searches - -
- ) : null; - if (controller.isLoading) { return ; } @@ -68,24 +327,21 @@ function KeywordResearchContent({ getErrorCode(controller.researchMutationError) === "INSUFFICIENT_CREDITS"; return ( -
- {recentSearchesButton} -
-
-
- -

{controller.researchError}

-
- {isCreditsError ? ( - - Go to Billing - - ) : ( - - )} +
+
+
+ +

{controller.researchError}

+ {isCreditsError ? ( + + Go to Billing + + ) : ( + + )}
); @@ -93,22 +349,14 @@ function KeywordResearchContent({ if (controller.rows.length === 0) { return ( -
- {recentSearchesButton} - -
+ ); } - return ( -
- {recentSearchesButton} - -
- ); + return ; } function KeywordSaveDialog({ diff --git a/src/client/features/keywords/page/KeywordResearchPagination.tsx b/src/client/features/keywords/page/KeywordResearchPagination.tsx new file mode 100644 index 0000000..cbeaf57 --- /dev/null +++ b/src/client/features/keywords/page/KeywordResearchPagination.tsx @@ -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 ( +
+
+ {start.toLocaleString()}-{end.toLocaleString()} of{" "} + {totalCount.toLocaleString()} +
+
+ +
+ + Page {page.toLocaleString()} of {totalPages.toLocaleString()} + +
+ + +
+
+
+
+ ); +} + +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(() => + 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. + } +} diff --git a/src/client/features/keywords/page/KeywordResearchResults.tsx b/src/client/features/keywords/page/KeywordResearchResults.tsx index 4ac0e34..f543378 100644 --- a/src/client/features/keywords/page/KeywordResearchResults.tsx +++ b/src/client/features/keywords/page/KeywordResearchResults.tsx @@ -8,7 +8,7 @@ type Props = { export function KeywordResearchResults({ controller }: Props) { return ( -
+
diff --git a/src/client/features/keywords/page/KeywordResearchSearchBar.tsx b/src/client/features/keywords/page/KeywordResearchSearchBar.tsx index df7a07a..a7ae130 100644 --- a/src/client/features/keywords/page/KeywordResearchSearchBar.tsx +++ b/src/client/features/keywords/page/KeywordResearchSearchBar.tsx @@ -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 (
{(field) => { const keywordError = getFieldError(field.state.meta.errors); + const rows = getTextareaRows(field.state.value); return (