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 { useQuery } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import { import {
AlertCircle, AlertCircle,
ArrowLeft, ArrowLeft,
@ -95,10 +96,14 @@ function BrandLookupPageInner({
removeHistoryItem, removeHistoryItem,
} = useBrandLookupSearchHistory(projectId); } = 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(() => { useEffect(() => {
if (hasActiveQuery && lookupQuery.isSuccess) { if (!hasActiveQuery || !lookupQuery.isSuccess) return;
addSearch({ query: trimmedInitialQuery }); if (lastAddedQueryRef.current === trimmedInitialQuery) return;
} lastAddedQueryRef.current = trimmedInitialQuery;
addSearch({ query: trimmedInitialQuery });
}, [hasActiveQuery, lookupQuery.isSuccess, trimmedInitialQuery, addSearch]); }, [hasActiveQuery, lookupQuery.isSuccess, trimmedInitialQuery, addSearch]);
const handleSubmit = (event: FormEvent) => { const handleSubmit = (event: FormEvent) => {
@ -118,17 +123,13 @@ function BrandLookupPageInner({
onQueryChange(trimmed); onQueryChange(trimmed);
}; };
const handleSelectHistoryItem = (item: { query: string }) => { // The query input is reset whenever the URL `q` changes — including the
setQuery(item.query); // 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); setValidationError(null);
onQueryChange(item.query); }, [initialQuery]);
};
const handleShowRecentSearches = () => {
setQuery("");
setValidationError(null);
onQueryChange("");
};
const isLoading = hasActiveQuery && lookupQuery.isPending; const isLoading = hasActiveQuery && lookupQuery.isPending;
const errorMessage = const errorMessage =
@ -191,23 +192,26 @@ function BrandLookupPageInner({
) : resultData ? ( ) : resultData ? (
<> <>
<div> <div>
<button <Link
type="button" 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" className="btn btn-ghost btn-sm gap-2 px-0 text-base-content/70 hover:bg-transparent"
onClick={handleShowRecentSearches}
> >
<ArrowLeft className="size-4" /> <ArrowLeft className="size-4" />
Recent searches Recent searches
</button> </Link>
</div> </div>
<BrandLookupResults result={resultData} /> <BrandLookupResults result={resultData} />
</> </>
) : !errorMessage ? ( ) : !errorMessage ? (
<BrandLookupHistorySection <BrandLookupHistorySection
projectId={projectId}
history={history} history={history}
historyLoaded={historyLoaded} historyLoaded={historyLoaded}
onRemoveHistoryItem={removeHistoryItem} onRemoveHistoryItem={removeHistoryItem}
onSelectHistoryItem={handleSelectHistoryItem}
/> />
) : null} ) : null}
</> </>

View File

