(
+
+ {content}
+
+ )}
renderItem={(item) => (
<>
diff --git a/src/client/features/ai-search/components/SearchHistorySection.tsx b/src/client/features/ai-search/components/SearchHistorySection.tsx
index 69a2a20..553024c 100644
--- a/src/client/features/ai-search/components/SearchHistorySection.tsx
+++ b/src/client/features/ai-search/components/SearchHistorySection.tsx
@@ -5,7 +5,12 @@ type Props = {
history: TItem[];
historyLoaded: boolean;
onRemoveHistoryItem: (timestamp: number) => void;
- onSelectHistoryItem: (item: TItem) => void;
+ /**
+ * Renders the clickable area of a history row. The caller is responsible
+ * for wrapping `content` in a (or other clickable element) so that
+ * cmd+click and right-click → "open in new tab" behave natively.
+ */
+ renderItemLink: (item: TItem, content: ReactNode) => ReactNode;
/** Icon component rendered in the empty state (e.g. Sparkles, MessageSquare). */
emptyIcon: ComponentType<{ className?: string }>;
/** Empty-state headline copy. */
@@ -23,7 +28,7 @@ export function SearchHistorySection({
history,
historyLoaded,
onRemoveHistoryItem,
- onSelectHistoryItem,
+ renderItemLink,
emptyIcon: EmptyIcon,
emptyMessage,
noun,
@@ -62,14 +67,13 @@ export function SearchHistorySection({
key={item.timestamp}
className="group flex items-center gap-2 rounded-lg border border-base-300 bg-base-100 p-2"
>
-
+ {renderItemLink(
+ item,
+ <>
+
+ {renderItem(item)}
+ >,
+ )}
{new Date(item.timestamp).toLocaleDateString(undefined, {
@@ -92,3 +96,6 @@ export function SearchHistorySection({
);
}
+
+export const HISTORY_ITEM_LINK_CLASS =
+ "flex min-w-0 flex-1 items-center gap-3 rounded-md px-1 py-1 text-left transition-colors hover:bg-base-200";
diff --git a/src/client/features/audit/launch/AuditHistorySection.tsx b/src/client/features/audit/launch/AuditHistorySection.tsx
index b4d9711..f524173 100644
--- a/src/client/features/audit/launch/AuditHistorySection.tsx
+++ b/src/client/features/audit/launch/AuditHistorySection.tsx
@@ -1,16 +1,17 @@
+import { Link } from "@tanstack/react-router";
import { MoreHorizontal, ScanSearch, Trash2 } from "lucide-react";
import type { getAuditHistory } from "@/serverFunctions/audit";
import { formatDate, StatusBadge } from "@/client/features/audit/shared";
export function AuditHistorySection({
+ projectId,
history,
isLoading,
- onView,
onDelete,
}: {
+ projectId: string;
history: Awaited>;
isLoading: boolean;
- onView: (auditId: string) => void;
onDelete: (auditId: string) => void;
}) {
if (history.length === 0 && !isLoading) {
@@ -60,8 +61,8 @@ export function AuditHistorySection({
|
|
@@ -76,22 +77,24 @@ export function AuditHistorySection({
}
function HistoryActions({
+ projectId,
auditId,
- onView,
onDelete,
}: {
+ projectId: string;
auditId: string;
- onView: (auditId: string) => void;
onDelete: (auditId: string) => void;
}) {
return (
-
+
diff --git a/src/client/features/audit/results/ResultsView.tsx b/src/client/features/audit/results/ResultsView.tsx
index f9db878..3fa499a 100644
--- a/src/client/features/audit/results/ResultsView.tsx
+++ b/src/client/features/audit/results/ResultsView.tsx
@@ -1,4 +1,5 @@
import { useMemo } from "react";
+import { Link } from "@tanstack/react-router";
import { StatCard } from "@/client/features/audit/shared";
import {
exportPages,
@@ -12,18 +13,14 @@ import {
PerformanceTable,
} from "@/client/features/audit/results/ResultsTables";
-type SearchSetter = (updates: Record
) => void;
-
export function ResultsView({
projectId,
data,
tab,
- setSearchParams,
}: {
projectId: string;
data: AuditResultsData;
tab: string;
- setSearchParams: SearchSetter;
}) {
const { audit, pages, lighthouse } = data;
const hasPerformanceTab = lighthouse.length > 0;
@@ -43,11 +40,12 @@ export function ResultsView({
{
if (activeTab === "performance") {
exportPerformance(lighthouse, pages, format);
@@ -117,38 +115,46 @@ function useResultStats(
}
function ResultsHeader({
+ projectId,
+ auditId,
pageCount,
lighthouseCount,
hasPerformanceTab,
activeTab,
- setSearchParams,
onExport,
}: {
+ projectId: string;
+ auditId: string;
pageCount: number;
lighthouseCount: number;
hasPerformanceTab: boolean;
activeTab: string;
- setSearchParams: SearchSetter;
onExport: (format: "csv" | "json" | "sheets") => void;
}) {
return (
{hasPerformanceTab ? (
-
-
+
) : (
Pages ({pageCount})
diff --git a/src/client/features/backlinks/BacklinksHistorySection.tsx b/src/client/features/backlinks/BacklinksHistorySection.tsx
index 4c30ebf..28afaca 100644
--- a/src/client/features/backlinks/BacklinksHistorySection.tsx
+++ b/src/client/features/backlinks/BacklinksHistorySection.tsx
@@ -1,18 +1,19 @@
+import { Link } from "@tanstack/react-router";
import { Clock, History, Link2, X } from "lucide-react";
import type { BacklinksSearchHistoryItem } from "@/client/hooks/useBacklinksSearchHistory";
type Props = {
+ projectId: string;
history: BacklinksSearchHistoryItem[];
historyLoaded: boolean;
onRemoveHistoryItem: (timestamp: number) => void;
- onSelectHistoryItem: (item: BacklinksSearchHistoryItem) => void;
};
export function BacklinksHistorySection({
+ projectId,
history,
historyLoaded,
onRemoveHistoryItem,
- onSelectHistoryItem,
}: Props) {
if (!historyLoaded) {
return null;
@@ -46,10 +47,17 @@ export function BacklinksHistorySection({
key={item.timestamp}
className="group flex items-center gap-2 rounded-lg border border-base-300 bg-base-100 p-2"
>
-
+
{new Date(item.timestamp).toLocaleDateString(undefined, {
diff --git a/src/client/features/backlinks/BacklinksPage.tsx b/src/client/features/backlinks/BacklinksPage.tsx
index d69e7f8..a2b1bb5 100644
--- a/src/client/features/backlinks/BacklinksPage.tsx
+++ b/src/client/features/backlinks/BacklinksPage.tsx
@@ -2,9 +2,7 @@ import { BacklinksSearchCard } from "./BacklinksSearchCard";
import { BacklinksBody } from "./BacklinksPageContent";
import type { BacklinksPageProps } from "./backlinksPageTypes";
import {
- navigateToBacklinksHistory,
navigateToBacklinksSearch,
- navigateToBacklinksTab,
useBacklinksPageData,
} from "./useBacklinksPageData";
import { useBacklinksFilters } from "./useBacklinksFilters";
@@ -37,16 +35,6 @@ export function BacklinksPage({
removeHistoryItem,
} = useBacklinksSearchHistory(projectId);
- const handleHistorySelect = (item: {
- target: string;
- scope: "domain" | "page";
- }) => {
- navigateToBacklinksSearch(navigate, {
- target: item.target,
- scope: item.scope,
- });
- };
-
return (
@@ -77,6 +65,7 @@ export function BacklinksPage({
) : null}
navigateToBacklinksHistory(navigate)}
- onSetActiveTab={(tab) => navigateToBacklinksTab(navigate, tab)}
onRetryOverview={() => void overviewQuery.refetch()}
/>
diff --git a/src/client/features/backlinks/BacklinksPageContent.tsx b/src/client/features/backlinks/BacklinksPageContent.tsx
index e5f0589..541410b 100644
--- a/src/client/features/backlinks/BacklinksPageContent.tsx
+++ b/src/client/features/backlinks/BacklinksPageContent.tsx
@@ -27,6 +27,7 @@ import {
import type { BacklinksFiltersState } from "./useBacklinksFilters";
type BacklinksBodyProps = {
+ projectId: string;
accessGate: UseAccessGateResult;
backlinksDisabledByError: boolean;
history: BacklinksSearchHistoryItem[];
@@ -41,13 +42,11 @@ type BacklinksBodyProps = {
tabLoading: boolean;
topPages: BacklinksTopPagesData | undefined;
onRemoveHistoryItem: (timestamp: number) => void;
- onSelectHistoryItem: (item: BacklinksSearchHistoryItem) => void;
- onShowHistory: () => void;
- onSetActiveTab: (tab: BacklinksSearchState["tab"]) => void;
onRetryOverview: () => void;
};
export function BacklinksBody({
+ projectId,
accessGate,
backlinksDisabledByError,
history,
@@ -62,9 +61,6 @@ export function BacklinksBody({
tabLoading,
topPages,
onRemoveHistoryItem,
- onSelectHistoryItem,
- onShowHistory,
- onSetActiveTab,
onRetryOverview,
}: BacklinksBodyProps) {
const mergedData = useMemo(
@@ -123,10 +119,10 @@ export function BacklinksBody({
if (!searchState.target) {
return (
);
}
@@ -147,11 +143,12 @@ export function BacklinksBody({
return (
<>
>
diff --git a/src/client/features/backlinks/BacklinksPageSections.tsx b/src/client/features/backlinks/BacklinksPageSections.tsx
index 6031932..92b4f7e 100644
--- a/src/client/features/backlinks/BacklinksPageSections.tsx
+++ b/src/client/features/backlinks/BacklinksPageSections.tsx
@@ -1,4 +1,5 @@
import { useMemo } from "react";
+import { Link } from "@tanstack/react-router";
import { HeaderHelpLabel } from "@/client/features/keywords/components";
import { ArrowLeft, Download, SlidersHorizontal } from "lucide-react";
import { ExportToSheetsButton } from "@/client/components/table/ExportToSheetsButton";
@@ -22,25 +23,27 @@ import { buildBacklinksTabExport, exportBacklinksTabCsv } from "./export";
import type { BacklinksFiltersState } from "./useBacklinksFilters";
export function BacklinksOverviewPanels({
+ projectId,
data,
- onShowHistory,
summaryStats,
}: {
+ projectId: string;
data: BacklinksOverviewData;
- onShowHistory: () => void;
summaryStats: Array<{ label: string; value: string; description: string }>;
}) {
return (
<>
{data.scope}
@@ -63,14 +66,15 @@ export function BacklinksOverviewPanels({
}
export function BacklinksResultsCard({
+ projectId,
activeTab,
filteredData,
filters,
isTabLoading,
tabErrorMessage,
- onSetActiveTab,
exportTarget,
}: {
+ projectId: string;
activeTab: BacklinksSearchState["tab"];
filteredData: {
backlinks: BacklinksOverviewData["backlinks"];
@@ -80,7 +84,6 @@ export function BacklinksResultsCard({
filters: BacklinksFiltersState;
isTabLoading: boolean;
tabErrorMessage: string | null;
- onSetActiveTab: (tab: BacklinksSearchState["tab"]) => void;
exportTarget: string;
}) {
const currentFilterCount = filters[activeTab].activeFilterCount;
@@ -94,27 +97,19 @@ export function BacklinksResultsCard({
-
Backlinks
-
-
+
+
Referring Domains
-
-
+
+
Top Pages
-
+
{TAB_DESCRIPTIONS[activeTab]}
@@ -280,25 +275,31 @@ function TrendCard({
);
}
-function TabButton({
+function TabLink({
+ projectId,
activeTab,
children,
- onClick,
tab,
}: {
+ projectId: string;
activeTab: BacklinksSearchState["tab"];
children: string;
- onClick: (tab: BacklinksSearchState["tab"]) => void;
tab: BacklinksSearchState["tab"];
}) {
return (
-
+
);
}
diff --git a/src/client/features/backlinks/useBacklinksPageData.ts b/src/client/features/backlinks/useBacklinksPageData.ts
index fd98540..d06a701 100644
--- a/src/client/features/backlinks/useBacklinksPageData.ts
+++ b/src/client/features/backlinks/useBacklinksPageData.ts
@@ -142,33 +142,6 @@ export function navigateToBacklinksSearch(
});
}
-export function navigateToBacklinksHistory(
- navigate: BacklinksPageProps["navigate"],
-) {
- navigate({
- search: (prev) => ({
- ...prev,
- target: undefined,
- scope: undefined,
- tab: undefined,
- }),
- replace: true,
- });
-}
-
-export function navigateToBacklinksTab(
- navigate: BacklinksPageProps["navigate"],
- tab: BacklinksSearchState["tab"],
-) {
- navigate({
- search: (prev) => ({
- ...prev,
- tab: tab === "backlinks" ? undefined : tab,
- }),
- replace: true,
- });
-}
-
function buildBacklinksRequestInput(
projectId: string,
searchState: BacklinksSearchState,
diff --git a/src/client/features/domain/DomainOverviewPage.tsx b/src/client/features/domain/DomainOverviewPage.tsx
index 2bd9dd5..431bc3b 100644
--- a/src/client/features/domain/DomainOverviewPage.tsx
+++ b/src/client/features/domain/DomainOverviewPage.tsx
@@ -125,6 +125,7 @@ export function DomainOverviewPage({
) : null}
{
- if (
- tab === "pages" &&
- (searchState.sort === "rank" ||
- searchState.sort === "score" ||
- searchState.sort === "cpc")
- ) {
- state.applySort("traffic", getDefaultSortOrder("traffic"));
- }
- state.setSearchParams({ tab });
- }}
onSearchChange={state.setPendingSearch}
onSaveKeywords={state.handleSaveKeywords}
canSaveKeywords={state.canSaveKeywords}
diff --git a/src/client/features/domain/components/DomainResultsCard.tsx b/src/client/features/domain/components/DomainResultsCard.tsx
index 333ddb6..f474aed 100644
--- a/src/client/features/domain/components/DomainResultsCard.tsx
+++ b/src/client/features/domain/components/DomainResultsCard.tsx
@@ -1,4 +1,5 @@
import { type Dispatch, type SetStateAction } from "react";
+import { Link } from "@tanstack/react-router";
import {
ChevronDown,
Copy,
@@ -14,7 +15,11 @@ import { DomainFilterPanel } from "@/client/features/domain/components/DomainFil
import { DomainKeywordsTable } from "@/client/features/domain/components/DomainKeywordsTable";
import { DomainPagesTable } from "@/client/features/domain/components/DomainPagesTable";
import type { useDomainFilters } from "@/client/features/domain/hooks/useDomainFilters";
-import { keywordsToTable, pagesToTable } from "@/client/features/domain/utils";
+import {
+ getDefaultSortOrder,
+ keywordsToTable,
+ pagesToTable,
+} from "@/client/features/domain/utils";
import { buildCsv, downloadCsv } from "@/client/lib/csv";
import { exportTableToSheets } from "@/client/lib/exportToSheets";
import { captureClientEvent } from "@/client/lib/posthog";
@@ -28,6 +33,7 @@ import type {
} from "@/client/features/domain/types";
type Props = {
+ projectId: string;
overview: DomainOverviewData;
activeTab: DomainActiveTab;
sortMode: DomainSortMode;
@@ -42,7 +48,6 @@ type Props = {
filtersForm: ReturnType["filtersForm"];
activeFilterCount: number;
resetFilters: () => void;
- onTabChange: (tab: DomainActiveTab) => void;
onSearchChange: (value: string) => void;
onSaveKeywords: () => void;
canSaveKeywords: boolean;
@@ -51,7 +56,14 @@ type Props = {
onToggleAllVisible: () => void;
};
+const KEYWORDS_ONLY_SORTS: ReadonlySet = new Set([
+ "rank",
+ "score",
+ "cpc",
+]);
+
export function DomainResultsCard({
+ projectId,
overview,
activeTab,
sortMode,
@@ -66,7 +78,6 @@ export function DomainResultsCard({
filtersForm,
activeFilterCount,
resetFilters,
- onTabChange,
onSearchChange,
onSaveKeywords,
canSaveKeywords,
@@ -112,20 +123,40 @@ export function DomainResultsCard({
-
-
+
diff --git a/src/client/features/keywords/hooks/useKeywordResearchData.ts b/src/client/features/keywords/hooks/useKeywordResearchData.ts
index b297d21..a349aaa 100644
--- a/src/client/features/keywords/hooks/useKeywordResearchData.ts
+++ b/src/client/features/keywords/hooks/useKeywordResearchData.ts
@@ -1,5 +1,4 @@
-import { useMutation } from "@tanstack/react-query";
-import { useState } from "react";
+import { useRef, useState } from "react";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { captureClientEvent } from "@/client/lib/posthog";
import { LOCATIONS, getLanguageCode } from "@/client/features/keywords/utils";
@@ -30,26 +29,23 @@ export function useKeywordResearchData(addSearch: AddSearchFn) {
DEFAULT_LOCATION_CODE,
);
const [researchError, setResearchError] = useState
(null);
+ const [researchMutationError, setResearchMutationError] =
+ useState(null);
const [searchedKeyword, setSearchedKeyword] = useState("");
-
- const researchMutation = useMutation({
- mutationFn: (data: {
- projectId: string;
- keywords: string[];
- locationCode: number;
- languageCode: string;
- resultLimit: ResultLimit;
- mode: KeywordMode;
- }) => researchKeywords({ data }),
- });
+ const [isLoading, setIsLoading] = useState(false);
+ // Sequence token so a stale fetch (e.g. user fired a second search before
+ // the first resolved) can't overwrite state that belongs to a newer one.
+ const requestSeqRef = useRef(0);
const beginSearch = (seedKeyword: string, locationCode: number) => {
setResearchError(null);
+ setResearchMutationError(null);
setHasSearched(true);
setLastSearchError(false);
setSearchedKeyword(seedKeyword);
setLastSearchKeyword(seedKeyword);
setLastSearchLocationCode(locationCode);
+ setIsLoading(true);
};
const resetResearch = () => {
@@ -61,10 +57,12 @@ export function useKeywordResearchData(addSearch: AddSearchFn) {
setLastSearchKeyword("");
setLastSearchLocationCode(DEFAULT_LOCATION_CODE);
setResearchError(null);
+ setResearchMutationError(null);
setSearchedKeyword("");
+ setIsLoading(false);
};
- const runSearch = (
+ const runSearch = async (
input: {
projectId: string;
keywords: string[];
@@ -79,49 +77,54 @@ export function useKeywordResearchData(addSearch: AddSearchFn) {
) => {
const seedKeyword = input.keywords[0] ?? "";
const languageCode = getLanguageCode(input.locationCode);
+ const requestSeq = ++requestSeqRef.current;
+ const isStale = () => requestSeqRef.current !== requestSeq;
- researchMutation.mutate(
- {
- keywords: input.keywords,
- projectId: input.projectId,
- locationCode: input.locationCode,
- languageCode,
- resultLimit: input.resultLimit,
- mode: input.mode,
- },
- {
- onSuccess: (result) => {
- const resultCount = result.rows.length;
-
- setResearchError(null);
- setRows(result.rows);
- setLastResultSource(result.source);
- setLastUsedFallback(result.usedFallback);
-
- captureClientEvent("keyword_research:search_complete", {
- location_code: input.locationCode,
- search_mode: input.mode,
- result_count: resultCount,
- });
-
- if (seedKeyword) {
- addSearch(
- seedKeyword,
- input.locationCode,
- LOCATIONS[input.locationCode] || "Unknown",
- );
- }
-
- handlers?.onSuccess?.(seedKeyword, result.rows);
+ try {
+ const result = await researchKeywords({
+ data: {
+ keywords: input.keywords,
+ projectId: input.projectId,
+ locationCode: input.locationCode,
+ languageCode,
+ resultLimit: input.resultLimit,
+ mode: input.mode,
},
- onError: (error) => {
- setLastSearchError(true);
- setRows([]);
- setResearchError(getStandardErrorMessage(error, "Research failed."));
- handlers?.onError?.();
- },
- },
- );
+ });
+
+ if (isStale()) return;
+
+ setResearchError(null);
+ setResearchMutationError(null);
+ setRows(result.rows);
+ setLastResultSource(result.source);
+ setLastUsedFallback(result.usedFallback);
+
+ captureClientEvent("keyword_research:search_complete", {
+ location_code: input.locationCode,
+ search_mode: input.mode,
+ result_count: result.rows.length,
+ });
+
+ if (seedKeyword) {
+ addSearch(
+ seedKeyword,
+ input.locationCode,
+ LOCATIONS[input.locationCode] || "Unknown",
+ );
+ }
+
+ handlers?.onSuccess?.(seedKeyword, result.rows);
+ } catch (error) {
+ if (isStale()) return;
+ setLastSearchError(true);
+ setRows([]);
+ setResearchMutationError(error);
+ setResearchError(getStandardErrorMessage(error, "Research failed."));
+ handlers?.onError?.();
+ } finally {
+ if (!isStale()) setIsLoading(false);
+ }
};
return {
@@ -133,9 +136,9 @@ export function useKeywordResearchData(addSearch: AddSearchFn) {
lastSearchKeyword,
lastSearchLocationCode,
researchError,
- researchMutationError: researchMutation.error,
+ researchMutationError,
searchedKeyword,
- isLoading: researchMutation.isPending,
+ isLoading,
beginSearch,
resetResearch,
runSearch,
diff --git a/src/client/features/keywords/keywordSearchParams.ts b/src/client/features/keywords/keywordSearchParams.ts
index 29b9b50..09ff9d8 100644
--- a/src/client/features/keywords/keywordSearchParams.ts
+++ b/src/client/features/keywords/keywordSearchParams.ts
@@ -21,26 +21,6 @@ type KeywordSearchParams = {
exclude?: string;
};
-export function clearKeywordSearchParams(search: KeywordSearchParams) {
- return {
- ...search,
- q: undefined,
- loc: undefined,
- kLimit: undefined,
- mode: undefined,
- sort: undefined,
- order: undefined,
- minVol: undefined,
- maxVol: undefined,
- minCpc: undefined,
- maxCpc: undefined,
- minKd: undefined,
- maxKd: undefined,
- include: undefined,
- exclude: undefined,
- } satisfies KeywordSearchParams;
-}
-
export function normalizeLegacyKeywordSearch(search: KeywordSearchParams): {
normalized: KeywordSearchParams;
changed: boolean;
diff --git a/src/client/features/keywords/page/KeywordResearchEmptyState.tsx b/src/client/features/keywords/page/KeywordResearchEmptyState.tsx
index 6a3d543..7ffbfcb 100644
--- a/src/client/features/keywords/page/KeywordResearchEmptyState.tsx
+++ b/src/client/features/keywords/page/KeywordResearchEmptyState.tsx
@@ -1,22 +1,29 @@
+import { Link } from "@tanstack/react-router";
import { Clock, Globe, History, Search, X } from "lucide-react";
+import { DEFAULT_LOCATION_CODE } from "@/client/features/keywords/locations";
import { LOCATIONS } from "@/client/features/keywords/utils";
import type { KeywordResearchControllerState } from "./types";
type Props = {
controller: KeywordResearchControllerState;
+ projectId: string;
};
-export function KeywordResearchEmptyState({ controller }: Props) {
+export function KeywordResearchEmptyState({ controller, projectId }: Props) {
const { hasSearched, isLoading, lastSearchError } = controller;
if (hasSearched && !isLoading && !lastSearchError) {
return ;
}
- return ;
+ return ;
}
-function NoResultsState({ controller }: Props) {
+function NoResultsState({
+ controller,
+}: {
+ controller: KeywordResearchControllerState;
+}) {
const { lastSearchKeyword, lastSearchLocationCode } = controller;
return (
@@ -44,8 +51,14 @@ function NoResultsState({ controller }: Props) {
);
}
-function SearchHistoryState({ controller }: Props) {
- const { history, historyLoaded, onSearch, removeHistoryItem } = controller;
+function SearchHistoryState({
+ controller,
+ projectId,
+}: {
+ controller: KeywordResearchControllerState;
+ projectId: string;
+}) {
+ const { history, historyLoaded, removeHistoryItem } = controller;
if (!historyLoaded) {
return null;
@@ -70,15 +83,19 @@ function SearchHistoryState({ controller }: Props) {
key={item.timestamp}
className="group flex items-center gap-2 rounded-lg border border-base-300 bg-base-100 p-2"
>
-
+
{new Date(item.timestamp).toLocaleDateString(undefined, {
diff --git a/src/client/features/keywords/page/KeywordResearchPage.tsx b/src/client/features/keywords/page/KeywordResearchPage.tsx
index 551c4a2..4ad8dd5 100644
--- a/src/client/features/keywords/page/KeywordResearchPage.tsx
+++ b/src/client/features/keywords/page/KeywordResearchPage.tsx
@@ -10,16 +10,10 @@ import { KeywordResearchResults } from "./KeywordResearchResults";
import { KeywordResearchSearchBar } from "./KeywordResearchSearchBar";
import type { KeywordResearchControllerState } from "./types";
-type Props = KeywordResearchControllerInput & {
- onShowRecentSearches: () => void;
-};
+type Props = KeywordResearchControllerInput;
-export function KeywordResearchPage({ onShowRecentSearches, ...input }: Props) {
+export function KeywordResearchPage(input: Props) {
const controller = useKeywordResearchController(input);
- const handleShowRecentSearches = () => {
- controller.resetView();
- onShowRecentSearches();
- };
return (
@@ -34,7 +28,7 @@ export function KeywordResearchPage({ onShowRecentSearches, ...input }: Props) {
@@ -44,21 +38,24 @@ export function KeywordResearchPage({ onShowRecentSearches, ...input }: Props) {
function KeywordResearchContent({
controller,
- onShowRecentSearches,
+ projectId,
}: {
controller: KeywordResearchControllerState;
- onShowRecentSearches: () => void;
+ projectId: string;
}) {
const recentSearchesButton = controller.hasSearched ? (
) : null;
@@ -101,7 +98,10 @@ function KeywordResearchContent({
return (
{recentSearchesButton}
-
+
);
}
diff --git a/src/client/features/keywords/state/keywordControllerActions.ts b/src/client/features/keywords/state/keywordControllerActions.ts
index 749ee5e..cf46a27 100644
--- a/src/client/features/keywords/state/keywordControllerActions.ts
+++ b/src/client/features/keywords/state/keywordControllerActions.ts
@@ -7,6 +7,10 @@ import { getLanguageCode } from "@/client/features/keywords/utils";
import type { KeywordResearchRow } from "@/types/keywords";
import type { SaveKeywordsInput } from "@/types/schemas/keywords";
import type { SortDir, SortField } from "@/client/features/keywords/components";
+import type {
+ KeywordMode,
+ ResultLimit,
+} from "@/client/features/keywords/keywordResearchTypes";
import type { KeywordResearchControllerInput } from "./useKeywordResearchController";
export const KEYWORD_RESEARCH_HEADERS = [
@@ -51,6 +55,25 @@ export function parseKeywordInput(value: string) {
.filter(Boolean);
}
+/**
+ * Stable identity for a keyword-research request. Used to dedup the
+ * URL-driven search trigger against the form-submit path so the same
+ * params don't fire two requests back-to-back.
+ */
+export function buildKeywordSearchKey(params: {
+ keyword: string;
+ locationCode: number;
+ resultLimit: ResultLimit;
+ mode: KeywordMode;
+}) {
+ return [
+ parseKeywordInput(params.keyword).join(""),
+ params.locationCode,
+ params.resultLimit,
+ params.mode,
+ ].join("|");
+}
+
export function getNextSortParams(
currentField: SortField,
currentDirection: SortDir,
diff --git a/src/client/features/keywords/state/useKeywordResearchController.ts b/src/client/features/keywords/state/useKeywordResearchController.ts
index da70e2d..fea3be7 100644
--- a/src/client/features/keywords/state/useKeywordResearchController.ts
+++ b/src/client/features/keywords/state/useKeywordResearchController.ts
@@ -1,4 +1,4 @@
-import { useCallback, type FormEvent } from "react";
+import { useCallback, useEffect, useRef, type FormEvent } from "react";
import { useKeywordControlsForm } from "@/client/features/keywords/hooks/useKeywordControlsForm";
import { useKeywordFiltering } from "@/client/features/keywords/hooks/useKeywordFiltering";
import { useLocalKeywordFilters } from "@/client/features/keywords/hooks/useLocalKeywordFilters";
@@ -15,6 +15,7 @@ import { DEFAULT_LOCATION_CODE } from "@/client/features/keywords/locations";
import type { KeywordResearchRow } from "@/types/keywords";
import type { SortDir, SortField } from "@/client/features/keywords/components";
import {
+ buildKeywordSearchKey,
getNextSortParams,
parseKeywordInput,
useSaveAndExportActions,
@@ -96,18 +97,6 @@ export function useKeywordResearchController(
state.setSerpPage(0);
};
- const resetView = useCallback(() => {
- state.resetResearch();
- state.clearSelection();
- state.resetFilters();
- state.setSelectedKeyword(null);
- state.setSerpKeyword(null);
- state.setSerpPage(0);
- state.setMobileTab("keywords");
- state.setShowFilters(false);
- state.setShowSaveDialog(false);
- }, [state]);
-
return {
activeFilterCount: state.activeFilterCount,
activeSerpKeyword: state.activeSerpKeyword,
@@ -135,7 +124,6 @@ export function useKeywordResearchController(
removeHistoryItem: state.removeHistoryItem,
researchError: state.researchError,
researchMutationError: state.researchMutationError,
- resetView,
resetFilters: state.resetFilters,
rows: state.rows,
searchedKeyword: state.searchedKeyword,
@@ -212,41 +200,34 @@ function useKeywordControllerState(input: KeywordResearchControllerInput) {
const setSearchParams = useKeywordSearchParams();
const saveMutation = useKeywordSaveMutation(input.projectId);
- const controlsForm = useKeywordControlsForm(
- {
- ...input,
- locationCode,
- },
- (value) => {
- const keywords = parseKeywordInput(value.keyword);
- const activeLocation = value.locationCode;
- const activeResultLimit = value.resultLimit;
- const activeMode = value.mode;
+ // Tracks the parameters used for the most recent search trigger so the
+ // URL-driven effect below doesn't re-fire after the form-submit path
+ // already kicked off a search for the same params.
+ const lastTriggerKeyRef = useRef(null);
- setPreferredLocationCode(activeLocation);
- setSearchParams({
- q: value.keyword,
- loc:
- input.hasExplicitLocationCode ||
- activeLocation !== DEFAULT_LOCATION_CODE
- ? activeLocation
- : undefined,
- kLimit: activeResultLimit === 150 ? undefined : activeResultLimit,
- mode: activeMode === "auto" ? undefined : activeMode,
- });
+ const triggerSearch = useCallback(
+ (params: {
+ keyword: string;
+ locationCode: number;
+ resultLimit: ResultLimit;
+ mode: KeywordMode;
+ }) => {
+ const keywords = parseKeywordInput(params.keyword);
+ if (keywords.length === 0) return;
+ lastTriggerKeyRef.current = buildKeywordSearchKey(params);
uiState.setSelectedKeyword(null);
clearSelection();
setSerpKeyword(null);
- beginSearch(keywords[0] ?? "", activeLocation);
+ beginSearch(keywords[0] ?? "", params.locationCode);
- runSearch(
+ void runSearch(
{
projectId: input.projectId,
keywords,
- locationCode: activeLocation,
- resultLimit: activeResultLimit,
- mode: activeMode,
+ locationCode: params.locationCode,
+ resultLimit: params.resultLimit,
+ mode: params.mode,
},
{
onSuccess: (seedKeyword, nextRows) => {
@@ -260,8 +241,93 @@ function useKeywordControllerState(input: KeywordResearchControllerInput) {
},
);
},
+ [
+ beginSearch,
+ clearSelection,
+ input.projectId,
+ runSearch,
+ setSerpKeyword,
+ setSerpPage,
+ uiState,
+ ],
);
+ const controlsForm = useKeywordControlsForm(
+ {
+ ...input,
+ locationCode,
+ },
+ (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,
+ });
+
+ // Trigger immediately so re-submitting the same query (URL unchanged)
+ // still refetches. The dedup ref prevents the URL effect below from
+ // double-firing in the typical (URL-changes) case.
+ triggerSearch({
+ keyword: value.keyword,
+ locationCode: value.locationCode,
+ resultLimit: value.resultLimit,
+ mode: value.mode,
+ });
+ },
+ );
+
+ // URL-driven search trigger. Fires when the user lands on a shareable URL
+ // (direct link, cmd+click on a history item, browser back/forward) so the
+ // page reproduces the search those params describe without a form submit.
+ // When the URL is cleared (no `q`), the page resets to the recent-searches
+ // empty state so the "Recent searches" Link works without an extra handler.
+ useEffect(() => {
+ const trimmed = input.keywordInput.trim();
+
+ if (trimmed.length === 0) {
+ if (lastTriggerKeyRef.current === null) return;
+ lastTriggerKeyRef.current = null;
+ resetResearch();
+ clearSelection();
+ uiState.setSelectedKeyword(null);
+ setSerpKeyword(null);
+ setSerpPage(0);
+ return;
+ }
+
+ const urlKey = buildKeywordSearchKey({
+ keyword: input.keywordInput,
+ locationCode,
+ resultLimit: input.resultLimit,
+ mode: input.keywordMode,
+ });
+ if (urlKey === lastTriggerKeyRef.current) return;
+
+ triggerSearch({
+ keyword: input.keywordInput,
+ locationCode,
+ resultLimit: input.resultLimit,
+ mode: input.keywordMode,
+ });
+ }, [
+ clearSelection,
+ input.keywordInput,
+ input.keywordMode,
+ input.resultLimit,
+ locationCode,
+ resetResearch,
+ setSerpKeyword,
+ setSerpPage,
+ triggerSearch,
+ uiState,
+ ]);
+
const { filteredRows, activeFilterCount } = useKeywordFiltering({
rows,
filters: filterValues,
diff --git a/src/client/features/rank-tracking/RankTrackingDomainList.tsx b/src/client/features/rank-tracking/RankTrackingDomainList.tsx
index e1fd357..de15a16 100644
--- a/src/client/features/rank-tracking/RankTrackingDomainList.tsx
+++ b/src/client/features/rank-tracking/RankTrackingDomainList.tsx
@@ -1,6 +1,6 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import { useNavigate } from "@tanstack/react-router";
+import { Link } from "@tanstack/react-router";
import { toast } from "sonner";
import { LOCATIONS } from "@/client/features/keywords/locations";
import {
@@ -31,7 +31,6 @@ export function RankTrackingDomainList({
projectId: string;
onAddDomain: () => void;
}) {
- const navigate = useNavigate();
const queryClient = useQueryClient();
const [archiveTarget, setArchiveTarget] = useState(
null,
@@ -88,13 +87,8 @@ export function RankTrackingDomainList({
(summaries ?? []).map((summary) => (
- void navigate({
- to: "/p/$projectId/rank-tracking/$configId",
- params: { projectId, configId: summary.id },
- })
- }
onArchive={() => setArchiveTarget(summary)}
/>
))
@@ -134,31 +128,26 @@ export function RankTrackingDomainList({
}
function DomainRow({
+ projectId,
summary,
- onClick,
onArchive,
}: {
+ projectId: string;
summary: ConfigSummary;
- onClick: () => void;
onArchive: () => void;
}) {
const dl = getDevicesLabel(summary.devices);
const sl = getScheduleLabel(summary.scheduleInterval);
return (
- {
- if (e.key === "Enter" || e.key === " ") {
- e.preventDefault();
- onClick();
- }
- }}
- >
-
+
+
+
{summary.domain}
{LOCATIONS[summary.locationCode] ?? "US"} · {dl} · {sl}
@@ -177,7 +166,7 @@ function DomainRow({
)}
-
+
{summary.keywordCount > 0 && (
@@ -189,16 +178,17 @@ function DomainRow({
-
+
);
}
diff --git a/src/routes/_project/p/$projectId/audit/index.tsx b/src/routes/_project/p/$projectId/audit/index.tsx
index ca860f2..14b38db 100644
--- a/src/routes/_project/p/$projectId/audit/index.tsx
+++ b/src/routes/_project/p/$projectId/audit/index.tsx
@@ -55,7 +55,6 @@ function SiteAuditPage() {
projectId={projectId}
auditId={auditId}
tab={tab}
- setSearchParams={setSearchParams}
onBack={() => setSearchParams({ auditId: undefined })}
/>
);
@@ -65,13 +64,11 @@ function AuditDetail({
projectId,
auditId,
tab,
- setSearchParams,
onBack,
}: {
projectId: string;
auditId: string;
tab: string;
- setSearchParams: (updates: Record
) => void;
onBack: () => void;
}) {
const statusQuery = useQuery({
@@ -181,7 +178,6 @@ function AuditDetail({
projectId={projectId}
data={resultsQuery.data}
tab={tab}
- setSearchParams={setSearchParams}
/>
)}
diff --git a/src/routes/_project/p/$projectId/keywords.tsx b/src/routes/_project/p/$projectId/keywords.tsx
index 846e12d..d51d7de 100644
--- a/src/routes/_project/p/$projectId/keywords.tsx
+++ b/src/routes/_project/p/$projectId/keywords.tsx
@@ -1,8 +1,7 @@
-import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router";
+import { createFileRoute, redirect } from "@tanstack/react-router";
import { DEFAULT_LOCATION_CODE } from "@/client/features/keywords/locations";
import { KeywordResearchPage } from "@/client/features/keywords/page/KeywordResearchPage";
import {
- clearKeywordSearchParams,
isResultLimit,
normalizeKeywordMode,
normalizeLegacyKeywordSearch,
@@ -29,7 +28,6 @@ export const Route = createFileRoute("/_project/p/$projectId/keywords")({
function KeywordResearchPageRoute() {
const { projectId } = Route.useParams();
- const navigate = useNavigate({ from: Route.fullPath });
const search = Route.useSearch();
const {
q: keywordInput = "",
@@ -43,12 +41,6 @@ function KeywordResearchPageRoute() {
return (
{
- void navigate({
- search: clearKeywordSearchParams,
- replace: true,
- });
- }}
projectId={projectId}
keywordInput={keywordInput}
locationCode={locationCode}
diff --git a/src/routes/_project/p/$projectId/prompt-explorer.tsx b/src/routes/_project/p/$projectId/prompt-explorer.tsx
index 434e462..3aa6468 100644
--- a/src/routes/_project/p/$projectId/prompt-explorer.tsx
+++ b/src/routes/_project/p/$projectId/prompt-explorer.tsx
@@ -1,11 +1,48 @@
-import { createFileRoute } from "@tanstack/react-router";
+import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { PromptExplorerPage } from "@/client/features/ai-search/PromptExplorerPage";
+import {
+ PROMPT_EXPLORER_MODELS,
+ promptExplorerSearchSchema,
+} from "@/types/schemas/ai-search";
export const Route = createFileRoute("/_project/p/$projectId/prompt-explorer")({
+ validateSearch: promptExplorerSearchSchema,
component: PromptExplorerRoute,
});
function PromptExplorerRoute() {
const { projectId } = Route.useParams();
- return ;
+ const navigate = useNavigate({ from: Route.fullPath });
+ const search = Route.useSearch();
+
+ return (
+ 0
+ ? search.models
+ : [...PROMPT_EXPLORER_MODELS],
+ webSearch: search.web ?? true,
+ webSearchCountryCode: search.cc ?? "US",
+ }}
+ onSubmit={(values) => {
+ void navigate({
+ search: {
+ q: values.prompt,
+ models: values.models,
+ web: values.webSearch ? undefined : false,
+ cc:
+ values.webSearchCountryCode === "US"
+ ? undefined
+ : values.webSearchCountryCode,
+ hb: values.highlightBrand || undefined,
+ },
+ replace: true,
+ });
+ }}
+ />
+ );
}
diff --git a/src/types/schemas/ai-search.ts b/src/types/schemas/ai-search.ts
index 889564f..735bac3 100644
--- a/src/types/schemas/ai-search.ts
+++ b/src/types/schemas/ai-search.ts
@@ -209,3 +209,26 @@ export type PromptExplorerResult = z.infer;
export const brandLookupSearchSchema = z.object({
q: z.string().optional(),
});
+
+/**
+ * /p/$projectId/prompt-explorer query params. The full prompt config is
+ * encoded in the URL so a search is shareable and cmd+click on a history
+ * item opens the same answer in a new tab.
+ */
+export const promptExplorerSearchSchema = z.object({
+ q: z.string().optional(),
+ models: z
+ .union([promptExplorerModelSchema, z.array(promptExplorerModelSchema)])
+ .optional()
+ .transform((value) =>
+ value === undefined ? undefined : Array.isArray(value) ? value : [value],
+ ),
+ web: z
+ .union([z.boolean(), z.enum(["true", "false"])])
+ .optional()
+ .transform((value) =>
+ value === undefined ? undefined : value === true || value === "true",
+ ),
+ cc: webSearchCountryCodeSchema.optional(),
+ hb: z.string().optional(),
+});