Replace mutation-based search with URL-driven state management (#145)

* fix: preserve cmd+click on tracked domain rows

Tracked-domain rows used <div role="button" onClick={navigate(...)}>,
which prevented standard browser open-in-new-tab behavior (cmd+click,
middle-click, right-click → open). Use TanStack Router's <Link> as a
stretched overlay so the row is a real <a href> while the archive
button stays interactive.

* fix: use <Link> for tab toggles and history navigation

Replace onClick={() => navigate(...)} / setSearchParams patterns with
TanStack Router's <Link> across surfaces that change the URL on click.
<Link> renders a real <a href> and only intercepts plain left-clicks,
so cmd/ctrl+click, middle-click, and right-click → open-in-new-tab
all work natively.

- Audit: history "View" button + Pages/Performance tab toggles.
- Domain overview: Top Keywords / Top Pages tab toggles. The keyword-
  only sort fallback now happens in the Link's search updater.
- Backlinks: history items, Backlinks/Domains/Pages tab toggles, and
  "Recent searches" back-link. Removes now-unused
  navigateToBacklinksHistory / navigateToBacklinksTab helpers.

Keyword research and domain history items, and AI search histories,
are not converted: those pages don't trigger their data fetch from
URL params alone, so a plain link target wouldn't reproduce the
current click behavior without a deeper refactor.

* refactor: unify search-page state around URL-driven fetching

Drive Keyword Research, Brand Lookup, and Prompt Explorer from URL
search params so a search is reproducible from a link alone. With
that, all three history surfaces become <Link>s and cmd+click /
right-click → "open in new tab" work natively.

- Shared SearchHistorySection now takes a renderItemLink slot so
  callers wrap history items in a <Link> with the right destination.
- Prompt Explorer: added URL search params (q, models, web, cc, hb)
  via promptExplorerSearchSchema; switched the explore mutation to
  useQuery keyed on the URL params; addSearch now fires from a
  success effect; "Recent searches" back-button is a <Link>.
- Brand Lookup: history items + "Recent searches" back-button are
  <Link>s; local form state stays in sync with URL via an effect.
- Keyword Research: form submit still navigates+kicks off a search
  for the same-URL re-submit case, but the controller also runs an
  URL-driven search trigger (with a dedup ref against the form path).
  Direct URLs, cmd+click on history, and browser back/forward all
  reproduce the same fetch. "Recent searches" back-button and the
  history items are <Link>s; the bespoke resetView path is gone.

Also drops now-unused clearKeywordSearchParams,
navigateToBacklinksHistory/Tab helpers' last consumers, and the
PromptExplorerPage's onQueryChange/onSelectHistoryItem callbacks.

* format

* refactor: replace useMutation with manual state in keyword research

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Ben Senescu 2026-05-04 13:00:37 -04:00 committed by GitHub
parent 11252f8088
commit c1a9c1f57d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 631 additions and 432 deletions

View File

@ -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<string | null>(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 ? (
<>
<div>
<button
type="button"
<Link
from="/p/$projectId/brand-lookup"
to="/p/$projectId/brand-lookup"
params={{ projectId }}
search={{ q: undefined }}
replace
className="btn btn-ghost btn-sm gap-2 px-0 text-base-content/70 hover:bg-transparent"
onClick={handleShowRecentSearches}
>
<ArrowLeft className="size-4" />
Recent searches
</button>
</Link>
</div>
<BrandLookupResults result={resultData} />
</>
) : !errorMessage ? (
<BrandLookupHistorySection
projectId={projectId}
history={history}
historyLoaded={historyLoaded}
onRemoveHistoryItem={removeHistoryItem}
onSelectHistoryItem={handleSelectHistoryItem}
/>
) : null}
</>

View File

@ -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 (
<HostedPlanGate>
@ -82,9 +73,11 @@ export function PromptExplorerPage(props: Props) {
function PromptExplorerPageInner({
projectId,
urlState,
onSubmit,
planGate,
}: Props & { planGate: HostedPlanGateState }) {
const [form, setForm] = useState<FormState>(INITIAL_FORM_STATE);
const [form, setForm] = useState<PromptExplorerFormValues>(urlState);
const [validationError, setValidationError] = useState<string | null>(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<string | null>(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 = <K extends keyof FormState>(
const updateForm = <K extends keyof PromptExplorerFormValues>(
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({
</div>
) : null}
{exploreMutation.isPending ? (
{isLoading ? (
<PromptExplorerLoadingState modelCount={form.models.length} />
) : exploreMutation.data ? (
) : resultData ? (
<>
<div>
<button
type="button"
<Link
from="/p/$projectId/prompt-explorer"
to="/p/$projectId/prompt-explorer"
params={{ projectId }}
search={{}}
replace
className="btn btn-ghost btn-sm gap-2 px-0 text-base-content/70 hover:bg-transparent"
onClick={handleShowRecentSearches}
>
<ArrowLeft className="size-4" />
Recent searches
</button>
</Link>
</div>
<PromptExplorerResults result={exploreMutation.data} />
<PromptExplorerResults result={resultData} />
</>
) : !errorMessage ? (
<PromptExplorerHistorySection
projectId={projectId}
history={history}
historyLoaded={historyLoaded}
onRemoveHistoryItem={removeHistoryItem}
onSelectHistoryItem={handleSelectHistoryItem}
/>
) : null}
</>

View File

@ -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 (
<SearchHistorySection
{...props}
emptyIcon={Sparkles}
emptyMessage="Search a brand name or domain to see how AI cites it"
noun="lookup"
renderItemLink={(item, content) => (
<Link
from="/p/$projectId/brand-lookup"
to="/p/$projectId/brand-lookup"
params={{ projectId }}
search={{ q: item.query }}
replace
className={HISTORY_ITEM_LINK_CLASS}
>
{content}
</Link>
)}
renderItem={(item) => (
<p className="font-medium text-base-content truncate">{item.query}</p>
)}

View File

@ -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 (
<SearchHistorySection
{...props}
emptyIcon={MessageSquare}
emptyMessage="Enter a prompt to compare model answers"
noun="prompt"
renderItemLink={(item, content) => (
<Link
from="/p/$projectId/prompt-explorer"
to="/p/$projectId/prompt-explorer"
params={{ projectId }}
search={{
q: item.prompt,
models: item.models,
web: item.webSearch ? undefined : false,
cc:
item.webSearchCountryCode === "US"
? undefined
: item.webSearchCountryCode,
hb: item.highlightBrand || undefined,
}}
replace
className={HISTORY_ITEM_LINK_CLASS}
>
{content}
</Link>
)}
renderItem={(item) => (
<>
<p className="font-medium text-base-content truncate">

View File

@ -5,7 +5,12 @@ type Props<TItem extends { timestamp: number }> = {
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 <Link> (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<TItem extends { timestamp: number }>({
history,
historyLoaded,
onRemoveHistoryItem,
onSelectHistoryItem,
renderItemLink,
emptyIcon: EmptyIcon,
emptyMessage,
noun,
@ -62,14 +67,13 @@ export function SearchHistorySection<TItem extends { timestamp: number }>({
key={item.timestamp}
className="group flex items-center gap-2 rounded-lg border border-base-300 bg-base-100 p-2"
>
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-3 rounded-md px-1 py-1 text-left transition-colors hover:bg-base-200"
onClick={() => onSelectHistoryItem(item)}
>
<Clock className="size-4 text-base-content/40 shrink-0" />
<div className="min-w-0">{renderItem(item)}</div>
</button>
{renderItemLink(
item,
<>
<Clock className="size-4 text-base-content/40 shrink-0" />
<div className="min-w-0">{renderItem(item)}</div>
</>,
)}
<div className="flex items-center gap-2 shrink-0">
<span className="text-xs text-base-content/40">
{new Date(item.timestamp).toLocaleDateString(undefined, {
@ -92,3 +96,6 @@ export function SearchHistorySection<TItem extends { timestamp: number }>({
</section>
);
}
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";

View File

@ -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<ReturnType<typeof getAuditHistory>>;
isLoading: boolean;
onView: (auditId: string) => void;
onDelete: (auditId: string) => void;
}) {
if (history.length === 0 && !isLoading) {
@ -60,8 +61,8 @@ export function AuditHistorySection({
</td>
<td>
<HistoryActions
projectId={projectId}
auditId={audit.id}
onView={onView}
onDelete={onDelete}
/>
</td>
@ -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 (
<div className="flex items-center justify-end gap-2 transition-opacity md:opacity-0 md:group-hover:opacity-100 md:group-focus-within:opacity-100">
<button
<Link
to="/p/$projectId/audit"
params={{ projectId }}
search={{ auditId, tab: "pages" }}
className="btn btn-primary btn-xs"
onClick={() => onView(auditId)}
>
View
</button>
</Link>
<div className="dropdown dropdown-end">
<div
tabIndex={0}

View File

@ -22,9 +22,9 @@ export function LaunchView({
/>
<AuditHistorySection
projectId={projectId}
history={controller.historyQuery.data ?? []}
isLoading={controller.historyQuery.isLoading}
onView={onAuditStarted}
onDelete={controller.deleteAudit}
/>
</div>

View File

@ -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<string, string | undefined>) => 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({
<div className="card bg-base-100 border border-base-300">
<div className="card-body gap-3">
<ResultsHeader
projectId={projectId}
auditId={audit.id}
pageCount={pages.length}
lighthouseCount={lighthouse.length}
hasPerformanceTab={hasPerformanceTab}
activeTab={activeTab}
setSearchParams={setSearchParams}
onExport={(format) => {
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 (
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-3">
{hasPerformanceTab ? (
<div role="tablist" className="tabs tabs-box w-fit">
<button
<Link
to="/p/$projectId/audit"
params={{ projectId }}
search={{ auditId, tab: "pages" }}
replace
role="tab"
className={`tab ${activeTab === "pages" ? "tab-active" : ""}`}
onClick={() => setSearchParams({ tab: "pages" })}
>
Pages ({pageCount})
</button>
<button
</Link>
<Link
to="/p/$projectId/audit"
params={{ projectId }}
search={{ auditId, tab: "performance" }}
replace
role="tab"
className={`tab ${activeTab === "performance" ? "tab-active" : ""}`}
onClick={() => setSearchParams({ tab: "performance" })}
>
Performance ({lighthouseCount})
</button>
</Link>
</div>
) : (
<h3 className="text-base font-medium">Pages ({pageCount})</h3>

View File

@ -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"
>
<button
type="button"
<Link
to="/p/$projectId/backlinks"
params={{ projectId }}
search={(prev) => ({
...prev,
target: item.target,
scope: item.scope,
tab: undefined,
})}
replace
className="flex min-w-0 flex-1 items-center gap-3 rounded-md px-1 py-1 text-left transition-colors hover:bg-base-200"
onClick={() => onSelectHistoryItem(item)}
>
<Clock className="size-4 text-base-content/40 shrink-0" />
<div className="min-w-0">
@ -60,7 +68,7 @@ export function BacklinksHistorySection({
{item.scope === "domain" ? "Site-wide" : "Exact page"}
</p>
</div>
</button>
</Link>
<div className="flex items-center gap-2 shrink-0">
<span className="text-xs text-base-content/40">
{new Date(item.timestamp).toLocaleDateString(undefined, {

View File

@ -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 (
<div className="px-4 py-4 pb-24 overflow-auto md:px-6 md:py-6 md:pb-8">
<div className="mx-auto max-w-7xl space-y-4">
@ -77,6 +65,7 @@ export function BacklinksPage({
) : null}
<BacklinksBody
projectId={projectId}
accessGate={accessGate}
backlinksDisabledByError={backlinksDisabledByError}
history={history}
@ -95,9 +84,6 @@ export function BacklinksPage({
}
topPages={topPagesQuery.data}
onRemoveHistoryItem={removeHistoryItem}
onSelectHistoryItem={handleHistorySelect}
onShowHistory={() => navigateToBacklinksHistory(navigate)}
onSetActiveTab={(tab) => navigateToBacklinksTab(navigate, tab)}
onRetryOverview={() => void overviewQuery.refetch()}
/>
</div>

View File

@ -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 (
<BacklinksHistorySection
projectId={projectId}
history={history}
historyLoaded={historyLoaded}
onRemoveHistoryItem={onRemoveHistoryItem}
onSelectHistoryItem={onSelectHistoryItem}
/>
);
}
@ -147,11 +143,12 @@ export function BacklinksBody({
return (
<>
<BacklinksOverviewPanels
projectId={projectId}
data={mergedData}
onShowHistory={onShowHistory}
summaryStats={summaryStats}
/>
<BacklinksResultsCard
projectId={projectId}
activeTab={searchState.tab}
filteredData={filteredData}
filters={filters}
@ -159,7 +156,6 @@ export function BacklinksBody({
tabErrorMessage={
searchState.tab !== "backlinks" ? tabErrorMessage : null
}
onSetActiveTab={onSetActiveTab}
exportTarget={mergedData.displayTarget || searchState.target}
/>
</>

View File

@ -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 (
<>
<div>
<button
type="button"
<Link
to="/p/$projectId/backlinks"
params={{ projectId }}
search={{ target: undefined, scope: undefined, tab: undefined }}
replace
className="btn btn-ghost btn-sm gap-2 px-0 text-base-content/70 hover:bg-transparent"
onClick={onShowHistory}
>
<ArrowLeft className="size-4" />
Recent searches
</button>
</Link>
</div>
<div className="flex flex-wrap items-center gap-2 text-sm text-base-content/65">
<span className="badge badge-outline">{data.scope}</span>
@ -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({
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-3 px-4 py-3 border-b border-base-300">
<div className="space-y-2">
<div role="tablist" className="tabs tabs-box w-fit">
<TabButton
<TabLink
projectId={projectId}
activeTab={activeTab}
tab="backlinks"
onClick={onSetActiveTab}
>
Backlinks
</TabButton>
<TabButton
activeTab={activeTab}
tab="domains"
onClick={onSetActiveTab}
>
</TabLink>
<TabLink projectId={projectId} activeTab={activeTab} tab="domains">
Referring Domains
</TabButton>
<TabButton
activeTab={activeTab}
tab="pages"
onClick={onSetActiveTab}
>
</TabLink>
<TabLink projectId={projectId} activeTab={activeTab} tab="pages">
Top Pages
</TabButton>
</TabLink>
</div>
<p className="max-w-xl text-sm text-base-content/60">
{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 (
<button
<Link
to="/p/$projectId/backlinks"
params={{ projectId }}
search={(prev) => ({
...prev,
tab: tab === "backlinks" ? undefined : tab,
})}
replace
role="tab"
className={`tab ${activeTab === tab ? "tab-active" : ""}`}
onClick={() => onClick(tab)}
>
{children}
</button>
</Link>
);
}

View File

@ -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,

View File

@ -125,6 +125,7 @@ export function DomainOverviewPage({
) : null}
<DomainResultsCard
projectId={projectId}
overview={state.overview}
activeTab={searchState.tab}
sortMode={searchState.sort}
@ -139,17 +140,6 @@ export function DomainOverviewPage({
filtersForm={state.filtersForm}
activeFilterCount={state.activeFilterCount}
resetFilters={state.resetFilters}
onTabChange={(tab) => {
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}

View File

@ -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<typeof useDomainFilters>["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<DomainSortMode> = 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({
<div className="border border-base-300 rounded-xl bg-base-100 overflow-hidden">
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-3 px-4 py-3 border-b border-base-300">
<div role="tablist" className="tabs tabs-box w-fit">
<button
<Link
from="/p/$projectId/domain"
to="/p/$projectId/domain"
params={{ projectId }}
search={(prev) => ({ ...prev, tab: undefined })}
replace
role="tab"
className={`tab ${activeTab === "keywords" ? "tab-active" : ""}`}
onClick={() => onTabChange("keywords")}
>
Top Keywords
</button>
<button
</Link>
<Link
from="/p/$projectId/domain"
to="/p/$projectId/domain"
params={{ projectId }}
search={(prev) => {
const fallbackSortNeeded = KEYWORDS_ONLY_SORTS.has(sortMode);
const nextSort = fallbackSortNeeded ? "traffic" : prev.sort;
const nextOrder = fallbackSortNeeded
? getDefaultSortOrder("traffic")
: prev.order;
return {
...prev,
tab: "pages" as const,
sort: nextSort,
order: nextOrder,
};
}}
replace
role="tab"
className={`tab ${activeTab === "pages" ? "tab-active" : ""}`}
onClick={() => onTabChange("pages")}
>
Top Pages
</button>
</Link>
</div>
<div className="flex flex-wrap items-center gap-2">

View File

@ -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<string | null>(null);
const [researchMutationError, setResearchMutationError] =
useState<unknown>(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,

View File

@ -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;

View File

@ -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 <NoResultsState controller={controller} />;
}
return <SearchHistoryState controller={controller} />;
return <SearchHistoryState controller={controller} projectId={projectId} />;
}
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"
>
<button
type="button"
<Link
from="/p/$projectId/keywords"
to="/p/$projectId/keywords"
params={{ projectId }}
search={{
q: item.keyword,
loc:
item.locationCode === DEFAULT_LOCATION_CODE
? undefined
: item.locationCode,
}}
replace
className="flex min-w-0 flex-1 items-center gap-3 rounded-md px-1 py-1 text-left transition-colors hover:bg-base-200"
onClick={() =>
onSearch({
keyword: item.keyword,
locationCode: item.locationCode,
})
}
>
<Clock className="size-4 shrink-0 text-base-content/40" />
<div className="min-w-0">
@ -89,7 +106,7 @@ function SearchHistoryState({ controller }: Props) {
{item.locationName}
</p>
</div>
</button>
</Link>
<div className="flex shrink-0 items-center gap-2">
<span className="text-xs text-base-content/40">
{new Date(item.timestamp).toLocaleDateString(undefined, {

View File

@ -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 (
<div className="px-4 py-4 md:px-6 md:py-6 pb-24 md:pb-8 overflow-auto">
@ -34,7 +28,7 @@ export function KeywordResearchPage({ onShowRecentSearches, ...input }: Props) {
<KeywordResearchSearchBar controller={controller} />
<KeywordResearchContent
controller={controller}
onShowRecentSearches={handleShowRecentSearches}
projectId={input.projectId}
/>
<KeywordSaveDialog controller={controller} />
</div>
@ -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 ? (
<div>
<button
type="button"
<Link
from="/p/$projectId/keywords"
to="/p/$projectId/keywords"
params={{ projectId }}
search={{}}
replace
className="btn btn-ghost btn-sm gap-2 px-0 text-base-content/70 hover:bg-transparent"
onClick={onShowRecentSearches}
>
<ArrowLeft className="size-4" />
Recent searches
</button>
</Link>
</div>
) : null;
@ -101,7 +98,10 @@ function KeywordResearchContent({
return (
<div className="space-y-4 pt-1">
{recentSearchesButton}
<KeywordResearchEmptyState controller={controller} />
<KeywordResearchEmptyState
controller={controller}
projectId={projectId}
/>
</div>
);
}

View File

@ -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,

View File

@ -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<string | null>(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,

View File

@ -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<ConfigSummary | null>(
null,
@ -88,13 +87,8 @@ export function RankTrackingDomainList({
(summaries ?? []).map((summary) => (
<DomainRow
key={summary.id}
projectId={projectId}
summary={summary}
onClick={() =>
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 (
<div
role="button"
tabIndex={0}
className="flex w-full items-center gap-4 px-5 py-3.5 text-left transition-colors hover:bg-base-200/50 cursor-pointer"
onClick={onClick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onClick();
}
}}
>
<div className="min-w-0 flex-1">
<div className="relative flex w-full items-center gap-4 px-5 py-3.5 transition-colors hover:bg-base-200/50">
<Link
to="/p/$projectId/rank-tracking/$configId"
params={{ projectId, configId: summary.id }}
className="absolute inset-0 z-0"
aria-label={`Open ${summary.domain}`}
/>
<div className="min-w-0 flex-1 pointer-events-none">
<p className="font-medium truncate">{summary.domain}</p>
<p className="text-xs text-base-content/60">
{LOCATIONS[summary.locationCode] ?? "US"} &middot; {dl} &middot; {sl}
@ -177,7 +166,7 @@ function DomainRow({
</p>
)}
</div>
<div className="hidden sm:flex items-center gap-6 text-sm">
<div className="hidden sm:flex items-center gap-6 text-sm pointer-events-none">
{summary.keywordCount > 0 && (
<div className="text-center">
<p className="text-xs uppercase tracking-wide text-base-content/60">
@ -189,16 +178,17 @@ function DomainRow({
</div>
<button
type="button"
className="btn btn-ghost btn-xs text-base-content/40 hover:text-error"
className="btn btn-ghost btn-xs text-base-content/40 hover:text-error relative z-10"
title="Archive domain"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
onArchive();
}}
>
<Archive className="size-4" />
</button>
<ChevronRight className="size-4 shrink-0 text-base-content/40" />
<ChevronRight className="size-4 shrink-0 text-base-content/40 pointer-events-none" />
</div>
);
}

View File

@ -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<string, string | undefined>) => void;
onBack: () => void;
}) {
const statusQuery = useQuery({
@ -181,7 +178,6 @@ function AuditDetail({
projectId={projectId}
data={resultsQuery.data}
tab={tab}
setSearchParams={setSearchParams}
/>
)}
</div>

View File

@ -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 (
<KeywordResearchPage
onShowRecentSearches={() => {
void navigate({
search: clearKeywordSearchParams,
replace: true,
});
}}
projectId={projectId}
keywordInput={keywordInput}
locationCode={locationCode}

View File

@ -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 <PromptExplorerPage projectId={projectId} />;
const navigate = useNavigate({ from: Route.fullPath });
const search = Route.useSearch();
return (
<PromptExplorerPage
projectId={projectId}
urlState={{
prompt: search.q ?? "",
highlightBrand: search.hb ?? "",
models:
search.models && search.models.length > 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,
});
}}
/>
);
}

View File

@ -209,3 +209,26 @@ export type PromptExplorerResult = z.infer<typeof promptExplorerResultSchema>;
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(),
});