@ -1,5 +1,6 @@
import { useState, type FormEvent } from "react"; import { useEffect, useRef, useState, type FormEvent } from "react";
import { useMutation } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import { import {
AlertCircle, AlertCircle,
ArrowLeft, ArrowLeft,
@ -23,19 +24,25 @@ import {
AiSearchSetupGate, AiSearchSetupGate,
} from "@/client/features/ai-search/components/AiSearchSetupGate"; } from "@/client/features/ai-search/components/AiSearchSetupGate";
import { useAiSearchAccess } from "@/client/features/ai-search/useAiSearchAccess"; import { useAiSearchAccess } from "@/client/features/ai-search/useAiSearchAccess";
import { import { usePromptExplorerSearchHistory } from "@/client/hooks/usePromptExplorerSearchHistory";
usePromptExplorerSearchHistory,
type PromptExplorerSearchHistoryItem,
} from "@/client/hooks/usePromptExplorerSearchHistory";
import { import {
PROMPT_EXPLORER_MAX_PROMPT_LENGTH, PROMPT_EXPLORER_MAX_PROMPT_LENGTH,
PROMPT_EXPLORER_MODELS,
type PromptExplorerModel, type PromptExplorerModel,
type WebSearchCountryCode, type WebSearchCountryCode,
} from "@/types/schemas/ai-search"; } from "@/types/schemas/ai-search";
type PromptExplorerFormValues = {
prompt: string;
highlightBrand: string;
models: PromptExplorerModel[];
webSearch: boolean;
webSearchCountryCode: WebSearchCountryCode;
};
type Props = { type Props = {
projectId: string; projectId: string;
urlState: PromptExplorerFormValues;
onSubmit: (values: PromptExplorerFormValues) => void;
}; };
const PROMPT_EXPLORER_BULLETS = [ 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) { export function PromptExplorerPage(props: Props) {
return ( return (
<HostedPlanGate> <HostedPlanGate>
@ -82,9 +73,11 @@ export function PromptExplorerPage(props: Props) {
function PromptExplorerPageInner({ function PromptExplorerPageInner({
projectId, projectId,
urlState,
onSubmit,
planGate, planGate,
}: Props & { planGate: HostedPlanGateState }) { }: 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 [validationError, setValidationError] = useState<string | null>(null);
const access = useAiSearchAccess(projectId); const access = useAiSearchAccess(projectId);
@ -95,45 +88,88 @@ function PromptExplorerPageInner({
removeHistoryItem, removeHistoryItem,
} = usePromptExplorerSearchHistory(projectId); } = usePromptExplorerSearchHistory(projectId);
const exploreMutation = useMutation({ const trimmedPrompt = urlState.prompt.trim();
mutationFn: (input: FormState) => 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({ explorePrompt({
data: { data: {
projectId, projectId,
prompt: input.prompt, prompt: trimmedPrompt,
models: input.models, models: urlState.models,
highlightBrand: highlightBrand: urlState.highlightBrand.trim() || undefined,
input.highlightBrand.length > 0 ? input.highlightBrand : undefined, webSearch: urlState.webSearch,
webSearch: input.webSearch, webSearchCountryCode: urlState.webSearchCountryCode,
webSearchCountryCode: input.webSearchCountryCode,
}, },
}), }),
enabled:
hasActivePrompt &&
urlState.models.length > 0 &&
!planGate.isFreePlan &&
access.enabled,
staleTime: 5 * 60 * 1000,
retry: false,
}); });
const runExplore = (values: FormState) => { // Sync form to URL state — covers initial mount, browser back/forward, and
const normalized: FormState = { // cmd+click history navigation (in the originating tab nothing changes; in
...values, // a new tab the form mounts populated from the URL).
prompt: values.prompt.trim(), useEffect(() => {
highlightBrand: values.highlightBrand.trim(), 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({ addSearch({
prompt: normalized.prompt, prompt: trimmedPrompt,
highlightBrand: normalized.highlightBrand, highlightBrand: urlState.highlightBrand.trim(),
models: normalized.models, models: urlState.models,
webSearch: normalized.webSearch, webSearch: urlState.webSearch,
webSearchCountryCode: normalized.webSearchCountryCode, webSearchCountryCode: urlState.webSearchCountryCode,
}); });
exploreMutation.mutate(normalized); }, [
}; hasActivePrompt,
exploreQuery.isSuccess,
trimmedPrompt,
urlState.highlightBrand,
urlState.models,
urlState.webSearch,
urlState.webSearchCountryCode,
addSearch,
]);
const handleSubmit = (event: FormEvent) => { const handleSubmit = (event: FormEvent) => {
event.preventDefault(); event.preventDefault();
const trimmedPrompt = form.prompt.trim(); const trimmed = form.prompt.trim();
if (trimmedPrompt.length === 0) { if (trimmed.length === 0) {
setValidationError("Enter a prompt"); setValidationError("Enter a prompt");
return; return;
} }
if (trimmedPrompt.length > PROMPT_EXPLORER_MAX_PROMPT_LENGTH) { if (trimmed.length > PROMPT_EXPLORER_MAX_PROMPT_LENGTH) {
setValidationError( setValidationError(
`Keep prompts under ${PROMPT_EXPLORER_MAX_PROMPT_LENGTH} characters`, `Keep prompts under ${PROMPT_EXPLORER_MAX_PROMPT_LENGTH} characters`,
); );
@ -144,35 +180,22 @@ function PromptExplorerPageInner({
return; return;
} }
setValidationError(null); setValidationError(null);
runExplore(form); onSubmit({
...form,
prompt: trimmed,
highlightBrand: form.highlightBrand.trim(),
});
}; };
const handleSelectHistoryItem = (item: PromptExplorerSearchHistoryItem) => { const errorMessage = exploreQuery.isError
const nextForm: FormState = { ? getStandardErrorMessage(exploreQuery.error)
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)
: null; : 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, key: K,
value: FormState[K], value: PromptExplorerFormValues[K],
) => { ) => {
setForm((prev) => ({ ...prev, [key]: value })); setForm((prev) => ({ ...prev, [key]: value }));
if (validationError) setValidationError(null); if (validationError) setValidationError(null);
@ -219,7 +242,7 @@ function PromptExplorerPageInner({
updateForm("webSearchCountryCode", value) updateForm("webSearchCountryCode", value)
} }
onSubmit={handleSubmit} onSubmit={handleSubmit}
isLoading={exploreMutation.isPending} isLoading={isLoading}
validationError={validationError} validationError={validationError}
/> />
@ -233,28 +256,31 @@ function PromptExplorerPageInner({
</div> </div>
) : null} ) : null}
{exploreMutation.isPending ? ( {isLoading ? (
<PromptExplorerLoadingState modelCount={form.models.length} /> <PromptExplorerLoadingState modelCount={form.models.length} />
) : exploreMutation.data ? ( ) : resultData ? (
<> <>
<div> <div>
<button <Link
type="button" 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" className="btn btn-ghost btn-sm gap-2 px-0 text-base-content/70 hover:bg-transparent"
onClick={handleShowRecentSearches}
> >
<ArrowLeft className="size-4" /> <ArrowLeft className="size-4" />
Recent searches Recent searches
</button> </Link>
</div> </div>
<PromptExplorerResults result={exploreMutation.data} /> <PromptExplorerResults result={resultData} />
</> </>
) : !errorMessage ? ( ) : !errorMessage ? (
<PromptExplorerHistorySection <PromptExplorerHistorySection
projectId={projectId}
history={history} history={history}
historyLoaded={historyLoaded} historyLoaded={historyLoaded}
onRemoveHistoryItem={removeHistoryItem} onRemoveHistoryItem={removeHistoryItem}
onSelectHistoryItem={handleSelectHistoryItem}
/> />
) : null} ) : null}
</> </>

View File

@ -1,21 +1,37 @@
import { Link } from "@tanstack/react-router";
import { Sparkles } from "lucide-react"; 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"; import type { BrandLookupSearchHistoryItem } from "@/client/hooks/useBrandLookupSearchHistory";
type Props = { type Props = {
projectId: string;
history: BrandLookupSearchHistoryItem[]; history: BrandLookupSearchHistoryItem[];
historyLoaded: boolean; historyLoaded: boolean;
onRemoveHistoryItem: (timestamp: number) => void; onRemoveHistoryItem: (timestamp: number) => void;
onSelectHistoryItem: (item: BrandLookupSearchHistoryItem) => void;
}; };
export function BrandLookupHistorySection(props: Props) { export function BrandLookupHistorySection({ projectId, ...props }: Props) {
return ( return (
<SearchHistorySection <SearchHistorySection
{...props} {...props}
emptyIcon={Sparkles} emptyIcon={Sparkles}
emptyMessage="Search a brand name or domain to see how AI cites it" emptyMessage="Search a brand name or domain to see how AI cites it"
noun="lookup" 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) => ( renderItem={(item) => (
<p className="font-medium text-base-content truncate">{item.query}</p> <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 { 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 { formatModelLabel } from "@/client/features/ai-search/platformLabels";
import type { PromptExplorerSearchHistoryItem } from "@/client/hooks/usePromptExplorerSearchHistory"; import type { PromptExplorerSearchHistoryItem } from "@/client/hooks/usePromptExplorerSearchHistory";
type Props = { type Props = {
projectId: string;
history: PromptExplorerSearchHistoryItem[]; history: PromptExplorerSearchHistoryItem[];
historyLoaded: boolean; historyLoaded: boolean;
onRemoveHistoryItem: (timestamp: number) => void; onRemoveHistoryItem: (timestamp: number) => void;
onSelectHistoryItem: (item: PromptExplorerSearchHistoryItem) => void;
}; };
export function PromptExplorerHistorySection(props: Props) { export function PromptExplorerHistorySection({ projectId, ...props }: Props) {
return ( return (
<SearchHistorySection <SearchHistorySection
{...props} {...props}
emptyIcon={MessageSquare} emptyIcon={MessageSquare}
emptyMessage="Enter a prompt to compare model answers" emptyMessage="Enter a prompt to compare model answers"
noun="prompt" 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) => ( renderItem={(item) => (
<> <>
<p className="font-medium text-base-content truncate"> <p className="font-medium text-base-content truncate">

View File

@ -5,7 +5,12 @@ type Props<TItem extends { timestamp: number }> = {
history: TItem[]; history: TItem[];
historyLoaded: boolean; historyLoaded: boolean;
onRemoveHistoryItem: (timestamp: number) => void; 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). */ /** Icon component rendered in the empty state (e.g. Sparkles, MessageSquare). */
emptyIcon: ComponentType<{ className?: string }>; emptyIcon: ComponentType<{ className?: string }>;
/** Empty-state headline copy. */ /** Empty-state headline copy. */
@ -23,7 +28,7 @@ export function SearchHistorySection<TItem extends { timestamp: number }>({
history, history,
historyLoaded, historyLoaded,
onRemoveHistoryItem, onRemoveHistoryItem,
onSelectHistoryItem, renderItemLink,
emptyIcon: EmptyIcon, emptyIcon: EmptyIcon,
emptyMessage, emptyMessage,
noun, noun,
@ -62,14 +67,13 @@ export function SearchHistorySection<TItem extends { timestamp: number }>({
key={item.timestamp} key={item.timestamp}
className="group flex items-center gap-2 rounded-lg border border-base-300 bg-base-100 p-2" className="group flex items-center gap-2 rounded-lg border border-base-300 bg-base-100 p-2"
> >
<button {renderItemLink(
type="button" item,
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>
<Clock className="size-4 text-base-content/40 shrink-0" /> </>,
<div className="min-w-0">{renderItem(item)}</div> )}
</button>
<div className="flex items-center gap-2 shrink-0"> <div className="flex items-center gap-2 shrink-0">
<span className="text-xs text-base-content/40"> <span className="text-xs text-base-content/40">
{new Date(item.timestamp).toLocaleDateString(undefined, { {new Date(item.timestamp).toLocaleDateString(undefined, {
@ -92,3 +96,6 @@ export function SearchHistorySection<TItem extends { timestamp: number }>({
</section> </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 { MoreHorizontal, ScanSearch, Trash2 } from "lucide-react";
import type { getAuditHistory } from "@/serverFunctions/audit"; import type { getAuditHistory } from "@/serverFunctions/audit";
import { formatDate, StatusBadge } from "@/client/features/audit/shared"; import { formatDate, StatusBadge } from "@/client/features/audit/shared";
export function AuditHistorySection({ export function AuditHistorySection({
projectId,
history, history,
isLoading, isLoading,
onView,
onDelete, onDelete,
}: { }: {
projectId: string;
history: Awaited<ReturnType<typeof getAuditHistory>>; history: Awaited<ReturnType<typeof getAuditHistory>>;
isLoading: boolean; isLoading: boolean;
onView: (auditId: string) => void;
onDelete: (auditId: string) => void; onDelete: (auditId: string) => void;
}) { }) {
if (history.length === 0 && !isLoading) { if (history.length === 0 && !isLoading) {
@ -60,8 +61,8 @@ export function AuditHistorySection({
</td> </td>
<td> <td>
<HistoryActions <HistoryActions
projectId={projectId}
auditId={audit.id} auditId={audit.id}
onView={onView}
onDelete={onDelete} onDelete={onDelete}
/> />
</td> </td>
@ -76,22 +77,24 @@ export function AuditHistorySection({
} }
function HistoryActions({ function HistoryActions({
projectId,
auditId, auditId,
onView,
onDelete, onDelete,
}: { }: {
projectId: string;
auditId: string; auditId: string;
onView: (auditId: string) => void;
onDelete: (auditId: string) => void; onDelete: (auditId: string) => void;
}) { }) {
return ( 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"> <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" className="btn btn-primary btn-xs"
onClick={() => onView(auditId)}
> >
View View
</button> </Link>
<div className="dropdown dropdown-end"> <div className="dropdown dropdown-end">
<div <div
tabIndex={0} tabIndex={0}

View File

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

View File

@ -1,4 +1,5 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { Link } from "@tanstack/react-router";
import { StatCard } from "@/client/features/audit/shared"; import { StatCard } from "@/client/features/audit/shared";
import { import {
exportPages, exportPages,
@ -12,18 +13,14 @@ import {
PerformanceTable, PerformanceTable,
} from "@/client/features/audit/results/ResultsTables"; } from "@/client/features/audit/results/ResultsTables";
type SearchSetter = (updates: Record<string, string | undefined>) => void;
export function ResultsView({ export function ResultsView({
projectId, projectId,
data, data,
tab, tab,
setSearchParams,
}: { }: {
projectId: string; projectId: string;
data: AuditResultsData; data: AuditResultsData;
tab: string; tab: string;
setSearchParams: SearchSetter;
}) { }) {
const { audit, pages, lighthouse } = data; const { audit, pages, lighthouse } = data;
const hasPerformanceTab = lighthouse.length > 0; 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 bg-base-100 border border-base-300">
<div className="card-body gap-3"> <div className="card-body gap-3">
<ResultsHeader <ResultsHeader
projectId={projectId}
auditId={audit.id}
pageCount={pages.length} pageCount={pages.length}
lighthouseCount={lighthouse.length} lighthouseCount={lighthouse.length}
hasPerformanceTab={hasPerformanceTab} hasPerformanceTab={hasPerformanceTab}
activeTab={activeTab} activeTab={activeTab}
setSearchParams={setSearchParams}
onExport={(format) => { onExport={(format) => {
if (activeTab === "performance") { if (activeTab === "performance") {
exportPerformance(lighthouse, pages, format); exportPerformance(lighthouse, pages, format);
@ -117,38 +115,46 @@ function useResultStats(
} }
function ResultsHeader({ function ResultsHeader({
projectId,
auditId,
pageCount, pageCount,
lighthouseCount, lighthouseCount,
hasPerformanceTab, hasPerformanceTab,
activeTab, activeTab,
setSearchParams,
onExport, onExport,
}: { }: {
projectId: string;
auditId: string;
pageCount: number; pageCount: number;
lighthouseCount: number; lighthouseCount: number;
hasPerformanceTab: boolean; hasPerformanceTab: boolean;
activeTab: string; activeTab: string;
setSearchParams: SearchSetter;
onExport: (format: "csv" | "json" | "sheets") => void; onExport: (format: "csv" | "json" | "sheets") => void;
}) { }) {
return ( return (
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-3"> <div className="flex flex-col lg:flex-row lg:items-center justify-between gap-3">
{hasPerformanceTab ? ( {hasPerformanceTab ? (
<div role="tablist" className="tabs tabs-box w-fit"> <div role="tablist" className="tabs tabs-box w-fit">
<button <Link
to="/p/$projectId/audit"
params={{ projectId }}
search={{ auditId, tab: "pages" }}
replace
role="tab" role="tab"
className={`tab ${activeTab === "pages" ? "tab-active" : ""}`} className={`tab ${activeTab === "pages" ? "tab-active" : ""}`}
onClick={() => setSearchParams({ tab: "pages" })}
> >
Pages ({pageCount}) Pages ({pageCount})
</button> </Link>
<button <Link
to="/p/$projectId/audit"
params={{ projectId }}
search={{ auditId, tab: "performance" }}
replace
role="tab" role="tab"
className={`tab ${activeTab === "performance" ? "tab-active" : ""}`} className={`tab ${activeTab === "performance" ? "tab-active" : ""}`}
onClick={() => setSearchParams({ tab: "performance" })}
> >
Performance ({lighthouseCount}) Performance ({lighthouseCount})
</button> </Link>
</div> </div>
) : ( ) : (
<h3 className="text-base font-medium">Pages ({pageCount})</h3> <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 { Clock, History, Link2, X } from "lucide-react";
import type { BacklinksSearchHistoryItem } from "@/client/hooks/useBacklinksSearchHistory"; import type { BacklinksSearchHistoryItem } from "@/client/hooks/useBacklinksSearchHistory";
type Props = { type Props = {
projectId: string;
history: BacklinksSearchHistoryItem[]; history: BacklinksSearchHistoryItem[];
historyLoaded: boolean; historyLoaded: boolean;
onRemoveHistoryItem: (timestamp: number) => void; onRemoveHistoryItem: (timestamp: number) => void;
onSelectHistoryItem: (item: BacklinksSearchHistoryItem) => void;
}; };
export function BacklinksHistorySection({ export function BacklinksHistorySection({
projectId,
history, history,
historyLoaded, historyLoaded,
onRemoveHistoryItem, onRemoveHistoryItem,
onSelectHistoryItem,
}: Props) { }: Props) {
if (!historyLoaded) { if (!historyLoaded) {
return null; return null;
@ -46,10 +47,17 @@ export function BacklinksHistorySection({
key={item.timestamp} key={item.timestamp}
className="group flex items-center gap-2 rounded-lg border border-base-300 bg-base-100 p-2" className="group flex items-center gap-2 rounded-lg border border-base-300 bg-base-100 p-2"
> >
<button <Link
type="button" 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" 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" /> <Clock className="size-4 text-base-content/40 shrink-0" />
<div className="min-w-0"> <div className="min-w-0">
@ -60,7 +68,7 @@ export function BacklinksHistorySection({
{item.scope === "domain" ? "Site-wide" : "Exact page"} {item.scope === "domain" ? "Site-wide" : "Exact page"}
</p> </p>
</div> </div>
</button> </Link>
<div className="flex items-center gap-2 shrink-0"> <div className="flex items-center gap-2 shrink-0">
<span className="text-xs text-base-content/40"> <span className="text-xs text-base-content/40">
{new Date(item.timestamp).toLocaleDateString(undefined, { {new Date(item.timestamp).toLocaleDateString(undefined, {

View File

@ -2,9 +2,7 @@ import { BacklinksSearchCard } from "./BacklinksSearchCard";
import { BacklinksBody } from "./BacklinksPageContent"; import { BacklinksBody } from "./BacklinksPageContent";
import type { BacklinksPageProps } from "./backlinksPageTypes"; import type { BacklinksPageProps } from "./backlinksPageTypes";
import { import {
navigateToBacklinksHistory,
navigateToBacklinksSearch, navigateToBacklinksSearch,
navigateToBacklinksTab,
useBacklinksPageData, useBacklinksPageData,
} from "./useBacklinksPageData"; } from "./useBacklinksPageData";
import { useBacklinksFilters } from "./useBacklinksFilters"; import { useBacklinksFilters } from "./useBacklinksFilters";
@ -37,16 +35,6 @@ export function BacklinksPage({
removeHistoryItem, removeHistoryItem,
} = useBacklinksSearchHistory(projectId); } = useBacklinksSearchHistory(projectId);
const handleHistorySelect = (item: {
target: string;
scope: "domain" | "page";
}) => {
navigateToBacklinksSearch(navigate, {
target: item.target,
scope: item.scope,
});
};
return ( return (
<div className="px-4 py-4 pb-24 overflow-auto md:px-6 md:py-6 md:pb-8"> <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"> <div className="mx-auto max-w-7xl space-y-4">
@ -77,6 +65,7 @@ export function BacklinksPage({
) : null} ) : null}
<BacklinksBody <BacklinksBody
projectId={projectId}
accessGate={accessGate} accessGate={accessGate}
backlinksDisabledByError={backlinksDisabledByError} backlinksDisabledByError={backlinksDisabledByError}
history={history} history={history}
@ -95,9 +84,6 @@ export function BacklinksPage({
} }
topPages={topPagesQuery.data} topPages={topPagesQuery.data}
onRemoveHistoryItem={removeHistoryItem} onRemoveHistoryItem={removeHistoryItem}
onSelectHistoryItem={handleHistorySelect}
onShowHistory={() => navigateToBacklinksHistory(navigate)}
onSetActiveTab={(tab) => navigateToBacklinksTab(navigate, tab)}
onRetryOverview={() => void overviewQuery.refetch()} onRetryOverview={() => void overviewQuery.refetch()}
/> />
</div> </div>

View File

@ -27,6 +27,7 @@ import {
import type { BacklinksFiltersState } from "./useBacklinksFilters"; import type { BacklinksFiltersState } from "./useBacklinksFilters";
type BacklinksBodyProps = { type BacklinksBodyProps = {
projectId: string;
accessGate: UseAccessGateResult; accessGate: UseAccessGateResult;
backlinksDisabledByError: boolean; backlinksDisabledByError: boolean;
history: BacklinksSearchHistoryItem[]; history: BacklinksSearchHistoryItem[];
@ -41,13 +42,11 @@ type BacklinksBodyProps = {
tabLoading: boolean; tabLoading: boolean;
topPages: BacklinksTopPagesData | undefined; topPages: BacklinksTopPagesData | undefined;
onRemoveHistoryItem: (timestamp: number) => void; onRemoveHistoryItem: (timestamp: number) => void;
onSelectHistoryItem: (item: BacklinksSearchHistoryItem) => void;
onShowHistory: () => void;
onSetActiveTab: (tab: BacklinksSearchState["tab"]) => void;
onRetryOverview: () => void; onRetryOverview: () => void;
}; };
export function BacklinksBody({ export function BacklinksBody({
projectId,
accessGate, accessGate,
backlinksDisabledByError, backlinksDisabledByError,
history, history,
@ -62,9 +61,6 @@ export function BacklinksBody({
tabLoading, tabLoading,
topPages, topPages,
onRemoveHistoryItem, onRemoveHistoryItem,
onSelectHistoryItem,
onShowHistory,
onSetActiveTab,
onRetryOverview, onRetryOverview,
}: BacklinksBodyProps) { }: BacklinksBodyProps) {
const mergedData = useMemo( const mergedData = useMemo(
@ -123,10 +119,10 @@ export function BacklinksBody({
if (!searchState.target) { if (!searchState.target) {
return ( return (
<BacklinksHistorySection <BacklinksHistorySection
projectId={projectId}
history={history} history={history}
historyLoaded={historyLoaded} historyLoaded={historyLoaded}
onRemoveHistoryItem={onRemoveHistoryItem} onRemoveHistoryItem={onRemoveHistoryItem}
onSelectHistoryItem={onSelectHistoryItem}
/> />
); );
} }
@ -147,11 +143,12 @@ export function BacklinksBody({
return ( return (
<> <>
<BacklinksOverviewPanels <BacklinksOverviewPanels
projectId={projectId}
data={mergedData} data={mergedData}
onShowHistory={onShowHistory}
summaryStats={summaryStats} summaryStats={summaryStats}
/> />
<BacklinksResultsCard <BacklinksResultsCard
projectId={projectId}
activeTab={searchState.tab} activeTab={searchState.tab}
filteredData={filteredData} filteredData={filteredData}
filters={filters} filters={filters}
@ -159,7 +156,6 @@ export function BacklinksBody({
tabErrorMessage={ tabErrorMessage={
searchState.tab !== "backlinks" ? tabErrorMessage : null searchState.tab !== "backlinks" ? tabErrorMessage : null
} }
onSetActiveTab={onSetActiveTab}
exportTarget={mergedData.displayTarget || searchState.target} exportTarget={mergedData.displayTarget || searchState.target}
/> />
</> </>

View File

@ -1,4 +1,5 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { Link } from "@tanstack/react-router";
import { HeaderHelpLabel } from "@/client/features/keywords/components"; import { HeaderHelpLabel } from "@/client/features/keywords/components";
import { ArrowLeft, Download, SlidersHorizontal } from "lucide-react"; import { ArrowLeft, Download, SlidersHorizontal } from "lucide-react";
import { ExportToSheetsButton } from "@/client/components/table/ExportToSheetsButton"; import { ExportToSheetsButton } from "@/client/components/table/ExportToSheetsButton";
@ -22,25 +23,27 @@ import { buildBacklinksTabExport, exportBacklinksTabCsv } from "./export";
import type { BacklinksFiltersState } from "./useBacklinksFilters"; import type { BacklinksFiltersState } from "./useBacklinksFilters";
export function BacklinksOverviewPanels({ export function BacklinksOverviewPanels({
projectId,
data, data,
onShowHistory,
summaryStats, summaryStats,
}: { }: {
projectId: string;
data: BacklinksOverviewData; data: BacklinksOverviewData;
onShowHistory: () => void;
summaryStats: Array<{ label: string; value: string; description: string }>; summaryStats: Array<{ label: string; value: string; description: string }>;
}) { }) {
return ( return (
<> <>
<div> <div>
<button <Link
type="button" 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" className="btn btn-ghost btn-sm gap-2 px-0 text-base-content/70 hover:bg-transparent"
onClick={onShowHistory}
> >
<ArrowLeft className="size-4" /> <ArrowLeft className="size-4" />
Recent searches Recent searches
</button> </Link>
</div> </div>
<div className="flex flex-wrap items-center gap-2 text-sm text-base-content/65"> <div className="flex flex-wrap items-center gap-2 text-sm text-base-content/65">
<span className="badge badge-outline">{data.scope}</span> <span className="badge badge-outline">{data.scope}</span>
@ -63,14 +66,15 @@ export function BacklinksOverviewPanels({
} }
export function BacklinksResultsCard({ export function BacklinksResultsCard({
projectId,
activeTab, activeTab,
filteredData, filteredData,
filters, filters,
isTabLoading, isTabLoading,
tabErrorMessage, tabErrorMessage,
onSetActiveTab,
exportTarget, exportTarget,
}: { }: {
projectId: string;
activeTab: BacklinksSearchState["tab"]; activeTab: BacklinksSearchState["tab"];
filteredData: { filteredData: {
backlinks: BacklinksOverviewData["backlinks"]; backlinks: BacklinksOverviewData["backlinks"];
@ -80,7 +84,6 @@ export function BacklinksResultsCard({
filters: BacklinksFiltersState; filters: BacklinksFiltersState;
isTabLoading: boolean; isTabLoading: boolean;
tabErrorMessage: string | null; tabErrorMessage: string | null;
onSetActiveTab: (tab: BacklinksSearchState["tab"]) => void;
exportTarget: string; exportTarget: string;
}) { }) {
const currentFilterCount = filters[activeTab].activeFilterCount; 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="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 className="space-y-2">
<div role="tablist" className="tabs tabs-box w-fit"> <div role="tablist" className="tabs tabs-box w-fit">
<TabButton <TabLink
projectId={projectId}
activeTab={activeTab} activeTab={activeTab}
tab="backlinks" tab="backlinks"
onClick={onSetActiveTab}
> >
Backlinks Backlinks
</TabButton> </TabLink>
<TabButton <TabLink projectId={projectId} activeTab={activeTab} tab="domains">
activeTab={activeTab}
tab="domains"
onClick={onSetActiveTab}
>
Referring Domains Referring Domains
</TabButton> </TabLink>
<TabButton <TabLink projectId={projectId} activeTab={activeTab} tab="pages">
activeTab={activeTab}
tab="pages"
onClick={onSetActiveTab}
>
Top Pages Top Pages
</TabButton> </TabLink>
</div> </div>
<p className="max-w-xl text-sm text-base-content/60"> <p className="max-w-xl text-sm text-base-content/60">
{TAB_DESCRIPTIONS[activeTab]} {TAB_DESCRIPTIONS[activeTab]}
@ -280,25 +275,31 @@ function TrendCard({
); );
} }
function TabButton({ function TabLink({
projectId,
activeTab, activeTab,
children, children,
onClick,
tab, tab,
}: { }: {
projectId: string;
activeTab: BacklinksSearchState["tab"]; activeTab: BacklinksSearchState["tab"];
children: string; children: string;
onClick: (tab: BacklinksSearchState["tab"]) => void;
tab: BacklinksSearchState["tab"]; tab: BacklinksSearchState["tab"];
}) { }) {
return ( return (
<button <Link
to="/p/$projectId/backlinks"
params={{ projectId }}
search={(prev) => ({
...prev,
tab: tab === "backlinks" ? undefined : tab,
})}
replace
role="tab" role="tab"
className={`tab ${activeTab === tab ? "tab-active" : ""}`} className={`tab ${activeTab === tab ? "tab-active" : ""}`}
onClick={() => onClick(tab)}
> >
{children} {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( function buildBacklinksRequestInput(
projectId: string, projectId: string,
searchState: BacklinksSearchState, searchState: BacklinksSearchState,

View File

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

View File

@ -1,4 +1,5 @@
import { type Dispatch, type SetStateAction } from "react"; import { type Dispatch, type SetStateAction } from "react";
import { Link } from "@tanstack/react-router";
import { import {
ChevronDown, ChevronDown,
Copy, Copy,
@ -14,7 +15,11 @@ import { DomainFilterPanel } from "@/client/features/domain/components/DomainFil
import { DomainKeywordsTable } from "@/client/features/domain/components/DomainKeywordsTable"; import { DomainKeywordsTable } from "@/client/features/domain/components/DomainKeywordsTable";
import { DomainPagesTable } from "@/client/features/domain/components/DomainPagesTable"; import { DomainPagesTable } from "@/client/features/domain/components/DomainPagesTable";
import type { useDomainFilters } from "@/client/features/domain/hooks/useDomainFilters"; 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 { buildCsv, downloadCsv } from "@/client/lib/csv";
import { exportTableToSheets } from "@/client/lib/exportToSheets"; import { exportTableToSheets } from "@/client/lib/exportToSheets";
import { captureClientEvent } from "@/client/lib/posthog"; import { captureClientEvent } from "@/client/lib/posthog";
@ -28,6 +33,7 @@ import type {
} from "@/client/features/domain/types"; } from "@/client/features/domain/types";
type Props = { type Props = {
projectId: string;
overview: DomainOverviewData; overview: DomainOverviewData;
activeTab: DomainActiveTab; activeTab: DomainActiveTab;
sortMode: DomainSortMode; sortMode: DomainSortMode;
@ -42,7 +48,6 @@ type Props = {
filtersForm: ReturnType<typeof useDomainFilters>["filtersForm"]; filtersForm: ReturnType<typeof useDomainFilters>["filtersForm"];
activeFilterCount: number; activeFilterCount: number;
resetFilters: () => void; resetFilters: () => void;
onTabChange: (tab: DomainActiveTab) => void;
onSearchChange: (value: string) => void; onSearchChange: (value: string) => void;
onSaveKeywords: () => void; onSaveKeywords: () => void;
canSaveKeywords: boolean; canSaveKeywords: boolean;
@ -51,7 +56,14 @@ type Props = {
onToggleAllVisible: () => void; onToggleAllVisible: () => void;
}; };
const KEYWORDS_ONLY_SORTS: ReadonlySet<DomainSortMode> = new Set([
"rank",
"score",
"cpc",
]);
export function DomainResultsCard({ export function DomainResultsCard({
projectId,
overview, overview,
activeTab, activeTab,
sortMode, sortMode,
@ -66,7 +78,6 @@ export function DomainResultsCard({
filtersForm, filtersForm,
activeFilterCount, activeFilterCount,
resetFilters, resetFilters,
onTabChange,
onSearchChange, onSearchChange,
onSaveKeywords, onSaveKeywords,
canSaveKeywords, canSaveKeywords,
@ -112,20 +123,40 @@ export function DomainResultsCard({
<div className="border border-base-300 rounded-xl bg-base-100 overflow-hidden"> <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 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"> <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" role="tab"
className={`tab ${activeTab === "keywords" ? "tab-active" : ""}`} className={`tab ${activeTab === "keywords" ? "tab-active" : ""}`}
onClick={() => onTabChange("keywords")}
> >
Top Keywords Top Keywords
</button> </Link>
<button <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" role="tab"
className={`tab ${activeTab === "pages" ? "tab-active" : ""}`} className={`tab ${activeTab === "pages" ? "tab-active" : ""}`}
onClick={() => onTabChange("pages")}
> >
Top Pages Top Pages
</button> </Link>
</div> </div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">

View File

@ -1,5 +1,4 @@
import { useMutation } from "@tanstack/react-query"; import { useRef, useState } from "react";
import { useState } from "react";
import { getStandardErrorMessage } from "@/client/lib/error-messages"; import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { captureClientEvent } from "@/client/lib/posthog"; import { captureClientEvent } from "@/client/lib/posthog";
import { LOCATIONS, getLanguageCode } from "@/client/features/keywords/utils"; import { LOCATIONS, getLanguageCode } from "@/client/features/keywords/utils";
@ -30,26 +29,23 @@ export function useKeywordResearchData(addSearch: AddSearchFn) {
DEFAULT_LOCATION_CODE, DEFAULT_LOCATION_CODE,
); );
const [researchError, setResearchError] = useState<string | null>(null); const [researchError, setResearchError] = useState<string | null>(null);
const [researchMutationError, setResearchMutationError] =
useState<unknown>(null);
const [searchedKeyword, setSearchedKeyword] = useState(""); const [searchedKeyword, setSearchedKeyword] = useState("");
const [isLoading, setIsLoading] = useState(false);
const researchMutation = useMutation({ // Sequence token so a stale fetch (e.g. user fired a second search before
mutationFn: (data: { // the first resolved) can't overwrite state that belongs to a newer one.
projectId: string; const requestSeqRef = useRef(0);
keywords: string[];
locationCode: number;
languageCode: string;
resultLimit: ResultLimit;
mode: KeywordMode;
}) => researchKeywords({ data }),
});
const beginSearch = (seedKeyword: string, locationCode: number) => { const beginSearch = (seedKeyword: string, locationCode: number) => {
setResearchError(null); setResearchError(null);
setResearchMutationError(null);
setHasSearched(true); setHasSearched(true);
setLastSearchError(false); setLastSearchError(false);
setSearchedKeyword(seedKeyword); setSearchedKeyword(seedKeyword);
setLastSearchKeyword(seedKeyword); setLastSearchKeyword(seedKeyword);
setLastSearchLocationCode(locationCode); setLastSearchLocationCode(locationCode);
setIsLoading(true);
}; };
const resetResearch = () => { const resetResearch = () => {
@ -61,10 +57,12 @@ export function useKeywordResearchData(addSearch: AddSearchFn) {
setLastSearchKeyword(""); setLastSearchKeyword("");
setLastSearchLocationCode(DEFAULT_LOCATION_CODE); setLastSearchLocationCode(DEFAULT_LOCATION_CODE);
setResearchError(null); setResearchError(null);
setResearchMutationError(null);
setSearchedKeyword(""); setSearchedKeyword("");
setIsLoading(false);
}; };
const runSearch = ( const runSearch = async (
input: { input: {
projectId: string; projectId: string;
keywords: string[]; keywords: string[];
@ -79,49 +77,54 @@ export function useKeywordResearchData(addSearch: AddSearchFn) {
) => { ) => {
const seedKeyword = input.keywords[0] ?? ""; const seedKeyword = input.keywords[0] ?? "";
const languageCode = getLanguageCode(input.locationCode); const languageCode = getLanguageCode(input.locationCode);
const requestSeq = ++requestSeqRef.current;
const isStale = () => requestSeqRef.current !== requestSeq;
researchMutation.mutate( try {
{ const result = await researchKeywords({
keywords: input.keywords, data: {
projectId: input.projectId, keywords: input.keywords,
locationCode: input.locationCode, projectId: input.projectId,
languageCode, locationCode: input.locationCode,
resultLimit: input.resultLimit, languageCode,
mode: input.mode, 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);
}, },
onError: (error) => { });
setLastSearchError(true);
setRows([]); if (isStale()) return;
setResearchError(getStandardErrorMessage(error, "Research failed."));
handlers?.onError?.(); 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 { return {
@ -133,9 +136,9 @@ export function useKeywordResearchData(addSearch: AddSearchFn) {
lastSearchKeyword, lastSearchKeyword,
lastSearchLocationCode, lastSearchLocationCode,
researchError, researchError,
researchMutationError: researchMutation.error, researchMutationError,
searchedKeyword, searchedKeyword,
isLoading: researchMutation.isPending, isLoading,
beginSearch, beginSearch,
resetResearch, resetResearch,
runSearch, runSearch,

View File

@ -21,26 +21,6 @@ type KeywordSearchParams = {
exclude?: string; 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): { export function normalizeLegacyKeywordSearch(search: KeywordSearchParams): {
normalized: KeywordSearchParams; normalized: KeywordSearchParams;
changed: boolean; changed: boolean;

View File

@ -1,22 +1,29 @@
import { Link } from "@tanstack/react-router";
import { Clock, Globe, History, Search, X } from "lucide-react"; 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 { LOCATIONS } from "@/client/features/keywords/utils";
import type { KeywordResearchControllerState } from "./types"; import type { KeywordResearchControllerState } from "./types";
type Props = { type Props = {
controller: KeywordResearchControllerState; controller: KeywordResearchControllerState;
projectId: string;
}; };
export function KeywordResearchEmptyState({ controller }: Props) { export function KeywordResearchEmptyState({ controller, projectId }: Props) {
const { hasSearched, isLoading, lastSearchError } = controller; const { hasSearched, isLoading, lastSearchError } = controller;
if (hasSearched && !isLoading && !lastSearchError) { if (hasSearched && !isLoading && !lastSearchError) {
return <NoResultsState controller={controller} />; 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; const { lastSearchKeyword, lastSearchLocationCode } = controller;
return ( return (
@ -44,8 +51,14 @@ function NoResultsState({ controller }: Props) {
); );
} }
function SearchHistoryState({ controller }: Props) { function SearchHistoryState({
const { history, historyLoaded, onSearch, removeHistoryItem } = controller; controller,
projectId,
}: {
controller: KeywordResearchControllerState;
projectId: string;
}) {
const { history, historyLoaded, removeHistoryItem } = controller;
if (!historyLoaded) { if (!historyLoaded) {
return null; return null;
@ -70,15 +83,19 @@ function SearchHistoryState({ controller }: Props) {
key={item.timestamp} key={item.timestamp}
className="group flex items-center gap-2 rounded-lg border border-base-300 bg-base-100 p-2" className="group flex items-center gap-2 rounded-lg border border-base-300 bg-base-100 p-2"
> >
<button <Link
type="button" 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" 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" /> <Clock className="size-4 shrink-0 text-base-content/40" />
<div className="min-w-0"> <div className="min-w-0">
@ -89,7 +106,7 @@ function SearchHistoryState({ controller }: Props) {
{item.locationName} {item.locationName}
</p> </p>
</div> </div>
</button> </Link>
<div className="flex shrink-0 items-center gap-2"> <div className="flex shrink-0 items-center gap-2">
<span className="text-xs text-base-content/40"> <span className="text-xs text-base-content/40">
{new Date(item.timestamp).toLocaleDateString(undefined, { {new Date(item.timestamp).toLocaleDateString(undefined, {

View File

@ -10,16 +10,10 @@ import { KeywordResearchResults } from "./KeywordResearchResults";
import { KeywordResearchSearchBar } from "./KeywordResearchSearchBar"; import { KeywordResearchSearchBar } from "./KeywordResearchSearchBar";
import type { KeywordResearchControllerState } from "./types"; import type { KeywordResearchControllerState } from "./types";
type Props = KeywordResearchControllerInput & { type Props = KeywordResearchControllerInput;
onShowRecentSearches: () => void;
};
export function KeywordResearchPage({ onShowRecentSearches, ...input }: Props) { export function KeywordResearchPage(input: Props) {
const controller = useKeywordResearchController(input); const controller = useKeywordResearchController(input);
const handleShowRecentSearches = () => {
controller.resetView();
onShowRecentSearches();
};
return ( return (
<div className="px-4 py-4 md:px-6 md:py-6 pb-24 md:pb-8 overflow-auto"> <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} /> <KeywordResearchSearchBar controller={controller} />
<KeywordResearchContent <KeywordResearchContent
controller={controller} controller={controller}
onShowRecentSearches={handleShowRecentSearches} projectId={input.projectId}
/> />
<KeywordSaveDialog controller={controller} /> <KeywordSaveDialog controller={controller} />
</div> </div>
@ -44,21 +38,24 @@ export function KeywordResearchPage({ onShowRecentSearches, ...input }: Props) {
function KeywordResearchContent({ function KeywordResearchContent({
controller, controller,
onShowRecentSearches, projectId,
}: { }: {
controller: KeywordResearchControllerState; controller: KeywordResearchControllerState;
onShowRecentSearches: () => void; projectId: string;
}) { }) {
const recentSearchesButton = controller.hasSearched ? ( const recentSearchesButton = controller.hasSearched ? (
<div> <div>
<button <Link
type="button" 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" className="btn btn-ghost btn-sm gap-2 px-0 text-base-content/70 hover:bg-transparent"
onClick={onShowRecentSearches}
> >
<ArrowLeft className="size-4" /> <ArrowLeft className="size-4" />
Recent searches Recent searches
</button> </Link>
</div> </div>
) : null; ) : null;
@ -101,7 +98,10 @@ function KeywordResearchContent({
return ( return (
<div className="space-y-4 pt-1"> <div className="space-y-4 pt-1">
{recentSearchesButton} {recentSearchesButton}
<KeywordResearchEmptyState controller={controller} /> <KeywordResearchEmptyState
controller={controller}
projectId={projectId}
/>
</div> </div>
); );
} }

View File

@ -7,6 +7,10 @@ import { getLanguageCode } from "@/client/features/keywords/utils";
import type { KeywordResearchRow } from "@/types/keywords"; import type { KeywordResearchRow } from "@/types/keywords";
import type { SaveKeywordsInput } from "@/types/schemas/keywords"; import type { SaveKeywordsInput } from "@/types/schemas/keywords";
import type { SortDir, SortField } from "@/client/features/keywords/components"; import type { SortDir, SortField } from "@/client/features/keywords/components";
import type {
KeywordMode,
ResultLimit,
} from "@/client/features/keywords/keywordResearchTypes";
import type { KeywordResearchControllerInput } from "./useKeywordResearchController"; import type { KeywordResearchControllerInput } from "./useKeywordResearchController";
export const KEYWORD_RESEARCH_HEADERS = [ export const KEYWORD_RESEARCH_HEADERS = [
@ -51,6 +55,25 @@ export function parseKeywordInput(value: string) {
.filter(Boolean); .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( export function getNextSortParams(
currentField: SortField, currentField: SortField,
currentDirection: SortDir, 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 { useKeywordControlsForm } from "@/client/features/keywords/hooks/useKeywordControlsForm";
import { useKeywordFiltering } from "@/client/features/keywords/hooks/useKeywordFiltering"; import { useKeywordFiltering } from "@/client/features/keywords/hooks/useKeywordFiltering";
import { useLocalKeywordFilters } from "@/client/features/keywords/hooks/useLocalKeywordFilters"; 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 { KeywordResearchRow } from "@/types/keywords";
import type { SortDir, SortField } from "@/client/features/keywords/components"; import type { SortDir, SortField } from "@/client/features/keywords/components";
import { import {
buildKeywordSearchKey,
getNextSortParams, getNextSortParams,
parseKeywordInput, parseKeywordInput,
useSaveAndExportActions, useSaveAndExportActions,
@ -96,18 +97,6 @@ export function useKeywordResearchController(
state.setSerpPage(0); 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 { return {
activeFilterCount: state.activeFilterCount, activeFilterCount: state.activeFilterCount,
activeSerpKeyword: state.activeSerpKeyword, activeSerpKeyword: state.activeSerpKeyword,
@ -135,7 +124,6 @@ export function useKeywordResearchController(
removeHistoryItem: state.removeHistoryItem, removeHistoryItem: state.removeHistoryItem,
researchError: state.researchError, researchError: state.researchError,
researchMutationError: state.researchMutationError, researchMutationError: state.researchMutationError,
resetView,
resetFilters: state.resetFilters, resetFilters: state.resetFilters,
rows: state.rows, rows: state.rows,
searchedKeyword: state.searchedKeyword, searchedKeyword: state.searchedKeyword,
@ -212,41 +200,34 @@ function useKeywordControllerState(input: KeywordResearchControllerInput) {
const setSearchParams = useKeywordSearchParams(); const setSearchParams = useKeywordSearchParams();
const saveMutation = useKeywordSaveMutation(input.projectId); const saveMutation = useKeywordSaveMutation(input.projectId);
const controlsForm = useKeywordControlsForm( // 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
...input, // already kicked off a search for the same params.
locationCode, const lastTriggerKeyRef = useRef<string | null>(null);
},
(value) => {
const keywords = parseKeywordInput(value.keyword);
const activeLocation = value.locationCode;
const activeResultLimit = value.resultLimit;
const activeMode = value.mode;
setPreferredLocationCode(activeLocation); const triggerSearch = useCallback(
setSearchParams({ (params: {
q: value.keyword, keyword: string;
loc: locationCode: number;
input.hasExplicitLocationCode || resultLimit: ResultLimit;
activeLocation !== DEFAULT_LOCATION_CODE mode: KeywordMode;
? activeLocation }) => {
: undefined, const keywords = parseKeywordInput(params.keyword);
kLimit: activeResultLimit === 150 ? undefined : activeResultLimit, if (keywords.length === 0) return;
mode: activeMode === "auto" ? undefined : activeMode,
});
lastTriggerKeyRef.current = buildKeywordSearchKey(params);
uiState.setSelectedKeyword(null); uiState.setSelectedKeyword(null);
clearSelection(); clearSelection();
setSerpKeyword(null); setSerpKeyword(null);
beginSearch(keywords[0] ?? "", activeLocation); beginSearch(keywords[0] ?? "", params.locationCode);
runSearch( void runSearch(
{ {
projectId: input.projectId, projectId: input.projectId,
keywords, keywords,
locationCode: activeLocation, locationCode: params.locationCode,
resultLimit: activeResultLimit, resultLimit: params.resultLimit,
mode: activeMode, mode: params.mode,
}, },
{ {
onSuccess: (seedKeyword, nextRows) => { 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({ const { filteredRows, activeFilterCount } = useKeywordFiltering({
rows, rows,
filters: filterValues, filters: filterValues,

View File

@ -1,6 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 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 { toast } from "sonner";
import { LOCATIONS } from "@/client/features/keywords/locations"; import { LOCATIONS } from "@/client/features/keywords/locations";
import { import {
@ -31,7 +31,6 @@ export function RankTrackingDomainList({
projectId: string; projectId: string;
onAddDomain: () => void; onAddDomain: () => void;
}) { }) {
const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [archiveTarget, setArchiveTarget] = useState<ConfigSummary | null>( const [archiveTarget, setArchiveTarget] = useState<ConfigSummary | null>(
null, null,
@ -88,13 +87,8 @@ export function RankTrackingDomainList({
(summaries ?? []).map((summary) => ( (summaries ?? []).map((summary) => (
<DomainRow <DomainRow
key={summary.id} key={summary.id}
projectId={projectId}
summary={summary} summary={summary}
onClick={() =>
void navigate({
to: "/p/$projectId/rank-tracking/$configId",
params: { projectId, configId: summary.id },
})
}
onArchive={() => setArchiveTarget(summary)} onArchive={() => setArchiveTarget(summary)}
/> />
)) ))
@ -134,31 +128,26 @@ export function RankTrackingDomainList({
} }
function DomainRow({ function DomainRow({
projectId,
summary, summary,
onClick,
onArchive, onArchive,
}: { }: {
projectId: string;
summary: ConfigSummary; summary: ConfigSummary;
onClick: () => void;
onArchive: () => void; onArchive: () => void;
}) { }) {
const dl = getDevicesLabel(summary.devices); const dl = getDevicesLabel(summary.devices);
const sl = getScheduleLabel(summary.scheduleInterval); const sl = getScheduleLabel(summary.scheduleInterval);
return ( return (
<div <div className="relative flex w-full items-center gap-4 px-5 py-3.5 transition-colors hover:bg-base-200/50">
role="button" <Link
tabIndex={0} to="/p/$projectId/rank-tracking/$configId"
className="flex w-full items-center gap-4 px-5 py-3.5 text-left transition-colors hover:bg-base-200/50 cursor-pointer" params={{ projectId, configId: summary.id }}
onClick={onClick} className="absolute inset-0 z-0"
onKeyDown={(e) => { aria-label={`Open ${summary.domain}`}
if (e.key === "Enter" || e.key === " ") { />
e.preventDefault(); <div className="min-w-0 flex-1 pointer-events-none">
onClick();
}
}}
>
<div className="min-w-0 flex-1">
<p className="font-medium truncate">{summary.domain}</p> <p className="font-medium truncate">{summary.domain}</p>
<p className="text-xs text-base-content/60"> <p className="text-xs text-base-content/60">
{LOCATIONS[summary.locationCode] ?? "US"} &middot; {dl} &middot; {sl} {LOCATIONS[summary.locationCode] ?? "US"} &middot; {dl} &middot; {sl}
@ -177,7 +166,7 @@ function DomainRow({
</p> </p>
)} )}
</div> </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 && ( {summary.keywordCount > 0 && (
<div className="text-center"> <div className="text-center">
<p className="text-xs uppercase tracking-wide text-base-content/60"> <p className="text-xs uppercase tracking-wide text-base-content/60">
@ -189,16 +178,17 @@ function DomainRow({
</div> </div>
<button <button
type="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" title="Archive domain"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
e.preventDefault();
onArchive(); onArchive();
}} }}
> >
<Archive className="size-4" /> <Archive className="size-4" />
</button> </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> </div>
); );
} }

View File

@ -55,7 +55,6 @@ function SiteAuditPage() {
projectId={projectId} projectId={projectId}
auditId={auditId} auditId={auditId}
tab={tab} tab={tab}
setSearchParams={setSearchParams}
onBack={() => setSearchParams({ auditId: undefined })} onBack={() => setSearchParams({ auditId: undefined })}
/> />
); );
@ -65,13 +64,11 @@ function AuditDetail({
projectId, projectId,
auditId, auditId,
tab, tab,
setSearchParams,
onBack, onBack,
}: { }: {
projectId: string; projectId: string;
auditId: string; auditId: string;
tab: string; tab: string;
setSearchParams: (updates: Record<string, string | undefined>) => void;
onBack: () => void; onBack: () => void;
}) { }) {
const statusQuery = useQuery({ const statusQuery = useQuery({
@ -181,7 +178,6 @@ function AuditDetail({
projectId={projectId} projectId={projectId}
data={resultsQuery.data} data={resultsQuery.data}
tab={tab} tab={tab}
setSearchParams={setSearchParams}
/> />
)} )}
</div> </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 { DEFAULT_LOCATION_CODE } from "@/client/features/keywords/locations";
import { KeywordResearchPage } from "@/client/features/keywords/page/KeywordResearchPage"; import { KeywordResearchPage } from "@/client/features/keywords/page/KeywordResearchPage";
import { import {
clearKeywordSearchParams,
isResultLimit, isResultLimit,
normalizeKeywordMode, normalizeKeywordMode,
normalizeLegacyKeywordSearch, normalizeLegacyKeywordSearch,
@ -29,7 +28,6 @@ export const Route = createFileRoute("/_project/p/$projectId/keywords")({
function KeywordResearchPageRoute() { function KeywordResearchPageRoute() {
const { projectId } = Route.useParams(); const { projectId } = Route.useParams();
const navigate = useNavigate({ from: Route.fullPath });
const search = Route.useSearch(); const search = Route.useSearch();
const { const {
q: keywordInput = "", q: keywordInput = "",
@ -43,12 +41,6 @@ function KeywordResearchPageRoute() {
return ( return (
<KeywordResearchPage <KeywordResearchPage
onShowRecentSearches={() => {
void navigate({
search: clearKeywordSearchParams,
replace: true,
});
}}
projectId={projectId} projectId={projectId}
keywordInput={keywordInput} keywordInput={keywordInput}
locationCode={locationCode} 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 { 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")({ export const Route = createFileRoute("/_project/p/$projectId/prompt-explorer")({
validateSearch: promptExplorerSearchSchema,
component: PromptExplorerRoute, component: PromptExplorerRoute,
}); });
function PromptExplorerRoute() { function PromptExplorerRoute() {
const { projectId } = Route.useParams(); 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({ export const brandLookupSearchSchema = z.object({
q: z.string().optional(), 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(),
});