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 { Link } from "@tanstack/react-router";
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowLeft,
|
||||
@ -95,10 +96,14 @@ function BrandLookupPageInner({
|
||||
removeHistoryItem,
|
||||
} = useBrandLookupSearchHistory(projectId);
|
||||
|
||||
// Dedup ref prevents repeat adds — `addSearch` identity is not stable
|
||||
// across renders, so we'd otherwise re-write the same item every render.
|
||||
const lastAddedQueryRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (hasActiveQuery && lookupQuery.isSuccess) {
|
||||
addSearch({ query: trimmedInitialQuery });
|
||||
}
|
||||
if (!hasActiveQuery || !lookupQuery.isSuccess) return;
|
||||
if (lastAddedQueryRef.current === trimmedInitialQuery) return;
|
||||
lastAddedQueryRef.current = trimmedInitialQuery;
|
||||
addSearch({ query: trimmedInitialQuery });
|
||||
}, [hasActiveQuery, lookupQuery.isSuccess, trimmedInitialQuery, addSearch]);
|
||||
|
||||
const handleSubmit = (event: FormEvent) => {
|
||||
@ -118,17 +123,13 @@ function BrandLookupPageInner({
|
||||
onQueryChange(trimmed);
|
||||
};
|
||||
|
||||
const handleSelectHistoryItem = (item: { query: string }) => {
|
||||
setQuery(item.query);
|
||||
// The query input is reset whenever the URL `q` changes — including the
|
||||
// browser-back path and Cmd+click navigation. This keeps local form state
|
||||
// in sync with the URL source-of-truth.
|
||||
useEffect(() => {
|
||||
setQuery(initialQuery);
|
||||
setValidationError(null);
|
||||
onQueryChange(item.query);
|
||||
};
|
||||
|
||||
const handleShowRecentSearches = () => {
|
||||
setQuery("");
|
||||
setValidationError(null);
|
||||
onQueryChange("");
|
||||
};
|
||||
}, [initialQuery]);
|
||||
|
||||
const isLoading = hasActiveQuery && lookupQuery.isPending;
|
||||
const errorMessage =
|
||||
@ -191,23 +192,26 @@ function BrandLookupPageInner({
|
||||
) : resultData ? (
|
||||
<>
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
<Link
|
||||
from="/p/$projectId/brand-lookup"
|
||||
to="/p/$projectId/brand-lookup"
|
||||
params={{ projectId }}
|
||||
search={{ q: undefined }}
|
||||
replace
|
||||
className="btn btn-ghost btn-sm gap-2 px-0 text-base-content/70 hover:bg-transparent"
|
||||
onClick={handleShowRecentSearches}
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
Recent searches
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
<BrandLookupResults result={resultData} />
|
||||
</>
|
||||
) : !errorMessage ? (
|
||||
<BrandLookupHistorySection
|
||||
projectId={projectId}
|
||||
history={history}
|
||||
historyLoaded={historyLoaded}
|
||||
onRemoveHistoryItem={removeHistoryItem}
|
||||
onSelectHistoryItem={handleSelectHistoryItem}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useEffect, useRef, useState, type FormEvent } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowLeft,
|
||||
@ -23,19 +24,25 @@ import {
|
||||
AiSearchSetupGate,
|
||||
} from "@/client/features/ai-search/components/AiSearchSetupGate";
|
||||
import { useAiSearchAccess } from "@/client/features/ai-search/useAiSearchAccess";
|
||||
import {
|
||||
usePromptExplorerSearchHistory,
|
||||
type PromptExplorerSearchHistoryItem,
|
||||
} from "@/client/hooks/usePromptExplorerSearchHistory";
|
||||
import { usePromptExplorerSearchHistory } from "@/client/hooks/usePromptExplorerSearchHistory";
|
||||
import {
|
||||
PROMPT_EXPLORER_MAX_PROMPT_LENGTH,
|
||||
PROMPT_EXPLORER_MODELS,
|
||||
type PromptExplorerModel,
|
||||
type WebSearchCountryCode,
|
||||
} from "@/types/schemas/ai-search";
|
||||
|
||||
type PromptExplorerFormValues = {
|
||||
prompt: string;
|
||||
highlightBrand: string;
|
||||
models: PromptExplorerModel[];
|
||||
webSearch: boolean;
|
||||
webSearchCountryCode: WebSearchCountryCode;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
projectId: string;
|
||||
urlState: PromptExplorerFormValues;
|
||||
onSubmit: (values: PromptExplorerFormValues) => void;
|
||||
};
|
||||
|
||||
const PROMPT_EXPLORER_BULLETS = [
|
||||
@ -56,22 +63,6 @@ const PROMPT_EXPLORER_BULLETS = [
|
||||
},
|
||||
];
|
||||
|
||||
type FormState = {
|
||||
prompt: string;
|
||||
highlightBrand: string;
|
||||
models: PromptExplorerModel[];
|
||||
webSearch: boolean;
|
||||
webSearchCountryCode: WebSearchCountryCode;
|
||||
};
|
||||
|
||||
const INITIAL_FORM_STATE: FormState = {
|
||||
prompt: "",
|
||||
highlightBrand: "",
|
||||
models: [...PROMPT_EXPLORER_MODELS],
|
||||
webSearch: true,
|
||||
webSearchCountryCode: "US",
|
||||
};
|
||||
|
||||
export function PromptExplorerPage(props: Props) {
|
||||
return (
|
||||
<HostedPlanGate>
|
||||
@ -82,9 +73,11 @@ export function PromptExplorerPage(props: Props) {
|
||||
|
||||
function PromptExplorerPageInner({
|
||||
projectId,
|
||||
urlState,
|
||||
onSubmit,
|
||||
planGate,
|
||||
}: Props & { planGate: HostedPlanGateState }) {
|
||||
const [form, setForm] = useState<FormState>(INITIAL_FORM_STATE);
|
||||
const [form, setForm] = useState<PromptExplorerFormValues>(urlState);
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
const access = useAiSearchAccess(projectId);
|
||||
|
||||
@ -95,45 +88,88 @@ function PromptExplorerPageInner({
|
||||
removeHistoryItem,
|
||||
} = usePromptExplorerSearchHistory(projectId);
|
||||
|
||||
const exploreMutation = useMutation({
|
||||
mutationFn: (input: FormState) =>
|
||||
const trimmedPrompt = urlState.prompt.trim();
|
||||
const hasActivePrompt = trimmedPrompt.length > 0;
|
||||
|
||||
const exploreQuery = useQuery({
|
||||
queryKey: [
|
||||
"prompt-explorer",
|
||||
projectId,
|
||||
trimmedPrompt,
|
||||
urlState.models.toSorted().join(","),
|
||||
urlState.webSearch,
|
||||
urlState.webSearchCountryCode,
|
||||
urlState.highlightBrand.trim(),
|
||||
],
|
||||
queryFn: () =>
|
||||
explorePrompt({
|
||||
data: {
|
||||
projectId,
|
||||
prompt: input.prompt,
|
||||
models: input.models,
|
||||
highlightBrand:
|
||||
input.highlightBrand.length > 0 ? input.highlightBrand : undefined,
|
||||
webSearch: input.webSearch,
|
||||
webSearchCountryCode: input.webSearchCountryCode,
|
||||
prompt: trimmedPrompt,
|
||||
models: urlState.models,
|
||||
highlightBrand: urlState.highlightBrand.trim() || undefined,
|
||||
webSearch: urlState.webSearch,
|
||||
webSearchCountryCode: urlState.webSearchCountryCode,
|
||||
},
|
||||
}),
|
||||
enabled:
|
||||
hasActivePrompt &&
|
||||
urlState.models.length > 0 &&
|
||||
!planGate.isFreePlan &&
|
||||
access.enabled,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const runExplore = (values: FormState) => {
|
||||
const normalized: FormState = {
|
||||
...values,
|
||||
prompt: values.prompt.trim(),
|
||||
highlightBrand: values.highlightBrand.trim(),
|
||||
};
|
||||
// Sync form to URL state — covers initial mount, browser back/forward, and
|
||||
// cmd+click history navigation (in the originating tab nothing changes; in
|
||||
// a new tab the form mounts populated from the URL).
|
||||
useEffect(() => {
|
||||
setForm(urlState);
|
||||
setValidationError(null);
|
||||
}, [urlState]);
|
||||
|
||||
// Persist successful searches to history. Run on isSuccess so failed
|
||||
// requests don't pollute recent searches. The dedup ref prevents repeat
|
||||
// adds when downstream renders create new urlState references.
|
||||
const lastAddedKeyRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!hasActivePrompt || !exploreQuery.isSuccess) return;
|
||||
const key = [
|
||||
trimmedPrompt,
|
||||
urlState.highlightBrand.trim(),
|
||||
urlState.models.toSorted().join(","),
|
||||
urlState.webSearch,
|
||||
urlState.webSearchCountryCode,
|
||||
].join("|");
|
||||
if (lastAddedKeyRef.current === key) return;
|
||||
lastAddedKeyRef.current = key;
|
||||
addSearch({
|
||||
prompt: normalized.prompt,
|
||||
highlightBrand: normalized.highlightBrand,
|
||||
models: normalized.models,
|
||||
webSearch: normalized.webSearch,
|
||||
webSearchCountryCode: normalized.webSearchCountryCode,
|
||||
prompt: trimmedPrompt,
|
||||
highlightBrand: urlState.highlightBrand.trim(),
|
||||
models: urlState.models,
|
||||
webSearch: urlState.webSearch,
|
||||
webSearchCountryCode: urlState.webSearchCountryCode,
|
||||
});
|
||||
exploreMutation.mutate(normalized);
|
||||
};
|
||||
}, [
|
||||
hasActivePrompt,
|
||||
exploreQuery.isSuccess,
|
||||
trimmedPrompt,
|
||||
urlState.highlightBrand,
|
||||
urlState.models,
|
||||
urlState.webSearch,
|
||||
urlState.webSearchCountryCode,
|
||||
addSearch,
|
||||
]);
|
||||
|
||||
const handleSubmit = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
const trimmedPrompt = form.prompt.trim();
|
||||
if (trimmedPrompt.length === 0) {
|
||||
const trimmed = form.prompt.trim();
|
||||
if (trimmed.length === 0) {
|
||||
setValidationError("Enter a prompt");
|
||||
return;
|
||||
}
|
||||
if (trimmedPrompt.length > PROMPT_EXPLORER_MAX_PROMPT_LENGTH) {
|
||||
if (trimmed.length > PROMPT_EXPLORER_MAX_PROMPT_LENGTH) {
|
||||
setValidationError(
|
||||
`Keep prompts under ${PROMPT_EXPLORER_MAX_PROMPT_LENGTH} characters`,
|
||||
);
|
||||
@ -144,35 +180,22 @@ function PromptExplorerPageInner({
|
||||
return;
|
||||
}
|
||||
setValidationError(null);
|
||||
runExplore(form);
|
||||
onSubmit({
|
||||
...form,
|
||||
prompt: trimmed,
|
||||
highlightBrand: form.highlightBrand.trim(),
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelectHistoryItem = (item: PromptExplorerSearchHistoryItem) => {
|
||||
const nextForm: FormState = {
|
||||
prompt: item.prompt,
|
||||
highlightBrand: item.highlightBrand,
|
||||
models: item.models,
|
||||
webSearch: item.webSearch,
|
||||
webSearchCountryCode: item.webSearchCountryCode,
|
||||
};
|
||||
setForm(nextForm);
|
||||
setValidationError(null);
|
||||
runExplore(nextForm);
|
||||
};
|
||||
|
||||
const handleShowRecentSearches = () => {
|
||||
exploreMutation.reset();
|
||||
setForm(INITIAL_FORM_STATE);
|
||||
setValidationError(null);
|
||||
};
|
||||
|
||||
const errorMessage = exploreMutation.isError
|
||||
? getStandardErrorMessage(exploreMutation.error)
|
||||
const errorMessage = exploreQuery.isError
|
||||
? getStandardErrorMessage(exploreQuery.error)
|
||||
: null;
|
||||
const isLoading = hasActivePrompt && exploreQuery.isPending;
|
||||
const resultData = hasActivePrompt ? exploreQuery.data : undefined;
|
||||
|
||||
const updateForm = <K extends keyof FormState>(
|
||||
const updateForm = <K extends keyof PromptExplorerFormValues>(
|
||||
key: K,
|
||||
value: FormState[K],
|
||||
value: PromptExplorerFormValues[K],
|
||||
) => {
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
if (validationError) setValidationError(null);
|
||||
@ -219,7 +242,7 @@ function PromptExplorerPageInner({
|
||||
updateForm("webSearchCountryCode", value)
|
||||
}
|
||||
onSubmit={handleSubmit}
|
||||
isLoading={exploreMutation.isPending}
|
||||
isLoading={isLoading}
|
||||
validationError={validationError}
|
||||
/>
|
||||
|
||||
@ -233,28 +256,31 @@ function PromptExplorerPageInner({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{exploreMutation.isPending ? (
|
||||
{isLoading ? (
|
||||
<PromptExplorerLoadingState modelCount={form.models.length} />
|
||||
) : exploreMutation.data ? (
|
||||
) : resultData ? (
|
||||
<>
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
<Link
|
||||
from="/p/$projectId/prompt-explorer"
|
||||
to="/p/$projectId/prompt-explorer"
|
||||
params={{ projectId }}
|
||||
search={{}}
|
||||
replace
|
||||
className="btn btn-ghost btn-sm gap-2 px-0 text-base-content/70 hover:bg-transparent"
|
||||
onClick={handleShowRecentSearches}
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
Recent searches
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
<PromptExplorerResults result={exploreMutation.data} />
|
||||
<PromptExplorerResults result={resultData} />
|
||||
</>
|
||||
) : !errorMessage ? (
|
||||
<PromptExplorerHistorySection
|
||||
projectId={projectId}
|
||||
history={history}
|
||||
historyLoaded={historyLoaded}
|
||||
onRemoveHistoryItem={removeHistoryItem}
|
||||
onSelectHistoryItem={handleSelectHistoryItem}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
|
||||
@ -1,21 +1,37 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { SearchHistorySection } from "@/client/features/ai-search/components/SearchHistorySection";
|
||||
import {
|
||||
HISTORY_ITEM_LINK_CLASS,
|
||||
SearchHistorySection,
|
||||
} from "@/client/features/ai-search/components/SearchHistorySection";
|
||||
import type { BrandLookupSearchHistoryItem } from "@/client/hooks/useBrandLookupSearchHistory";
|
||||
|
||||
type Props = {
|
||||
projectId: string;
|
||||
history: BrandLookupSearchHistoryItem[];
|
||||
historyLoaded: boolean;
|
||||
onRemoveHistoryItem: (timestamp: number) => void;
|
||||
onSelectHistoryItem: (item: BrandLookupSearchHistoryItem) => void;
|
||||
};
|
||||
|
||||
export function BrandLookupHistorySection(props: Props) {
|
||||
export function BrandLookupHistorySection({ projectId, ...props }: Props) {
|
||||
return (
|
||||
<SearchHistorySection
|
||||
{...props}
|
||||
emptyIcon={Sparkles}
|
||||
emptyMessage="Search a brand name or domain to see how AI cites it"
|
||||
noun="lookup"
|
||||
renderItemLink={(item, content) => (
|
||||
<Link
|
||||
from="/p/$projectId/brand-lookup"
|
||||
to="/p/$projectId/brand-lookup"
|
||||
params={{ projectId }}
|
||||
search={{ q: item.query }}
|
||||
replace
|
||||
className={HISTORY_ITEM_LINK_CLASS}
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
)}
|
||||
renderItem={(item) => (
|
||||
<p className="font-medium text-base-content truncate">{item.query}</p>
|
||||
)}
|
||||
|
||||
@ -1,22 +1,47 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { MessageSquare } from "lucide-react";
|
||||
import { SearchHistorySection } from "@/client/features/ai-search/components/SearchHistorySection";
|
||||
import {
|
||||
HISTORY_ITEM_LINK_CLASS,
|
||||
SearchHistorySection,
|
||||
} from "@/client/features/ai-search/components/SearchHistorySection";
|
||||
import { formatModelLabel } from "@/client/features/ai-search/platformLabels";
|
||||
import type { PromptExplorerSearchHistoryItem } from "@/client/hooks/usePromptExplorerSearchHistory";
|
||||
|
||||
type Props = {
|
||||
projectId: string;
|
||||
history: PromptExplorerSearchHistoryItem[];
|
||||
historyLoaded: boolean;
|
||||
onRemoveHistoryItem: (timestamp: number) => void;
|
||||
onSelectHistoryItem: (item: PromptExplorerSearchHistoryItem) => void;
|
||||
};
|
||||
|
||||
export function PromptExplorerHistorySection(props: Props) {
|
||||
export function PromptExplorerHistorySection({ projectId, ...props }: Props) {
|
||||
return (
|
||||
<SearchHistorySection
|
||||
{...props}
|
||||
emptyIcon={MessageSquare}
|
||||
emptyMessage="Enter a prompt to compare model answers"
|
||||
noun="prompt"
|
||||
renderItemLink={(item, content) => (
|
||||
<Link
|
||||
from="/p/$projectId/prompt-explorer"
|
||||
to="/p/$projectId/prompt-explorer"
|
||||
params={{ projectId }}
|
||||
search={{
|
||||
q: item.prompt,
|
||||
models: item.models,
|
||||
web: item.webSearch ? undefined : false,
|
||||
cc:
|
||||
item.webSearchCountryCode === "US"
|
||||
? undefined
|
||||
: item.webSearchCountryCode,
|
||||
hb: item.highlightBrand || undefined,
|
||||
}}
|
||||
replace
|
||||
className={HISTORY_ITEM_LINK_CLASS}
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
)}
|
||||
renderItem={(item) => (
|
||||
<>
|
||||
<p className="font-medium text-base-content truncate">
|
||||
|
||||
@ -5,7 +5,12 @@ type Props<TItem extends { timestamp: number }> = {
|
||||
history: TItem[];
|
||||
historyLoaded: boolean;
|
||||
onRemoveHistoryItem: (timestamp: number) => void;
|
||||
onSelectHistoryItem: (item: TItem) => void;
|
||||
/**
|
||||
* Renders the clickable area of a history row. The caller is responsible
|
||||
* for wrapping `content` in a <Link> (or other clickable element) so that
|
||||
* cmd+click and right-click → "open in new tab" behave natively.
|
||||
*/
|
||||
renderItemLink: (item: TItem, content: ReactNode) => ReactNode;
|
||||
/** Icon component rendered in the empty state (e.g. Sparkles, MessageSquare). */
|
||||
emptyIcon: ComponentType<{ className?: string }>;
|
||||
/** Empty-state headline copy. */
|
||||
@ -23,7 +28,7 @@ export function SearchHistorySection<TItem extends { timestamp: number }>({
|
||||
history,
|
||||
historyLoaded,
|
||||
onRemoveHistoryItem,
|
||||
onSelectHistoryItem,
|
||||
renderItemLink,
|
||||
emptyIcon: EmptyIcon,
|
||||
emptyMessage,
|
||||
noun,
|
||||
@ -62,14 +67,13 @@ export function SearchHistorySection<TItem extends { timestamp: number }>({
|
||||
key={item.timestamp}
|
||||
className="group flex items-center gap-2 rounded-lg border border-base-300 bg-base-100 p-2"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 items-center gap-3 rounded-md px-1 py-1 text-left transition-colors hover:bg-base-200"
|
||||
onClick={() => onSelectHistoryItem(item)}
|
||||
>
|
||||
<Clock className="size-4 text-base-content/40 shrink-0" />
|
||||
<div className="min-w-0">{renderItem(item)}</div>
|
||||
</button>
|
||||
{renderItemLink(
|
||||
item,
|
||||
<>
|
||||
<Clock className="size-4 text-base-content/40 shrink-0" />
|
||||
<div className="min-w-0">{renderItem(item)}</div>
|
||||
</>,
|
||||
)}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="text-xs text-base-content/40">
|
||||
{new Date(item.timestamp).toLocaleDateString(undefined, {
|
||||
@ -92,3 +96,6 @@ export function SearchHistorySection<TItem extends { timestamp: number }>({
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export const HISTORY_ITEM_LINK_CLASS =
|
||||
"flex min-w-0 flex-1 items-center gap-3 rounded-md px-1 py-1 text-left transition-colors hover:bg-base-200";
|
||||
|
||||
@ -1,16 +1,17 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { MoreHorizontal, ScanSearch, Trash2 } from "lucide-react";
|
||||
import type { getAuditHistory } from "@/serverFunctions/audit";
|
||||
import { formatDate, StatusBadge } from "@/client/features/audit/shared";
|
||||
|
||||
export function AuditHistorySection({
|
||||
projectId,
|
||||
history,
|
||||
isLoading,
|
||||
onView,
|
||||
onDelete,
|
||||
}: {
|
||||
projectId: string;
|
||||
history: Awaited<ReturnType<typeof getAuditHistory>>;
|
||||
isLoading: boolean;
|
||||
onView: (auditId: string) => void;
|
||||
onDelete: (auditId: string) => void;
|
||||
}) {
|
||||
if (history.length === 0 && !isLoading) {
|
||||
@ -60,8 +61,8 @@ export function AuditHistorySection({
|
||||
</td>
|
||||
<td>
|
||||
<HistoryActions
|
||||
projectId={projectId}
|
||||
auditId={audit.id}
|
||||
onView={onView}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
</td>
|
||||
@ -76,22 +77,24 @@ export function AuditHistorySection({
|
||||
}
|
||||
|
||||
function HistoryActions({
|
||||
projectId,
|
||||
auditId,
|
||||
onView,
|
||||
onDelete,
|
||||
}: {
|
||||
projectId: string;
|
||||
auditId: string;
|
||||
onView: (auditId: string) => void;
|
||||
onDelete: (auditId: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-2 transition-opacity md:opacity-0 md:group-hover:opacity-100 md:group-focus-within:opacity-100">
|
||||
<button
|
||||
<Link
|
||||
to="/p/$projectId/audit"
|
||||
params={{ projectId }}
|
||||
search={{ auditId, tab: "pages" }}
|
||||
className="btn btn-primary btn-xs"
|
||||
onClick={() => onView(auditId)}
|
||||
>
|
||||
View
|
||||
</button>
|
||||
</Link>
|
||||
<div className="dropdown dropdown-end">
|
||||
<div
|
||||
tabIndex={0}
|
||||
|
||||
@ -22,9 +22,9 @@ export function LaunchView({
|
||||
/>
|
||||
|
||||
<AuditHistorySection
|
||||
projectId={projectId}
|
||||
history={controller.historyQuery.data ?? []}
|
||||
isLoading={controller.historyQuery.isLoading}
|
||||
onView={onAuditStarted}
|
||||
onDelete={controller.deleteAudit}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { useMemo } from "react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { StatCard } from "@/client/features/audit/shared";
|
||||
import {
|
||||
exportPages,
|
||||
@ -12,18 +13,14 @@ import {
|
||||
PerformanceTable,
|
||||
} from "@/client/features/audit/results/ResultsTables";
|
||||
|
||||
type SearchSetter = (updates: Record<string, string | undefined>) => void;
|
||||
|
||||
export function ResultsView({
|
||||
projectId,
|
||||
data,
|
||||
tab,
|
||||
setSearchParams,
|
||||
}: {
|
||||
projectId: string;
|
||||
data: AuditResultsData;
|
||||
tab: string;
|
||||
setSearchParams: SearchSetter;
|
||||
}) {
|
||||
const { audit, pages, lighthouse } = data;
|
||||
const hasPerformanceTab = lighthouse.length > 0;
|
||||
@ -43,11 +40,12 @@ export function ResultsView({
|
||||
<div className="card bg-base-100 border border-base-300">
|
||||
<div className="card-body gap-3">
|
||||
<ResultsHeader
|
||||
projectId={projectId}
|
||||
auditId={audit.id}
|
||||
pageCount={pages.length}
|
||||
lighthouseCount={lighthouse.length}
|
||||
hasPerformanceTab={hasPerformanceTab}
|
||||
activeTab={activeTab}
|
||||
setSearchParams={setSearchParams}
|
||||
onExport={(format) => {
|
||||
if (activeTab === "performance") {
|
||||
exportPerformance(lighthouse, pages, format);
|
||||
@ -117,38 +115,46 @@ function useResultStats(
|
||||
}
|
||||
|
||||
function ResultsHeader({
|
||||
projectId,
|
||||
auditId,
|
||||
pageCount,
|
||||
lighthouseCount,
|
||||
hasPerformanceTab,
|
||||
activeTab,
|
||||
setSearchParams,
|
||||
onExport,
|
||||
}: {
|
||||
projectId: string;
|
||||
auditId: string;
|
||||
pageCount: number;
|
||||
lighthouseCount: number;
|
||||
hasPerformanceTab: boolean;
|
||||
activeTab: string;
|
||||
setSearchParams: SearchSetter;
|
||||
onExport: (format: "csv" | "json" | "sheets") => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-3">
|
||||
{hasPerformanceTab ? (
|
||||
<div role="tablist" className="tabs tabs-box w-fit">
|
||||
<button
|
||||
<Link
|
||||
to="/p/$projectId/audit"
|
||||
params={{ projectId }}
|
||||
search={{ auditId, tab: "pages" }}
|
||||
replace
|
||||
role="tab"
|
||||
className={`tab ${activeTab === "pages" ? "tab-active" : ""}`}
|
||||
onClick={() => setSearchParams({ tab: "pages" })}
|
||||
>
|
||||
Pages ({pageCount})
|
||||
</button>
|
||||
<button
|
||||
</Link>
|
||||
<Link
|
||||
to="/p/$projectId/audit"
|
||||
params={{ projectId }}
|
||||
search={{ auditId, tab: "performance" }}
|
||||
replace
|
||||
role="tab"
|
||||
className={`tab ${activeTab === "performance" ? "tab-active" : ""}`}
|
||||
onClick={() => setSearchParams({ tab: "performance" })}
|
||||
>
|
||||
Performance ({lighthouseCount})
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<h3 className="text-base font-medium">Pages ({pageCount})</h3>
|
||||
|
||||
@ -1,18 +1,19 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { Clock, History, Link2, X } from "lucide-react";
|
||||
import type { BacklinksSearchHistoryItem } from "@/client/hooks/useBacklinksSearchHistory";
|
||||
|
||||
type Props = {
|
||||
projectId: string;
|
||||
history: BacklinksSearchHistoryItem[];
|
||||
historyLoaded: boolean;
|
||||
onRemoveHistoryItem: (timestamp: number) => void;
|
||||
onSelectHistoryItem: (item: BacklinksSearchHistoryItem) => void;
|
||||
};
|
||||
|
||||
export function BacklinksHistorySection({
|
||||
projectId,
|
||||
history,
|
||||
historyLoaded,
|
||||
onRemoveHistoryItem,
|
||||
onSelectHistoryItem,
|
||||
}: Props) {
|
||||
if (!historyLoaded) {
|
||||
return null;
|
||||
@ -46,10 +47,17 @@ export function BacklinksHistorySection({
|
||||
key={item.timestamp}
|
||||
className="group flex items-center gap-2 rounded-lg border border-base-300 bg-base-100 p-2"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
<Link
|
||||
to="/p/$projectId/backlinks"
|
||||
params={{ projectId }}
|
||||
search={(prev) => ({
|
||||
...prev,
|
||||
target: item.target,
|
||||
scope: item.scope,
|
||||
tab: undefined,
|
||||
})}
|
||||
replace
|
||||
className="flex min-w-0 flex-1 items-center gap-3 rounded-md px-1 py-1 text-left transition-colors hover:bg-base-200"
|
||||
onClick={() => onSelectHistoryItem(item)}
|
||||
>
|
||||
<Clock className="size-4 text-base-content/40 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
@ -60,7 +68,7 @@ export function BacklinksHistorySection({
|
||||
{item.scope === "domain" ? "Site-wide" : "Exact page"}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</Link>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="text-xs text-base-content/40">
|
||||
{new Date(item.timestamp).toLocaleDateString(undefined, {
|
||||
|
||||
@ -2,9 +2,7 @@ import { BacklinksSearchCard } from "./BacklinksSearchCard";
|
||||
import { BacklinksBody } from "./BacklinksPageContent";
|
||||
import type { BacklinksPageProps } from "./backlinksPageTypes";
|
||||
import {
|
||||
navigateToBacklinksHistory,
|
||||
navigateToBacklinksSearch,
|
||||
navigateToBacklinksTab,
|
||||
useBacklinksPageData,
|
||||
} from "./useBacklinksPageData";
|
||||
import { useBacklinksFilters } from "./useBacklinksFilters";
|
||||
@ -37,16 +35,6 @@ export function BacklinksPage({
|
||||
removeHistoryItem,
|
||||
} = useBacklinksSearchHistory(projectId);
|
||||
|
||||
const handleHistorySelect = (item: {
|
||||
target: string;
|
||||
scope: "domain" | "page";
|
||||
}) => {
|
||||
navigateToBacklinksSearch(navigate, {
|
||||
target: item.target,
|
||||
scope: item.scope,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-4 py-4 pb-24 overflow-auto md:px-6 md:py-6 md:pb-8">
|
||||
<div className="mx-auto max-w-7xl space-y-4">
|
||||
@ -77,6 +65,7 @@ export function BacklinksPage({
|
||||
) : null}
|
||||
|
||||
<BacklinksBody
|
||||
projectId={projectId}
|
||||
accessGate={accessGate}
|
||||
backlinksDisabledByError={backlinksDisabledByError}
|
||||
history={history}
|
||||
@ -95,9 +84,6 @@ export function BacklinksPage({
|
||||
}
|
||||
topPages={topPagesQuery.data}
|
||||
onRemoveHistoryItem={removeHistoryItem}
|
||||
onSelectHistoryItem={handleHistorySelect}
|
||||
onShowHistory={() => navigateToBacklinksHistory(navigate)}
|
||||
onSetActiveTab={(tab) => navigateToBacklinksTab(navigate, tab)}
|
||||
onRetryOverview={() => void overviewQuery.refetch()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -27,6 +27,7 @@ import {
|
||||
import type { BacklinksFiltersState } from "./useBacklinksFilters";
|
||||
|
||||
type BacklinksBodyProps = {
|
||||
projectId: string;
|
||||
accessGate: UseAccessGateResult;
|
||||
backlinksDisabledByError: boolean;
|
||||
history: BacklinksSearchHistoryItem[];
|
||||
@ -41,13 +42,11 @@ type BacklinksBodyProps = {
|
||||
tabLoading: boolean;
|
||||
topPages: BacklinksTopPagesData | undefined;
|
||||
onRemoveHistoryItem: (timestamp: number) => void;
|
||||
onSelectHistoryItem: (item: BacklinksSearchHistoryItem) => void;
|
||||
onShowHistory: () => void;
|
||||
onSetActiveTab: (tab: BacklinksSearchState["tab"]) => void;
|
||||
onRetryOverview: () => void;
|
||||
};
|
||||
|
||||
export function BacklinksBody({
|
||||
projectId,
|
||||
accessGate,
|
||||
backlinksDisabledByError,
|
||||
history,
|
||||
@ -62,9 +61,6 @@ export function BacklinksBody({
|
||||
tabLoading,
|
||||
topPages,
|
||||
onRemoveHistoryItem,
|
||||
onSelectHistoryItem,
|
||||
onShowHistory,
|
||||
onSetActiveTab,
|
||||
onRetryOverview,
|
||||
}: BacklinksBodyProps) {
|
||||
const mergedData = useMemo(
|
||||
@ -123,10 +119,10 @@ export function BacklinksBody({
|
||||
if (!searchState.target) {
|
||||
return (
|
||||
<BacklinksHistorySection
|
||||
projectId={projectId}
|
||||
history={history}
|
||||
historyLoaded={historyLoaded}
|
||||
onRemoveHistoryItem={onRemoveHistoryItem}
|
||||
onSelectHistoryItem={onSelectHistoryItem}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -147,11 +143,12 @@ export function BacklinksBody({
|
||||
return (
|
||||
<>
|
||||
<BacklinksOverviewPanels
|
||||
projectId={projectId}
|
||||
data={mergedData}
|
||||
onShowHistory={onShowHistory}
|
||||
summaryStats={summaryStats}
|
||||
/>
|
||||
<BacklinksResultsCard
|
||||
projectId={projectId}
|
||||
activeTab={searchState.tab}
|
||||
filteredData={filteredData}
|
||||
filters={filters}
|
||||
@ -159,7 +156,6 @@ export function BacklinksBody({
|
||||
tabErrorMessage={
|
||||
searchState.tab !== "backlinks" ? tabErrorMessage : null
|
||||
}
|
||||
onSetActiveTab={onSetActiveTab}
|
||||
exportTarget={mergedData.displayTarget || searchState.target}
|
||||
/>
|
||||
</>
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { useMemo } from "react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { HeaderHelpLabel } from "@/client/features/keywords/components";
|
||||
import { ArrowLeft, Download, SlidersHorizontal } from "lucide-react";
|
||||
import { ExportToSheetsButton } from "@/client/components/table/ExportToSheetsButton";
|
||||
@ -22,25 +23,27 @@ import { buildBacklinksTabExport, exportBacklinksTabCsv } from "./export";
|
||||
import type { BacklinksFiltersState } from "./useBacklinksFilters";
|
||||
|
||||
export function BacklinksOverviewPanels({
|
||||
projectId,
|
||||
data,
|
||||
onShowHistory,
|
||||
summaryStats,
|
||||
}: {
|
||||
projectId: string;
|
||||
data: BacklinksOverviewData;
|
||||
onShowHistory: () => void;
|
||||
summaryStats: Array<{ label: string; value: string; description: string }>;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
<Link
|
||||
to="/p/$projectId/backlinks"
|
||||
params={{ projectId }}
|
||||
search={{ target: undefined, scope: undefined, tab: undefined }}
|
||||
replace
|
||||
className="btn btn-ghost btn-sm gap-2 px-0 text-base-content/70 hover:bg-transparent"
|
||||
onClick={onShowHistory}
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
Recent searches
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm text-base-content/65">
|
||||
<span className="badge badge-outline">{data.scope}</span>
|
||||
@ -63,14 +66,15 @@ export function BacklinksOverviewPanels({
|
||||
}
|
||||
|
||||
export function BacklinksResultsCard({
|
||||
projectId,
|
||||
activeTab,
|
||||
filteredData,
|
||||
filters,
|
||||
isTabLoading,
|
||||
tabErrorMessage,
|
||||
onSetActiveTab,
|
||||
exportTarget,
|
||||
}: {
|
||||
projectId: string;
|
||||
activeTab: BacklinksSearchState["tab"];
|
||||
filteredData: {
|
||||
backlinks: BacklinksOverviewData["backlinks"];
|
||||
@ -80,7 +84,6 @@ export function BacklinksResultsCard({
|
||||
filters: BacklinksFiltersState;
|
||||
isTabLoading: boolean;
|
||||
tabErrorMessage: string | null;
|
||||
onSetActiveTab: (tab: BacklinksSearchState["tab"]) => void;
|
||||
exportTarget: string;
|
||||
}) {
|
||||
const currentFilterCount = filters[activeTab].activeFilterCount;
|
||||
@ -94,27 +97,19 @@ export function BacklinksResultsCard({
|
||||
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-3 px-4 py-3 border-b border-base-300">
|
||||
<div className="space-y-2">
|
||||
<div role="tablist" className="tabs tabs-box w-fit">
|
||||
<TabButton
|
||||
<TabLink
|
||||
projectId={projectId}
|
||||
activeTab={activeTab}
|
||||
tab="backlinks"
|
||||
onClick={onSetActiveTab}
|
||||
>
|
||||
Backlinks
|
||||
</TabButton>
|
||||
<TabButton
|
||||
activeTab={activeTab}
|
||||
tab="domains"
|
||||
onClick={onSetActiveTab}
|
||||
>
|
||||
</TabLink>
|
||||
<TabLink projectId={projectId} activeTab={activeTab} tab="domains">
|
||||
Referring Domains
|
||||
</TabButton>
|
||||
<TabButton
|
||||
activeTab={activeTab}
|
||||
tab="pages"
|
||||
onClick={onSetActiveTab}
|
||||
>
|
||||
</TabLink>
|
||||
<TabLink projectId={projectId} activeTab={activeTab} tab="pages">
|
||||
Top Pages
|
||||
</TabButton>
|
||||
</TabLink>
|
||||
</div>
|
||||
<p className="max-w-xl text-sm text-base-content/60">
|
||||
{TAB_DESCRIPTIONS[activeTab]}
|
||||
@ -280,25 +275,31 @@ function TrendCard({
|
||||
);
|
||||
}
|
||||
|
||||
function TabButton({
|
||||
function TabLink({
|
||||
projectId,
|
||||
activeTab,
|
||||
children,
|
||||
onClick,
|
||||
tab,
|
||||
}: {
|
||||
projectId: string;
|
||||
activeTab: BacklinksSearchState["tab"];
|
||||
children: string;
|
||||
onClick: (tab: BacklinksSearchState["tab"]) => void;
|
||||
tab: BacklinksSearchState["tab"];
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
<Link
|
||||
to="/p/$projectId/backlinks"
|
||||
params={{ projectId }}
|
||||
search={(prev) => ({
|
||||
...prev,
|
||||
tab: tab === "backlinks" ? undefined : tab,
|
||||
})}
|
||||
replace
|
||||
role="tab"
|
||||
className={`tab ${activeTab === tab ? "tab-active" : ""}`}
|
||||
onClick={() => onClick(tab)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -142,33 +142,6 @@ export function navigateToBacklinksSearch(
|
||||
});
|
||||
}
|
||||
|
||||
export function navigateToBacklinksHistory(
|
||||
navigate: BacklinksPageProps["navigate"],
|
||||
) {
|
||||
navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
target: undefined,
|
||||
scope: undefined,
|
||||
tab: undefined,
|
||||
}),
|
||||
replace: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function navigateToBacklinksTab(
|
||||
navigate: BacklinksPageProps["navigate"],
|
||||
tab: BacklinksSearchState["tab"],
|
||||
) {
|
||||
navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
tab: tab === "backlinks" ? undefined : tab,
|
||||
}),
|
||||
replace: true,
|
||||
});
|
||||
}
|
||||
|
||||
function buildBacklinksRequestInput(
|
||||
projectId: string,
|
||||
searchState: BacklinksSearchState,
|
||||
|
||||
@ -125,6 +125,7 @@ export function DomainOverviewPage({
|
||||
) : null}
|
||||
|
||||
<DomainResultsCard
|
||||
projectId={projectId}
|
||||
overview={state.overview}
|
||||
activeTab={searchState.tab}
|
||||
sortMode={searchState.sort}
|
||||
@ -139,17 +140,6 @@ export function DomainOverviewPage({
|
||||
filtersForm={state.filtersForm}
|
||||
activeFilterCount={state.activeFilterCount}
|
||||
resetFilters={state.resetFilters}
|
||||
onTabChange={(tab) => {
|
||||
if (
|
||||
tab === "pages" &&
|
||||
(searchState.sort === "rank" ||
|
||||
searchState.sort === "score" ||
|
||||
searchState.sort === "cpc")
|
||||
) {
|
||||
state.applySort("traffic", getDefaultSortOrder("traffic"));
|
||||
}
|
||||
state.setSearchParams({ tab });
|
||||
}}
|
||||
onSearchChange={state.setPendingSearch}
|
||||
onSaveKeywords={state.handleSaveKeywords}
|
||||
canSaveKeywords={state.canSaveKeywords}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { type Dispatch, type SetStateAction } from "react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import {
|
||||
ChevronDown,
|
||||
Copy,
|
||||
@ -14,7 +15,11 @@ import { DomainFilterPanel } from "@/client/features/domain/components/DomainFil
|
||||
import { DomainKeywordsTable } from "@/client/features/domain/components/DomainKeywordsTable";
|
||||
import { DomainPagesTable } from "@/client/features/domain/components/DomainPagesTable";
|
||||
import type { useDomainFilters } from "@/client/features/domain/hooks/useDomainFilters";
|
||||
import { keywordsToTable, pagesToTable } from "@/client/features/domain/utils";
|
||||
import {
|
||||
getDefaultSortOrder,
|
||||
keywordsToTable,
|
||||
pagesToTable,
|
||||
} from "@/client/features/domain/utils";
|
||||
import { buildCsv, downloadCsv } from "@/client/lib/csv";
|
||||
import { exportTableToSheets } from "@/client/lib/exportToSheets";
|
||||
import { captureClientEvent } from "@/client/lib/posthog";
|
||||
@ -28,6 +33,7 @@ import type {
|
||||
} from "@/client/features/domain/types";
|
||||
|
||||
type Props = {
|
||||
projectId: string;
|
||||
overview: DomainOverviewData;
|
||||
activeTab: DomainActiveTab;
|
||||
sortMode: DomainSortMode;
|
||||
@ -42,7 +48,6 @@ type Props = {
|
||||
filtersForm: ReturnType<typeof useDomainFilters>["filtersForm"];
|
||||
activeFilterCount: number;
|
||||
resetFilters: () => void;
|
||||
onTabChange: (tab: DomainActiveTab) => void;
|
||||
onSearchChange: (value: string) => void;
|
||||
onSaveKeywords: () => void;
|
||||
canSaveKeywords: boolean;
|
||||
@ -51,7 +56,14 @@ type Props = {
|
||||
onToggleAllVisible: () => void;
|
||||
};
|
||||
|
||||
const KEYWORDS_ONLY_SORTS: ReadonlySet<DomainSortMode> = new Set([
|
||||
"rank",
|
||||
"score",
|
||||
"cpc",
|
||||
]);
|
||||
|
||||
export function DomainResultsCard({
|
||||
projectId,
|
||||
overview,
|
||||
activeTab,
|
||||
sortMode,
|
||||
@ -66,7 +78,6 @@ export function DomainResultsCard({
|
||||
filtersForm,
|
||||
activeFilterCount,
|
||||
resetFilters,
|
||||
onTabChange,
|
||||
onSearchChange,
|
||||
onSaveKeywords,
|
||||
canSaveKeywords,
|
||||
@ -112,20 +123,40 @@ export function DomainResultsCard({
|
||||
<div className="border border-base-300 rounded-xl bg-base-100 overflow-hidden">
|
||||
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-3 px-4 py-3 border-b border-base-300">
|
||||
<div role="tablist" className="tabs tabs-box w-fit">
|
||||
<button
|
||||
<Link
|
||||
from="/p/$projectId/domain"
|
||||
to="/p/$projectId/domain"
|
||||
params={{ projectId }}
|
||||
search={(prev) => ({ ...prev, tab: undefined })}
|
||||
replace
|
||||
role="tab"
|
||||
className={`tab ${activeTab === "keywords" ? "tab-active" : ""}`}
|
||||
onClick={() => onTabChange("keywords")}
|
||||
>
|
||||
Top Keywords
|
||||
</button>
|
||||
<button
|
||||
</Link>
|
||||
<Link
|
||||
from="/p/$projectId/domain"
|
||||
to="/p/$projectId/domain"
|
||||
params={{ projectId }}
|
||||
search={(prev) => {
|
||||
const fallbackSortNeeded = KEYWORDS_ONLY_SORTS.has(sortMode);
|
||||
const nextSort = fallbackSortNeeded ? "traffic" : prev.sort;
|
||||
const nextOrder = fallbackSortNeeded
|
||||
? getDefaultSortOrder("traffic")
|
||||
: prev.order;
|
||||
return {
|
||||
...prev,
|
||||
tab: "pages" as const,
|
||||
sort: nextSort,
|
||||
order: nextOrder,
|
||||
};
|
||||
}}
|
||||
replace
|
||||
role="tab"
|
||||
className={`tab ${activeTab === "pages" ? "tab-active" : ""}`}
|
||||
onClick={() => onTabChange("pages")}
|
||||
>
|
||||
Top Pages
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||
import { captureClientEvent } from "@/client/lib/posthog";
|
||||
import { LOCATIONS, getLanguageCode } from "@/client/features/keywords/utils";
|
||||
@ -30,26 +29,23 @@ export function useKeywordResearchData(addSearch: AddSearchFn) {
|
||||
DEFAULT_LOCATION_CODE,
|
||||
);
|
||||
const [researchError, setResearchError] = useState<string | null>(null);
|
||||
const [researchMutationError, setResearchMutationError] =
|
||||
useState<unknown>(null);
|
||||
const [searchedKeyword, setSearchedKeyword] = useState("");
|
||||
|
||||
const researchMutation = useMutation({
|
||||
mutationFn: (data: {
|
||||
projectId: string;
|
||||
keywords: string[];
|
||||
locationCode: number;
|
||||
languageCode: string;
|
||||
resultLimit: ResultLimit;
|
||||
mode: KeywordMode;
|
||||
}) => researchKeywords({ data }),
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
// Sequence token so a stale fetch (e.g. user fired a second search before
|
||||
// the first resolved) can't overwrite state that belongs to a newer one.
|
||||
const requestSeqRef = useRef(0);
|
||||
|
||||
const beginSearch = (seedKeyword: string, locationCode: number) => {
|
||||
setResearchError(null);
|
||||
setResearchMutationError(null);
|
||||
setHasSearched(true);
|
||||
setLastSearchError(false);
|
||||
setSearchedKeyword(seedKeyword);
|
||||
setLastSearchKeyword(seedKeyword);
|
||||
setLastSearchLocationCode(locationCode);
|
||||
setIsLoading(true);
|
||||
};
|
||||
|
||||
const resetResearch = () => {
|
||||
@ -61,10 +57,12 @@ export function useKeywordResearchData(addSearch: AddSearchFn) {
|
||||
setLastSearchKeyword("");
|
||||
setLastSearchLocationCode(DEFAULT_LOCATION_CODE);
|
||||
setResearchError(null);
|
||||
setResearchMutationError(null);
|
||||
setSearchedKeyword("");
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const runSearch = (
|
||||
const runSearch = async (
|
||||
input: {
|
||||
projectId: string;
|
||||
keywords: string[];
|
||||
@ -79,49 +77,54 @@ export function useKeywordResearchData(addSearch: AddSearchFn) {
|
||||
) => {
|
||||
const seedKeyword = input.keywords[0] ?? "";
|
||||
const languageCode = getLanguageCode(input.locationCode);
|
||||
const requestSeq = ++requestSeqRef.current;
|
||||
const isStale = () => requestSeqRef.current !== requestSeq;
|
||||
|
||||
researchMutation.mutate(
|
||||
{
|
||||
keywords: input.keywords,
|
||||
projectId: input.projectId,
|
||||
locationCode: input.locationCode,
|
||||
languageCode,
|
||||
resultLimit: input.resultLimit,
|
||||
mode: input.mode,
|
||||
},
|
||||
{
|
||||
onSuccess: (result) => {
|
||||
const resultCount = result.rows.length;
|
||||
|
||||
setResearchError(null);
|
||||
setRows(result.rows);
|
||||
setLastResultSource(result.source);
|
||||
setLastUsedFallback(result.usedFallback);
|
||||
|
||||
captureClientEvent("keyword_research:search_complete", {
|
||||
location_code: input.locationCode,
|
||||
search_mode: input.mode,
|
||||
result_count: resultCount,
|
||||
});
|
||||
|
||||
if (seedKeyword) {
|
||||
addSearch(
|
||||
seedKeyword,
|
||||
input.locationCode,
|
||||
LOCATIONS[input.locationCode] || "Unknown",
|
||||
);
|
||||
}
|
||||
|
||||
handlers?.onSuccess?.(seedKeyword, result.rows);
|
||||
try {
|
||||
const result = await researchKeywords({
|
||||
data: {
|
||||
keywords: input.keywords,
|
||||
projectId: input.projectId,
|
||||
locationCode: input.locationCode,
|
||||
languageCode,
|
||||
resultLimit: input.resultLimit,
|
||||
mode: input.mode,
|
||||
},
|
||||
onError: (error) => {
|
||||
setLastSearchError(true);
|
||||
setRows([]);
|
||||
setResearchError(getStandardErrorMessage(error, "Research failed."));
|
||||
handlers?.onError?.();
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
if (isStale()) return;
|
||||
|
||||
setResearchError(null);
|
||||
setResearchMutationError(null);
|
||||
setRows(result.rows);
|
||||
setLastResultSource(result.source);
|
||||
setLastUsedFallback(result.usedFallback);
|
||||
|
||||
captureClientEvent("keyword_research:search_complete", {
|
||||
location_code: input.locationCode,
|
||||
search_mode: input.mode,
|
||||
result_count: result.rows.length,
|
||||
});
|
||||
|
||||
if (seedKeyword) {
|
||||
addSearch(
|
||||
seedKeyword,
|
||||
input.locationCode,
|
||||
LOCATIONS[input.locationCode] || "Unknown",
|
||||
);
|
||||
}
|
||||
|
||||
handlers?.onSuccess?.(seedKeyword, result.rows);
|
||||
} catch (error) {
|
||||
if (isStale()) return;
|
||||
setLastSearchError(true);
|
||||
setRows([]);
|
||||
setResearchMutationError(error);
|
||||
setResearchError(getStandardErrorMessage(error, "Research failed."));
|
||||
handlers?.onError?.();
|
||||
} finally {
|
||||
if (!isStale()) setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
@ -133,9 +136,9 @@ export function useKeywordResearchData(addSearch: AddSearchFn) {
|
||||
lastSearchKeyword,
|
||||
lastSearchLocationCode,
|
||||
researchError,
|
||||
researchMutationError: researchMutation.error,
|
||||
researchMutationError,
|
||||
searchedKeyword,
|
||||
isLoading: researchMutation.isPending,
|
||||
isLoading,
|
||||
beginSearch,
|
||||
resetResearch,
|
||||
runSearch,
|
||||
|
||||
@ -21,26 +21,6 @@ type KeywordSearchParams = {
|
||||
exclude?: string;
|
||||
};
|
||||
|
||||
export function clearKeywordSearchParams(search: KeywordSearchParams) {
|
||||
return {
|
||||
...search,
|
||||
q: undefined,
|
||||
loc: undefined,
|
||||
kLimit: undefined,
|
||||
mode: undefined,
|
||||
sort: undefined,
|
||||
order: undefined,
|
||||
minVol: undefined,
|
||||
maxVol: undefined,
|
||||
minCpc: undefined,
|
||||
maxCpc: undefined,
|
||||
minKd: undefined,
|
||||
maxKd: undefined,
|
||||
include: undefined,
|
||||
exclude: undefined,
|
||||
} satisfies KeywordSearchParams;
|
||||
}
|
||||
|
||||
export function normalizeLegacyKeywordSearch(search: KeywordSearchParams): {
|
||||
normalized: KeywordSearchParams;
|
||||
changed: boolean;
|
||||
|
||||
@ -1,22 +1,29 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { Clock, Globe, History, Search, X } from "lucide-react";
|
||||
import { DEFAULT_LOCATION_CODE } from "@/client/features/keywords/locations";
|
||||
import { LOCATIONS } from "@/client/features/keywords/utils";
|
||||
import type { KeywordResearchControllerState } from "./types";
|
||||
|
||||
type Props = {
|
||||
controller: KeywordResearchControllerState;
|
||||
projectId: string;
|
||||
};
|
||||
|
||||
export function KeywordResearchEmptyState({ controller }: Props) {
|
||||
export function KeywordResearchEmptyState({ controller, projectId }: Props) {
|
||||
const { hasSearched, isLoading, lastSearchError } = controller;
|
||||
|
||||
if (hasSearched && !isLoading && !lastSearchError) {
|
||||
return <NoResultsState controller={controller} />;
|
||||
}
|
||||
|
||||
return <SearchHistoryState controller={controller} />;
|
||||
return <SearchHistoryState controller={controller} projectId={projectId} />;
|
||||
}
|
||||
|
||||
function NoResultsState({ controller }: Props) {
|
||||
function NoResultsState({
|
||||
controller,
|
||||
}: {
|
||||
controller: KeywordResearchControllerState;
|
||||
}) {
|
||||
const { lastSearchKeyword, lastSearchLocationCode } = controller;
|
||||
|
||||
return (
|
||||
@ -44,8 +51,14 @@ function NoResultsState({ controller }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
function SearchHistoryState({ controller }: Props) {
|
||||
const { history, historyLoaded, onSearch, removeHistoryItem } = controller;
|
||||
function SearchHistoryState({
|
||||
controller,
|
||||
projectId,
|
||||
}: {
|
||||
controller: KeywordResearchControllerState;
|
||||
projectId: string;
|
||||
}) {
|
||||
const { history, historyLoaded, removeHistoryItem } = controller;
|
||||
|
||||
if (!historyLoaded) {
|
||||
return null;
|
||||
@ -70,15 +83,19 @@ function SearchHistoryState({ controller }: Props) {
|
||||
key={item.timestamp}
|
||||
className="group flex items-center gap-2 rounded-lg border border-base-300 bg-base-100 p-2"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
<Link
|
||||
from="/p/$projectId/keywords"
|
||||
to="/p/$projectId/keywords"
|
||||
params={{ projectId }}
|
||||
search={{
|
||||
q: item.keyword,
|
||||
loc:
|
||||
item.locationCode === DEFAULT_LOCATION_CODE
|
||||
? undefined
|
||||
: item.locationCode,
|
||||
}}
|
||||
replace
|
||||
className="flex min-w-0 flex-1 items-center gap-3 rounded-md px-1 py-1 text-left transition-colors hover:bg-base-200"
|
||||
onClick={() =>
|
||||
onSearch({
|
||||
keyword: item.keyword,
|
||||
locationCode: item.locationCode,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Clock className="size-4 shrink-0 text-base-content/40" />
|
||||
<div className="min-w-0">
|
||||
@ -89,7 +106,7 @@ function SearchHistoryState({ controller }: Props) {
|
||||
{item.locationName}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</Link>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span className="text-xs text-base-content/40">
|
||||
{new Date(item.timestamp).toLocaleDateString(undefined, {
|
||||
|
||||
@ -10,16 +10,10 @@ import { KeywordResearchResults } from "./KeywordResearchResults";
|
||||
import { KeywordResearchSearchBar } from "./KeywordResearchSearchBar";
|
||||
import type { KeywordResearchControllerState } from "./types";
|
||||
|
||||
type Props = KeywordResearchControllerInput & {
|
||||
onShowRecentSearches: () => void;
|
||||
};
|
||||
type Props = KeywordResearchControllerInput;
|
||||
|
||||
export function KeywordResearchPage({ onShowRecentSearches, ...input }: Props) {
|
||||
export function KeywordResearchPage(input: Props) {
|
||||
const controller = useKeywordResearchController(input);
|
||||
const handleShowRecentSearches = () => {
|
||||
controller.resetView();
|
||||
onShowRecentSearches();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-4 py-4 md:px-6 md:py-6 pb-24 md:pb-8 overflow-auto">
|
||||
@ -34,7 +28,7 @@ export function KeywordResearchPage({ onShowRecentSearches, ...input }: Props) {
|
||||
<KeywordResearchSearchBar controller={controller} />
|
||||
<KeywordResearchContent
|
||||
controller={controller}
|
||||
onShowRecentSearches={handleShowRecentSearches}
|
||||
projectId={input.projectId}
|
||||
/>
|
||||
<KeywordSaveDialog controller={controller} />
|
||||
</div>
|
||||
@ -44,21 +38,24 @@ export function KeywordResearchPage({ onShowRecentSearches, ...input }: Props) {
|
||||
|
||||
function KeywordResearchContent({
|
||||
controller,
|
||||
onShowRecentSearches,
|
||||
projectId,
|
||||
}: {
|
||||
controller: KeywordResearchControllerState;
|
||||
onShowRecentSearches: () => void;
|
||||
projectId: string;
|
||||
}) {
|
||||
const recentSearchesButton = controller.hasSearched ? (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
<Link
|
||||
from="/p/$projectId/keywords"
|
||||
to="/p/$projectId/keywords"
|
||||
params={{ projectId }}
|
||||
search={{}}
|
||||
replace
|
||||
className="btn btn-ghost btn-sm gap-2 px-0 text-base-content/70 hover:bg-transparent"
|
||||
onClick={onShowRecentSearches}
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
Recent searches
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
@ -101,7 +98,10 @@ function KeywordResearchContent({
|
||||
return (
|
||||
<div className="space-y-4 pt-1">
|
||||
{recentSearchesButton}
|
||||
<KeywordResearchEmptyState controller={controller} />
|
||||
<KeywordResearchEmptyState
|
||||
controller={controller}
|
||||
projectId={projectId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -7,6 +7,10 @@ import { getLanguageCode } from "@/client/features/keywords/utils";
|
||||
import type { KeywordResearchRow } from "@/types/keywords";
|
||||
import type { SaveKeywordsInput } from "@/types/schemas/keywords";
|
||||
import type { SortDir, SortField } from "@/client/features/keywords/components";
|
||||
import type {
|
||||
KeywordMode,
|
||||
ResultLimit,
|
||||
} from "@/client/features/keywords/keywordResearchTypes";
|
||||
import type { KeywordResearchControllerInput } from "./useKeywordResearchController";
|
||||
|
||||
export const KEYWORD_RESEARCH_HEADERS = [
|
||||
@ -51,6 +55,25 @@ export function parseKeywordInput(value: string) {
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable identity for a keyword-research request. Used to dedup the
|
||||
* URL-driven search trigger against the form-submit path so the same
|
||||
* params don't fire two requests back-to-back.
|
||||
*/
|
||||
export function buildKeywordSearchKey(params: {
|
||||
keyword: string;
|
||||
locationCode: number;
|
||||
resultLimit: ResultLimit;
|
||||
mode: KeywordMode;
|
||||
}) {
|
||||
return [
|
||||
parseKeywordInput(params.keyword).join(""),
|
||||
params.locationCode,
|
||||
params.resultLimit,
|
||||
params.mode,
|
||||
].join("|");
|
||||
}
|
||||
|
||||
export function getNextSortParams(
|
||||
currentField: SortField,
|
||||
currentDirection: SortDir,
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useCallback, type FormEvent } from "react";
|
||||
import { useCallback, useEffect, useRef, type FormEvent } from "react";
|
||||
import { useKeywordControlsForm } from "@/client/features/keywords/hooks/useKeywordControlsForm";
|
||||
import { useKeywordFiltering } from "@/client/features/keywords/hooks/useKeywordFiltering";
|
||||
import { useLocalKeywordFilters } from "@/client/features/keywords/hooks/useLocalKeywordFilters";
|
||||
@ -15,6 +15,7 @@ import { DEFAULT_LOCATION_CODE } from "@/client/features/keywords/locations";
|
||||
import type { KeywordResearchRow } from "@/types/keywords";
|
||||
import type { SortDir, SortField } from "@/client/features/keywords/components";
|
||||
import {
|
||||
buildKeywordSearchKey,
|
||||
getNextSortParams,
|
||||
parseKeywordInput,
|
||||
useSaveAndExportActions,
|
||||
@ -96,18 +97,6 @@ export function useKeywordResearchController(
|
||||
state.setSerpPage(0);
|
||||
};
|
||||
|
||||
const resetView = useCallback(() => {
|
||||
state.resetResearch();
|
||||
state.clearSelection();
|
||||
state.resetFilters();
|
||||
state.setSelectedKeyword(null);
|
||||
state.setSerpKeyword(null);
|
||||
state.setSerpPage(0);
|
||||
state.setMobileTab("keywords");
|
||||
state.setShowFilters(false);
|
||||
state.setShowSaveDialog(false);
|
||||
}, [state]);
|
||||
|
||||
return {
|
||||
activeFilterCount: state.activeFilterCount,
|
||||
activeSerpKeyword: state.activeSerpKeyword,
|
||||
@ -135,7 +124,6 @@ export function useKeywordResearchController(
|
||||
removeHistoryItem: state.removeHistoryItem,
|
||||
researchError: state.researchError,
|
||||
researchMutationError: state.researchMutationError,
|
||||
resetView,
|
||||
resetFilters: state.resetFilters,
|
||||
rows: state.rows,
|
||||
searchedKeyword: state.searchedKeyword,
|
||||
@ -212,41 +200,34 @@ function useKeywordControllerState(input: KeywordResearchControllerInput) {
|
||||
const setSearchParams = useKeywordSearchParams();
|
||||
const saveMutation = useKeywordSaveMutation(input.projectId);
|
||||
|
||||
const controlsForm = useKeywordControlsForm(
|
||||
{
|
||||
...input,
|
||||
locationCode,
|
||||
},
|
||||
(value) => {
|
||||
const keywords = parseKeywordInput(value.keyword);
|
||||
const activeLocation = value.locationCode;
|
||||
const activeResultLimit = value.resultLimit;
|
||||
const activeMode = value.mode;
|
||||
// Tracks the parameters used for the most recent search trigger so the
|
||||
// URL-driven effect below doesn't re-fire after the form-submit path
|
||||
// already kicked off a search for the same params.
|
||||
const lastTriggerKeyRef = useRef<string | null>(null);
|
||||
|
||||
setPreferredLocationCode(activeLocation);
|
||||
setSearchParams({
|
||||
q: value.keyword,
|
||||
loc:
|
||||
input.hasExplicitLocationCode ||
|
||||
activeLocation !== DEFAULT_LOCATION_CODE
|
||||
? activeLocation
|
||||
: undefined,
|
||||
kLimit: activeResultLimit === 150 ? undefined : activeResultLimit,
|
||||
mode: activeMode === "auto" ? undefined : activeMode,
|
||||
});
|
||||
const triggerSearch = useCallback(
|
||||
(params: {
|
||||
keyword: string;
|
||||
locationCode: number;
|
||||
resultLimit: ResultLimit;
|
||||
mode: KeywordMode;
|
||||
}) => {
|
||||
const keywords = parseKeywordInput(params.keyword);
|
||||
if (keywords.length === 0) return;
|
||||
|
||||
lastTriggerKeyRef.current = buildKeywordSearchKey(params);
|
||||
uiState.setSelectedKeyword(null);
|
||||
clearSelection();
|
||||
setSerpKeyword(null);
|
||||
beginSearch(keywords[0] ?? "", activeLocation);
|
||||
beginSearch(keywords[0] ?? "", params.locationCode);
|
||||
|
||||
runSearch(
|
||||
void runSearch(
|
||||
{
|
||||
projectId: input.projectId,
|
||||
keywords,
|
||||
locationCode: activeLocation,
|
||||
resultLimit: activeResultLimit,
|
||||
mode: activeMode,
|
||||
locationCode: params.locationCode,
|
||||
resultLimit: params.resultLimit,
|
||||
mode: params.mode,
|
||||
},
|
||||
{
|
||||
onSuccess: (seedKeyword, nextRows) => {
|
||||
@ -260,8 +241,93 @@ function useKeywordControllerState(input: KeywordResearchControllerInput) {
|
||||
},
|
||||
);
|
||||
},
|
||||
[
|
||||
beginSearch,
|
||||
clearSelection,
|
||||
input.projectId,
|
||||
runSearch,
|
||||
setSerpKeyword,
|
||||
setSerpPage,
|
||||
uiState,
|
||||
],
|
||||
);
|
||||
|
||||
const controlsForm = useKeywordControlsForm(
|
||||
{
|
||||
...input,
|
||||
locationCode,
|
||||
},
|
||||
(value) => {
|
||||
setPreferredLocationCode(value.locationCode);
|
||||
setSearchParams({
|
||||
q: value.keyword,
|
||||
loc:
|
||||
input.hasExplicitLocationCode ||
|
||||
value.locationCode !== DEFAULT_LOCATION_CODE
|
||||
? value.locationCode
|
||||
: undefined,
|
||||
kLimit: value.resultLimit === 150 ? undefined : value.resultLimit,
|
||||
mode: value.mode === "auto" ? undefined : value.mode,
|
||||
});
|
||||
|
||||
// Trigger immediately so re-submitting the same query (URL unchanged)
|
||||
// still refetches. The dedup ref prevents the URL effect below from
|
||||
// double-firing in the typical (URL-changes) case.
|
||||
triggerSearch({
|
||||
keyword: value.keyword,
|
||||
locationCode: value.locationCode,
|
||||
resultLimit: value.resultLimit,
|
||||
mode: value.mode,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// URL-driven search trigger. Fires when the user lands on a shareable URL
|
||||
// (direct link, cmd+click on a history item, browser back/forward) so the
|
||||
// page reproduces the search those params describe without a form submit.
|
||||
// When the URL is cleared (no `q`), the page resets to the recent-searches
|
||||
// empty state so the "Recent searches" Link works without an extra handler.
|
||||
useEffect(() => {
|
||||
const trimmed = input.keywordInput.trim();
|
||||
|
||||
if (trimmed.length === 0) {
|
||||
if (lastTriggerKeyRef.current === null) return;
|
||||
lastTriggerKeyRef.current = null;
|
||||
resetResearch();
|
||||
clearSelection();
|
||||
uiState.setSelectedKeyword(null);
|
||||
setSerpKeyword(null);
|
||||
setSerpPage(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const urlKey = buildKeywordSearchKey({
|
||||
keyword: input.keywordInput,
|
||||
locationCode,
|
||||
resultLimit: input.resultLimit,
|
||||
mode: input.keywordMode,
|
||||
});
|
||||
if (urlKey === lastTriggerKeyRef.current) return;
|
||||
|
||||
triggerSearch({
|
||||
keyword: input.keywordInput,
|
||||
locationCode,
|
||||
resultLimit: input.resultLimit,
|
||||
mode: input.keywordMode,
|
||||
});
|
||||
}, [
|
||||
clearSelection,
|
||||
input.keywordInput,
|
||||
input.keywordMode,
|
||||
input.resultLimit,
|
||||
locationCode,
|
||||
resetResearch,
|
||||
setSerpKeyword,
|
||||
setSerpPage,
|
||||
triggerSearch,
|
||||
uiState,
|
||||
]);
|
||||
|
||||
const { filteredRows, activeFilterCount } = useKeywordFiltering({
|
||||
rows,
|
||||
filters: filterValues,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { toast } from "sonner";
|
||||
import { LOCATIONS } from "@/client/features/keywords/locations";
|
||||
import {
|
||||
@ -31,7 +31,6 @@ export function RankTrackingDomainList({
|
||||
projectId: string;
|
||||
onAddDomain: () => void;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [archiveTarget, setArchiveTarget] = useState<ConfigSummary | null>(
|
||||
null,
|
||||
@ -88,13 +87,8 @@ export function RankTrackingDomainList({
|
||||
(summaries ?? []).map((summary) => (
|
||||
<DomainRow
|
||||
key={summary.id}
|
||||
projectId={projectId}
|
||||
summary={summary}
|
||||
onClick={() =>
|
||||
void navigate({
|
||||
to: "/p/$projectId/rank-tracking/$configId",
|
||||
params: { projectId, configId: summary.id },
|
||||
})
|
||||
}
|
||||
onArchive={() => setArchiveTarget(summary)}
|
||||
/>
|
||||
))
|
||||
@ -134,31 +128,26 @@ export function RankTrackingDomainList({
|
||||
}
|
||||
|
||||
function DomainRow({
|
||||
projectId,
|
||||
summary,
|
||||
onClick,
|
||||
onArchive,
|
||||
}: {
|
||||
projectId: string;
|
||||
summary: ConfigSummary;
|
||||
onClick: () => void;
|
||||
onArchive: () => void;
|
||||
}) {
|
||||
const dl = getDevicesLabel(summary.devices);
|
||||
const sl = getScheduleLabel(summary.scheduleInterval);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="flex w-full items-center gap-4 px-5 py-3.5 text-left transition-colors hover:bg-base-200/50 cursor-pointer"
|
||||
onClick={onClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onClick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="relative flex w-full items-center gap-4 px-5 py-3.5 transition-colors hover:bg-base-200/50">
|
||||
<Link
|
||||
to="/p/$projectId/rank-tracking/$configId"
|
||||
params={{ projectId, configId: summary.id }}
|
||||
className="absolute inset-0 z-0"
|
||||
aria-label={`Open ${summary.domain}`}
|
||||
/>
|
||||
<div className="min-w-0 flex-1 pointer-events-none">
|
||||
<p className="font-medium truncate">{summary.domain}</p>
|
||||
<p className="text-xs text-base-content/60">
|
||||
{LOCATIONS[summary.locationCode] ?? "US"} · {dl} · {sl}
|
||||
@ -177,7 +166,7 @@ function DomainRow({
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="hidden sm:flex items-center gap-6 text-sm">
|
||||
<div className="hidden sm:flex items-center gap-6 text-sm pointer-events-none">
|
||||
{summary.keywordCount > 0 && (
|
||||
<div className="text-center">
|
||||
<p className="text-xs uppercase tracking-wide text-base-content/60">
|
||||
@ -189,16 +178,17 @@ function DomainRow({
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-xs text-base-content/40 hover:text-error"
|
||||
className="btn btn-ghost btn-xs text-base-content/40 hover:text-error relative z-10"
|
||||
title="Archive domain"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
onArchive();
|
||||
}}
|
||||
>
|
||||
<Archive className="size-4" />
|
||||
</button>
|
||||
<ChevronRight className="size-4 shrink-0 text-base-content/40" />
|
||||
<ChevronRight className="size-4 shrink-0 text-base-content/40 pointer-events-none" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -55,7 +55,6 @@ function SiteAuditPage() {
|
||||
projectId={projectId}
|
||||
auditId={auditId}
|
||||
tab={tab}
|
||||
setSearchParams={setSearchParams}
|
||||
onBack={() => setSearchParams({ auditId: undefined })}
|
||||
/>
|
||||
);
|
||||
@ -65,13 +64,11 @@ function AuditDetail({
|
||||
projectId,
|
||||
auditId,
|
||||
tab,
|
||||
setSearchParams,
|
||||
onBack,
|
||||
}: {
|
||||
projectId: string;
|
||||
auditId: string;
|
||||
tab: string;
|
||||
setSearchParams: (updates: Record<string, string | undefined>) => void;
|
||||
onBack: () => void;
|
||||
}) {
|
||||
const statusQuery = useQuery({
|
||||
@ -181,7 +178,6 @@ function AuditDetail({
|
||||
projectId={projectId}
|
||||
data={resultsQuery.data}
|
||||
tab={tab}
|
||||
setSearchParams={setSearchParams}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router";
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
import { DEFAULT_LOCATION_CODE } from "@/client/features/keywords/locations";
|
||||
import { KeywordResearchPage } from "@/client/features/keywords/page/KeywordResearchPage";
|
||||
import {
|
||||
clearKeywordSearchParams,
|
||||
isResultLimit,
|
||||
normalizeKeywordMode,
|
||||
normalizeLegacyKeywordSearch,
|
||||
@ -29,7 +28,6 @@ export const Route = createFileRoute("/_project/p/$projectId/keywords")({
|
||||
|
||||
function KeywordResearchPageRoute() {
|
||||
const { projectId } = Route.useParams();
|
||||
const navigate = useNavigate({ from: Route.fullPath });
|
||||
const search = Route.useSearch();
|
||||
const {
|
||||
q: keywordInput = "",
|
||||
@ -43,12 +41,6 @@ function KeywordResearchPageRoute() {
|
||||
|
||||
return (
|
||||
<KeywordResearchPage
|
||||
onShowRecentSearches={() => {
|
||||
void navigate({
|
||||
search: clearKeywordSearchParams,
|
||||
replace: true,
|
||||
});
|
||||
}}
|
||||
projectId={projectId}
|
||||
keywordInput={keywordInput}
|
||||
locationCode={locationCode}
|
||||
|
||||
@ -1,11 +1,48 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { PromptExplorerPage } from "@/client/features/ai-search/PromptExplorerPage";
|
||||
import {
|
||||
PROMPT_EXPLORER_MODELS,
|
||||
promptExplorerSearchSchema,
|
||||
} from "@/types/schemas/ai-search";
|
||||
|
||||
export const Route = createFileRoute("/_project/p/$projectId/prompt-explorer")({
|
||||
validateSearch: promptExplorerSearchSchema,
|
||||
component: PromptExplorerRoute,
|
||||
});
|
||||
|
||||
function PromptExplorerRoute() {
|
||||
const { projectId } = Route.useParams();
|
||||
return <PromptExplorerPage projectId={projectId} />;
|
||||
const navigate = useNavigate({ from: Route.fullPath });
|
||||
const search = Route.useSearch();
|
||||
|
||||
return (
|
||||
<PromptExplorerPage
|
||||
projectId={projectId}
|
||||
urlState={{
|
||||
prompt: search.q ?? "",
|
||||
highlightBrand: search.hb ?? "",
|
||||
models:
|
||||
search.models && search.models.length > 0
|
||||
? search.models
|
||||
: [...PROMPT_EXPLORER_MODELS],
|
||||
webSearch: search.web ?? true,
|
||||
webSearchCountryCode: search.cc ?? "US",
|
||||
}}
|
||||
onSubmit={(values) => {
|
||||
void navigate({
|
||||
search: {
|
||||
q: values.prompt,
|
||||
models: values.models,
|
||||
web: values.webSearch ? undefined : false,
|
||||
cc:
|
||||
values.webSearchCountryCode === "US"
|
||||
? undefined
|
||||
: values.webSearchCountryCode,
|
||||
hb: values.highlightBrand || undefined,
|
||||
},
|
||||
replace: true,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -209,3 +209,26 @@ export type PromptExplorerResult = z.infer<typeof promptExplorerResultSchema>;
|
||||
export const brandLookupSearchSchema = z.object({
|
||||
q: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* /p/$projectId/prompt-explorer query params. The full prompt config is
|
||||
* encoded in the URL so a search is shareable and cmd+click on a history
|
||||
* item opens the same answer in a new tab.
|
||||
*/
|
||||
export const promptExplorerSearchSchema = z.object({
|
||||
q: z.string().optional(),
|
||||
models: z
|
||||
.union([promptExplorerModelSchema, z.array(promptExplorerModelSchema)])
|
||||
.optional()
|
||||
.transform((value) =>
|
||||
value === undefined ? undefined : Array.isArray(value) ? value : [value],
|
||||
),
|
||||
web: z
|
||||
.union([z.boolean(), z.enum(["true", "false"])])
|
||||
.optional()
|
||||
.transform((value) =>
|
||||
value === undefined ? undefined : value === true || value === "true",
|
||||
),
|
||||
cc: webSearchCountryCodeSchema.optional(),
|
||||
hb: z.string().optional(),
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user