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:
parent
11252f8088
commit
c1a9c1f57d
@ -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}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -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}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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";
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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, {
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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, {
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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"} · {dl} · {sl}
|
{LOCATIONS[summary.locationCode] ?? "US"} · {dl} · {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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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(),
|
||||||
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user