diff --git a/src/client/features/ai-search/BrandLookupPage.tsx b/src/client/features/ai-search/BrandLookupPage.tsx index 61f9b2d..c331b36 100644 --- a/src/client/features/ai-search/BrandLookupPage.tsx +++ b/src/client/features/ai-search/BrandLookupPage.tsx @@ -1,5 +1,6 @@ -import { useEffect, useState, type FormEvent } from "react"; +import { useEffect, useRef, useState, type FormEvent } from "react"; import { useQuery } from "@tanstack/react-query"; +import { Link } from "@tanstack/react-router"; import { AlertCircle, ArrowLeft, @@ -95,10 +96,14 @@ function BrandLookupPageInner({ removeHistoryItem, } = useBrandLookupSearchHistory(projectId); + // Dedup ref prevents repeat adds — `addSearch` identity is not stable + // across renders, so we'd otherwise re-write the same item every render. + const lastAddedQueryRef = useRef(null); useEffect(() => { - if (hasActiveQuery && lookupQuery.isSuccess) { - addSearch({ query: trimmedInitialQuery }); - } + if (!hasActiveQuery || !lookupQuery.isSuccess) return; + if (lastAddedQueryRef.current === trimmedInitialQuery) return; + lastAddedQueryRef.current = trimmedInitialQuery; + addSearch({ query: trimmedInitialQuery }); }, [hasActiveQuery, lookupQuery.isSuccess, trimmedInitialQuery, addSearch]); const handleSubmit = (event: FormEvent) => { @@ -118,17 +123,13 @@ function BrandLookupPageInner({ onQueryChange(trimmed); }; - const handleSelectHistoryItem = (item: { query: string }) => { - setQuery(item.query); + // The query input is reset whenever the URL `q` changes — including the + // browser-back path and Cmd+click navigation. This keeps local form state + // in sync with the URL source-of-truth. + useEffect(() => { + setQuery(initialQuery); setValidationError(null); - onQueryChange(item.query); - }; - - const handleShowRecentSearches = () => { - setQuery(""); - setValidationError(null); - onQueryChange(""); - }; + }, [initialQuery]); const isLoading = hasActiveQuery && lookupQuery.isPending; const errorMessage = @@ -191,23 +192,26 @@ function BrandLookupPageInner({ ) : resultData ? ( <>
- +
) : !errorMessage ? ( ) : null} diff --git a/src/client/features/ai-search/PromptExplorerPage.tsx b/src/client/features/ai-search/PromptExplorerPage.tsx index 6ef2fd2..29d9724 100644 --- a/src/client/features/ai-search/PromptExplorerPage.tsx +++ b/src/client/features/ai-search/PromptExplorerPage.tsx @@ -1,5 +1,6 @@ -import { useState, type FormEvent } from "react"; -import { useMutation } from "@tanstack/react-query"; +import { useEffect, useRef, useState, type FormEvent } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Link } from "@tanstack/react-router"; import { AlertCircle, ArrowLeft, @@ -23,19 +24,25 @@ import { AiSearchSetupGate, } from "@/client/features/ai-search/components/AiSearchSetupGate"; import { useAiSearchAccess } from "@/client/features/ai-search/useAiSearchAccess"; -import { - usePromptExplorerSearchHistory, - type PromptExplorerSearchHistoryItem, -} from "@/client/hooks/usePromptExplorerSearchHistory"; +import { usePromptExplorerSearchHistory } from "@/client/hooks/usePromptExplorerSearchHistory"; import { PROMPT_EXPLORER_MAX_PROMPT_LENGTH, - PROMPT_EXPLORER_MODELS, type PromptExplorerModel, type WebSearchCountryCode, } from "@/types/schemas/ai-search"; +type PromptExplorerFormValues = { + prompt: string; + highlightBrand: string; + models: PromptExplorerModel[]; + webSearch: boolean; + webSearchCountryCode: WebSearchCountryCode; +}; + type Props = { projectId: string; + urlState: PromptExplorerFormValues; + onSubmit: (values: PromptExplorerFormValues) => void; }; const PROMPT_EXPLORER_BULLETS = [ @@ -56,22 +63,6 @@ const PROMPT_EXPLORER_BULLETS = [ }, ]; -type FormState = { - prompt: string; - highlightBrand: string; - models: PromptExplorerModel[]; - webSearch: boolean; - webSearchCountryCode: WebSearchCountryCode; -}; - -const INITIAL_FORM_STATE: FormState = { - prompt: "", - highlightBrand: "", - models: [...PROMPT_EXPLORER_MODELS], - webSearch: true, - webSearchCountryCode: "US", -}; - export function PromptExplorerPage(props: Props) { return ( @@ -82,9 +73,11 @@ export function PromptExplorerPage(props: Props) { function PromptExplorerPageInner({ projectId, + urlState, + onSubmit, planGate, }: Props & { planGate: HostedPlanGateState }) { - const [form, setForm] = useState(INITIAL_FORM_STATE); + const [form, setForm] = useState(urlState); const [validationError, setValidationError] = useState(null); const access = useAiSearchAccess(projectId); @@ -95,45 +88,88 @@ function PromptExplorerPageInner({ removeHistoryItem, } = usePromptExplorerSearchHistory(projectId); - const exploreMutation = useMutation({ - mutationFn: (input: FormState) => + const trimmedPrompt = urlState.prompt.trim(); + const hasActivePrompt = trimmedPrompt.length > 0; + + const exploreQuery = useQuery({ + queryKey: [ + "prompt-explorer", + projectId, + trimmedPrompt, + urlState.models.toSorted().join(","), + urlState.webSearch, + urlState.webSearchCountryCode, + urlState.highlightBrand.trim(), + ], + queryFn: () => explorePrompt({ data: { projectId, - prompt: input.prompt, - models: input.models, - highlightBrand: - input.highlightBrand.length > 0 ? input.highlightBrand : undefined, - webSearch: input.webSearch, - webSearchCountryCode: input.webSearchCountryCode, + prompt: trimmedPrompt, + models: urlState.models, + highlightBrand: urlState.highlightBrand.trim() || undefined, + webSearch: urlState.webSearch, + webSearchCountryCode: urlState.webSearchCountryCode, }, }), + enabled: + hasActivePrompt && + urlState.models.length > 0 && + !planGate.isFreePlan && + access.enabled, + staleTime: 5 * 60 * 1000, + retry: false, }); - const runExplore = (values: FormState) => { - const normalized: FormState = { - ...values, - prompt: values.prompt.trim(), - highlightBrand: values.highlightBrand.trim(), - }; + // Sync form to URL state — covers initial mount, browser back/forward, and + // cmd+click history navigation (in the originating tab nothing changes; in + // a new tab the form mounts populated from the URL). + useEffect(() => { + setForm(urlState); + setValidationError(null); + }, [urlState]); + + // Persist successful searches to history. Run on isSuccess so failed + // requests don't pollute recent searches. The dedup ref prevents repeat + // adds when downstream renders create new urlState references. + const lastAddedKeyRef = useRef(null); + useEffect(() => { + if (!hasActivePrompt || !exploreQuery.isSuccess) return; + const key = [ + trimmedPrompt, + urlState.highlightBrand.trim(), + urlState.models.toSorted().join(","), + urlState.webSearch, + urlState.webSearchCountryCode, + ].join("|"); + if (lastAddedKeyRef.current === key) return; + lastAddedKeyRef.current = key; addSearch({ - prompt: normalized.prompt, - highlightBrand: normalized.highlightBrand, - models: normalized.models, - webSearch: normalized.webSearch, - webSearchCountryCode: normalized.webSearchCountryCode, + prompt: trimmedPrompt, + highlightBrand: urlState.highlightBrand.trim(), + models: urlState.models, + webSearch: urlState.webSearch, + webSearchCountryCode: urlState.webSearchCountryCode, }); - exploreMutation.mutate(normalized); - }; + }, [ + hasActivePrompt, + exploreQuery.isSuccess, + trimmedPrompt, + urlState.highlightBrand, + urlState.models, + urlState.webSearch, + urlState.webSearchCountryCode, + addSearch, + ]); const handleSubmit = (event: FormEvent) => { event.preventDefault(); - const trimmedPrompt = form.prompt.trim(); - if (trimmedPrompt.length === 0) { + const trimmed = form.prompt.trim(); + if (trimmed.length === 0) { setValidationError("Enter a prompt"); return; } - if (trimmedPrompt.length > PROMPT_EXPLORER_MAX_PROMPT_LENGTH) { + if (trimmed.length > PROMPT_EXPLORER_MAX_PROMPT_LENGTH) { setValidationError( `Keep prompts under ${PROMPT_EXPLORER_MAX_PROMPT_LENGTH} characters`, ); @@ -144,35 +180,22 @@ function PromptExplorerPageInner({ return; } setValidationError(null); - runExplore(form); + onSubmit({ + ...form, + prompt: trimmed, + highlightBrand: form.highlightBrand.trim(), + }); }; - const handleSelectHistoryItem = (item: PromptExplorerSearchHistoryItem) => { - const nextForm: FormState = { - prompt: item.prompt, - highlightBrand: item.highlightBrand, - models: item.models, - webSearch: item.webSearch, - webSearchCountryCode: item.webSearchCountryCode, - }; - setForm(nextForm); - setValidationError(null); - runExplore(nextForm); - }; - - const handleShowRecentSearches = () => { - exploreMutation.reset(); - setForm(INITIAL_FORM_STATE); - setValidationError(null); - }; - - const errorMessage = exploreMutation.isError - ? getStandardErrorMessage(exploreMutation.error) + const errorMessage = exploreQuery.isError + ? getStandardErrorMessage(exploreQuery.error) : null; + const isLoading = hasActivePrompt && exploreQuery.isPending; + const resultData = hasActivePrompt ? exploreQuery.data : undefined; - const updateForm = ( + const updateForm = ( key: K, - value: FormState[K], + value: PromptExplorerFormValues[K], ) => { setForm((prev) => ({ ...prev, [key]: value })); if (validationError) setValidationError(null); @@ -219,7 +242,7 @@ function PromptExplorerPageInner({ updateForm("webSearchCountryCode", value) } onSubmit={handleSubmit} - isLoading={exploreMutation.isPending} + isLoading={isLoading} validationError={validationError} /> @@ -233,28 +256,31 @@ function PromptExplorerPageInner({ ) : null} - {exploreMutation.isPending ? ( + {isLoading ? ( - ) : exploreMutation.data ? ( + ) : resultData ? ( <>
- +
- + ) : !errorMessage ? ( ) : null} diff --git a/src/client/features/ai-search/components/BrandLookupHistorySection.tsx b/src/client/features/ai-search/components/BrandLookupHistorySection.tsx index 60e47d5..3f39630 100644 --- a/src/client/features/ai-search/components/BrandLookupHistorySection.tsx +++ b/src/client/features/ai-search/components/BrandLookupHistorySection.tsx @@ -1,21 +1,37 @@ +import { Link } from "@tanstack/react-router"; import { Sparkles } from "lucide-react"; -import { SearchHistorySection } from "@/client/features/ai-search/components/SearchHistorySection"; +import { + HISTORY_ITEM_LINK_CLASS, + SearchHistorySection, +} from "@/client/features/ai-search/components/SearchHistorySection"; import type { BrandLookupSearchHistoryItem } from "@/client/hooks/useBrandLookupSearchHistory"; type Props = { + projectId: string; history: BrandLookupSearchHistoryItem[]; historyLoaded: boolean; onRemoveHistoryItem: (timestamp: number) => void; - onSelectHistoryItem: (item: BrandLookupSearchHistoryItem) => void; }; -export function BrandLookupHistorySection(props: Props) { +export function BrandLookupHistorySection({ projectId, ...props }: Props) { return ( ( + + {content} + + )} renderItem={(item) => (

{item.query}

)} diff --git a/src/client/features/ai-search/components/PromptExplorerHistorySection.tsx b/src/client/features/ai-search/components/PromptExplorerHistorySection.tsx index 8880cc7..527ba64 100644 --- a/src/client/features/ai-search/components/PromptExplorerHistorySection.tsx +++ b/src/client/features/ai-search/components/PromptExplorerHistorySection.tsx @@ -1,22 +1,47 @@ +import { Link } from "@tanstack/react-router"; import { MessageSquare } from "lucide-react"; -import { SearchHistorySection } from "@/client/features/ai-search/components/SearchHistorySection"; +import { + HISTORY_ITEM_LINK_CLASS, + SearchHistorySection, +} from "@/client/features/ai-search/components/SearchHistorySection"; import { formatModelLabel } from "@/client/features/ai-search/platformLabels"; import type { PromptExplorerSearchHistoryItem } from "@/client/hooks/usePromptExplorerSearchHistory"; type Props = { + projectId: string; history: PromptExplorerSearchHistoryItem[]; historyLoaded: boolean; onRemoveHistoryItem: (timestamp: number) => void; - onSelectHistoryItem: (item: PromptExplorerSearchHistoryItem) => void; }; -export function PromptExplorerHistorySection(props: Props) { +export function PromptExplorerHistorySection({ projectId, ...props }: Props) { return ( ( + + {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(), +});