feat: add backlink search history (#90)
* feat: add backlink search history Save recent backlink searches so users can quickly rerun previous lookups instead of starting from an empty state each time. Also widen the keyword research empty state layout to better use the available space. * update dev tools * update recent search navigation across SEO pages * refine search layouts and recent search navigation * simplify recent search resets and history storage * save * fix recent search hydration mismatch * fix ci lint issues
This commit is contained in:
parent
868d83fb1c
commit
f7ccdfee38
10
package.json
10
package.json
@ -50,9 +50,9 @@
|
||||
"@tanstack/query-core": "^5.90.9",
|
||||
"@tanstack/react-form": "^1.25.0",
|
||||
"@tanstack/react-query": "^5.90.9",
|
||||
"@tanstack/react-router": "^1.136.3",
|
||||
"@tanstack/react-router-devtools": "^1.136.3",
|
||||
"@tanstack/react-start": "^1.136.3",
|
||||
"@tanstack/react-router": "^1.168.10",
|
||||
"@tanstack/react-router-devtools": "^1.166.11",
|
||||
"@tanstack/react-start": "^1.167.16",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"autumn-js": "^1.1.7",
|
||||
"better-auth": "^1.5.5",
|
||||
@ -82,8 +82,8 @@
|
||||
"@cloudflare/workers-types": "^4.20251014.0",
|
||||
"@libsql/client": "^0.15.15",
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"@tanstack/devtools-vite": "^0.5.1",
|
||||
"@tanstack/react-devtools": "^0.9.6",
|
||||
"@tanstack/devtools-vite": "^0.6.0",
|
||||
"@tanstack/react-devtools": "^0.10.1",
|
||||
"@types/node": "^22.18.13",
|
||||
"@types/papaparse": "^5.5.2",
|
||||
"@types/react": "^19.0.8",
|
||||
|
||||
512
pnpm-lock.yaml
generated
512
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
84
src/client/features/backlinks/BacklinksHistorySection.tsx
Normal file
84
src/client/features/backlinks/BacklinksHistorySection.tsx
Normal file
@ -0,0 +1,84 @@
|
||||
import { Clock, History, Link2, X } from "lucide-react";
|
||||
import type { BacklinksSearchHistoryItem } from "@/client/hooks/useBacklinksSearchHistory";
|
||||
|
||||
type Props = {
|
||||
history: BacklinksSearchHistoryItem[];
|
||||
historyLoaded: boolean;
|
||||
onRemoveHistoryItem: (timestamp: number) => void;
|
||||
onSelectHistoryItem: (item: BacklinksSearchHistoryItem) => void;
|
||||
};
|
||||
|
||||
export function BacklinksHistorySection({
|
||||
history,
|
||||
historyLoaded,
|
||||
onRemoveHistoryItem,
|
||||
onSelectHistoryItem,
|
||||
}: Props) {
|
||||
if (!historyLoaded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (history.length === 0) {
|
||||
return (
|
||||
<section className="rounded-2xl border border-dashed border-base-300 bg-base-100/70 p-6 text-center text-base-content/55 space-y-2">
|
||||
<Link2 className="size-9 mx-auto opacity-35" />
|
||||
<p className="text-base font-medium text-base-content/80">
|
||||
Enter a domain or URL to get started
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="rounded-2xl border border-base-300 bg-base-100 p-5 md:p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<History className="size-4 text-base-content/45" />
|
||||
<span className="text-sm text-base-content/60">
|
||||
{history.length} recent search{history.length !== 1 ? "es" : ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
{history.map((item) => (
|
||||
<div
|
||||
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">
|
||||
<p className="font-medium text-base-content truncate">
|
||||
{item.target}
|
||||
</p>
|
||||
<p className="text-sm text-base-content/60 truncate">
|
||||
{item.scope === "domain" ? "Site-wide" : "Exact page"}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="text-xs text-base-content/40">
|
||||
{new Date(item.timestamp).toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-xs opacity-0 group-hover:opacity-100 p-1"
|
||||
onClick={() => onRemoveHistoryItem(item.timestamp)}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -2,10 +2,12 @@ import { BacklinksSearchCard } from "./BacklinksSearchCard";
|
||||
import { BacklinksBody } from "./BacklinksPageContent";
|
||||
import type { BacklinksPageProps } from "./backlinksPageTypes";
|
||||
import {
|
||||
navigateToBacklinksHistory,
|
||||
navigateToBacklinksSearch,
|
||||
navigateToBacklinksTab,
|
||||
useBacklinksPageData,
|
||||
} from "./useBacklinksPageData";
|
||||
import { useBacklinksSearchHistory } from "@/client/hooks/useBacklinksSearchHistory";
|
||||
import { useBacklinksSpamPreferences } from "./useBacklinksSpamPreferences";
|
||||
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||
|
||||
@ -34,6 +36,23 @@ export function BacklinksPage({
|
||||
searchState,
|
||||
});
|
||||
|
||||
const {
|
||||
history,
|
||||
isLoaded: historyLoaded,
|
||||
addSearch,
|
||||
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">
|
||||
@ -56,7 +75,10 @@ export function BacklinksPage({
|
||||
referringDomainsQuery.isFetching ||
|
||||
topPagesQuery.isFetching
|
||||
}
|
||||
onSubmit={(values) => navigateToBacklinksSearch(navigate, values)}
|
||||
onSubmit={(values) => {
|
||||
navigateToBacklinksSearch(navigate, values);
|
||||
addSearch({ target: values.target, scope: values.scope });
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@ -65,6 +87,8 @@ export function BacklinksPage({
|
||||
accessStatusError={accessStatusErrorMessage}
|
||||
backlinksDisabledByError={backlinksDisabledByError}
|
||||
backlinksEnabled={backlinksEnabled}
|
||||
history={history}
|
||||
historyLoaded={historyLoaded}
|
||||
isAccessStatusLoading={accessStatusQuery.isLoading}
|
||||
overviewData={overviewQuery.data}
|
||||
overviewError={overviewErrorMessage}
|
||||
@ -89,7 +113,10 @@ export function BacklinksPage({
|
||||
topPages={topPagesQuery.data}
|
||||
hideSpam={hideSpam}
|
||||
spamThreshold={spamThreshold}
|
||||
onRemoveHistoryItem={removeHistoryItem}
|
||||
onRetryAccess={() => void accessStatusQuery.refetch()}
|
||||
onSelectHistoryItem={handleHistorySelect}
|
||||
onShowHistory={() => navigateToBacklinksHistory(navigate)}
|
||||
onSetActiveTab={(tab) => navigateToBacklinksTab(navigate, tab)}
|
||||
onRetryOverview={() => void overviewQuery.refetch()}
|
||||
onTestAccess={() => testAccessMutation.mutate()}
|
||||
|
||||
@ -5,11 +5,12 @@ import {
|
||||
} from "./BacklinksPageSections";
|
||||
import {
|
||||
BacklinksAccessLoadingState,
|
||||
BacklinksEmptyState,
|
||||
BacklinksErrorState,
|
||||
BacklinksLoadingState,
|
||||
BacklinksSetupGate,
|
||||
} from "./BacklinksPageStates";
|
||||
import { BacklinksHistorySection } from "./BacklinksHistorySection";
|
||||
import type { BacklinksSearchHistoryItem } from "@/client/hooks/useBacklinksSearchHistory";
|
||||
import type {
|
||||
BacklinksAccessStatusData,
|
||||
BacklinksOverviewData,
|
||||
@ -24,6 +25,8 @@ type BacklinksBodyProps = {
|
||||
accessStatusError: string | null;
|
||||
backlinksDisabledByError: boolean;
|
||||
backlinksEnabled: boolean;
|
||||
history: BacklinksSearchHistoryItem[];
|
||||
historyLoaded: boolean;
|
||||
isAccessStatusLoading: boolean;
|
||||
hideSpam: boolean;
|
||||
overviewData: BacklinksOverviewData | undefined;
|
||||
@ -37,7 +40,10 @@ type BacklinksBodyProps = {
|
||||
testError: string | null;
|
||||
testIsPending: boolean;
|
||||
topPages: BacklinksTopPagesData | undefined;
|
||||
onRemoveHistoryItem: (timestamp: number) => void;
|
||||
onRetryAccess: () => void;
|
||||
onSelectHistoryItem: (item: BacklinksSearchHistoryItem) => void;
|
||||
onShowHistory: () => void;
|
||||
onSetActiveTab: (tab: BacklinksSearchState["tab"]) => void;
|
||||
onRetryOverview: () => void;
|
||||
onTestAccess: () => void;
|
||||
@ -50,6 +56,8 @@ export function BacklinksBody({
|
||||
accessStatusError,
|
||||
backlinksDisabledByError,
|
||||
backlinksEnabled,
|
||||
history,
|
||||
historyLoaded,
|
||||
isAccessStatusLoading,
|
||||
hideSpam,
|
||||
overviewData,
|
||||
@ -63,7 +71,10 @@ export function BacklinksBody({
|
||||
testError,
|
||||
testIsPending,
|
||||
topPages,
|
||||
onRemoveHistoryItem,
|
||||
onRetryAccess,
|
||||
onSelectHistoryItem,
|
||||
onShowHistory,
|
||||
onSetActiveTab,
|
||||
onRetryOverview,
|
||||
onTestAccess,
|
||||
@ -122,7 +133,14 @@ export function BacklinksBody({
|
||||
}
|
||||
|
||||
if (!searchState.target) {
|
||||
return <BacklinksEmptyState />;
|
||||
return (
|
||||
<BacklinksHistorySection
|
||||
history={history}
|
||||
historyLoaded={historyLoaded}
|
||||
onRemoveHistoryItem={onRemoveHistoryItem}
|
||||
onSelectHistoryItem={onSelectHistoryItem}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (overviewLoading) {
|
||||
@ -140,7 +158,11 @@ export function BacklinksBody({
|
||||
|
||||
return (
|
||||
<>
|
||||
<BacklinksOverviewPanels data={mergedData} summaryStats={summaryStats} />
|
||||
<BacklinksOverviewPanels
|
||||
data={mergedData}
|
||||
onShowHistory={onShowHistory}
|
||||
summaryStats={summaryStats}
|
||||
/>
|
||||
<BacklinksResultsCard
|
||||
activeTab={searchState.tab}
|
||||
filteredData={filteredData}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { HeaderHelpLabel } from "@/client/features/keywords/components";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import {
|
||||
BacklinksNewLostChart,
|
||||
BacklinksTrendChart,
|
||||
@ -15,13 +16,25 @@ import { formatRelativeTimestamp } from "./backlinksPageUtils";
|
||||
|
||||
export function BacklinksOverviewPanels({
|
||||
data,
|
||||
onShowHistory,
|
||||
summaryStats,
|
||||
}: {
|
||||
data: BacklinksOverviewData;
|
||||
onShowHistory: () => void;
|
||||
summaryStats: Array<{ label: string; value: string; description: string }>;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm text-base-content/65">
|
||||
<span className="badge badge-outline">{data.scope}</span>
|
||||
<span>Target: {data.displayTarget}</span>
|
||||
|
||||
@ -1,11 +1,4 @@
|
||||
import {
|
||||
Link2,
|
||||
ShieldAlert,
|
||||
Sparkles,
|
||||
TrendingUp,
|
||||
Wrench,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { ShieldAlert, Wrench } from "lucide-react";
|
||||
import type { BacklinksAccessStatusData } from "./backlinksPageTypes";
|
||||
import { formatRelativeTimestamp } from "./backlinksPageUtils";
|
||||
|
||||
@ -78,41 +71,6 @@ export function BacklinksSetupGate({
|
||||
);
|
||||
}
|
||||
|
||||
export function BacklinksEmptyState() {
|
||||
return (
|
||||
<section className="rounded-2xl border border-dashed border-base-300 bg-base-100/70 p-8 text-center space-y-3">
|
||||
<div className="mx-auto flex size-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
||||
<Link2 className="size-6" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-lg font-medium">Start with a domain or page URL</p>
|
||||
<p className="mx-auto max-w-2xl text-sm text-base-content/65">
|
||||
Keep backlink research simple: see who links to you, check what pages
|
||||
attract links, and spot recent wins or losses without getting buried
|
||||
in enterprise SEO dashboards.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-3 pt-2 text-left md:grid-cols-3">
|
||||
<BeginnerCard
|
||||
icon={TrendingUp}
|
||||
title="Check link health"
|
||||
body="Use the overview to see backlink totals, referring domains, and broken links worth fixing."
|
||||
/>
|
||||
<BeginnerCard
|
||||
icon={Sparkles}
|
||||
title="Review real links"
|
||||
body="Start with the backlinks tab to see the pages linking to you and the strongest sources first."
|
||||
/>
|
||||
<BeginnerCard
|
||||
icon={ShieldAlert}
|
||||
title="Find easy actions"
|
||||
body="Look for broken targets, recently lost links, and your most-linked pages to decide what to do next."
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function BacklinksLoadingState() {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
@ -203,24 +161,6 @@ function BacklinksSetupFeedback({
|
||||
);
|
||||
}
|
||||
|
||||
function BeginnerCard({
|
||||
icon: Icon,
|
||||
title,
|
||||
body,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
body: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-xl border border-base-300 bg-base-100 p-4 space-y-2">
|
||||
<Icon className="size-4 text-primary" />
|
||||
<p className="font-medium">{title}</p>
|
||||
<p className="text-sm text-base-content/65">{body}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InlineMailingListLink() {
|
||||
return (
|
||||
<a
|
||||
|
||||
@ -160,6 +160,20 @@ 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"],
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { DomainOverviewLoadingState } from "@/client/features/domain/components/DomainOverviewLoadingState";
|
||||
import { DomainHistorySection } from "@/client/features/domain/components/DomainHistorySection";
|
||||
import { DomainResultsCard } from "@/client/features/domain/components/DomainResultsCard";
|
||||
@ -29,12 +30,14 @@ type Props = {
|
||||
search: (prev: Record<string, unknown>) => Record<string, unknown>;
|
||||
replace: boolean;
|
||||
}) => void;
|
||||
onShowRecentSearches: () => void;
|
||||
};
|
||||
|
||||
export function DomainOverviewPage({
|
||||
projectId,
|
||||
searchState,
|
||||
navigate,
|
||||
onShowRecentSearches,
|
||||
}: Props) {
|
||||
const queryClient = useQueryClient();
|
||||
const state = useDomainOverviewController({
|
||||
@ -43,6 +46,10 @@ export function DomainOverviewPage({
|
||||
navigate,
|
||||
searchState,
|
||||
});
|
||||
const handleShowRecentSearches = () => {
|
||||
state.resetView();
|
||||
onShowRecentSearches();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-4 py-4 md:px-6 md:py-6 pb-24 md:pb-8 overflow-auto">
|
||||
@ -69,15 +76,24 @@ export function DomainOverviewPage({
|
||||
) : state.overview === null ? (
|
||||
<div className="space-y-4 pt-1">
|
||||
<DomainHistorySection
|
||||
historyLoaded={state.historyLoaded}
|
||||
history={state.history}
|
||||
onClearHistory={state.clearHistory}
|
||||
historyLoaded={state.historyLoaded}
|
||||
onRemoveHistoryItem={state.removeHistoryItem}
|
||||
onSelectHistoryItem={state.handleHistorySelect}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<StatCard
|
||||
label="Estimated Organic Traffic"
|
||||
|
||||
@ -3,21 +3,23 @@ import { Globe } from "lucide-react";
|
||||
import type { DomainHistoryItem } from "@/client/features/domain/types";
|
||||
|
||||
type Props = {
|
||||
historyLoaded: boolean;
|
||||
history: DomainHistoryItem[];
|
||||
onClearHistory: () => void;
|
||||
historyLoaded: boolean;
|
||||
onRemoveHistoryItem: (timestamp: number) => void;
|
||||
onSelectHistoryItem: (item: DomainHistoryItem) => void;
|
||||
};
|
||||
|
||||
export function DomainHistorySection({
|
||||
historyLoaded,
|
||||
history,
|
||||
onClearHistory,
|
||||
historyLoaded,
|
||||
onRemoveHistoryItem,
|
||||
onSelectHistoryItem,
|
||||
}: Props) {
|
||||
if (!historyLoaded || history.length === 0) {
|
||||
if (!historyLoaded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (history.length === 0) {
|
||||
return (
|
||||
<section className="rounded-2xl border border-dashed border-base-300 bg-base-100/70 p-6 text-center text-base-content/55 space-y-2">
|
||||
<Globe className="size-9 mx-auto opacity-35" />
|
||||
@ -37,22 +39,19 @@ export function DomainHistorySection({
|
||||
{history.length} recent search{history.length !== 1 ? "es" : ""}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-ghost btn-xs text-error"
|
||||
onClick={onClearHistory}
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
{history.map((item) => (
|
||||
<div
|
||||
key={item.timestamp}
|
||||
className="flex items-center justify-between p-3 rounded-lg border border-base-300 bg-base-100 hover:bg-base-200 transition-colors text-left group cursor-pointer"
|
||||
onClick={() => onSelectHistoryItem(item)}
|
||||
className="group flex items-center gap-2 rounded-lg border border-base-300 bg-base-100 p-2"
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<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">
|
||||
<p className="font-medium text-base-content truncate">
|
||||
@ -63,7 +62,7 @@ export function DomainHistorySection({
|
||||
{item.search?.trim() ? ` - ${item.search}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="text-xs text-base-content/40">
|
||||
{new Date(item.timestamp).toLocaleDateString(undefined, {
|
||||
@ -74,10 +73,7 @@ export function DomainHistorySection({
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-xs opacity-0 group-hover:opacity-100 p-1"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onRemoveHistoryItem(item.timestamp);
|
||||
}}
|
||||
onClick={() => onRemoveHistoryItem(item.timestamp)}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
|
||||
@ -112,8 +112,12 @@ export function useDomainOverviewController({
|
||||
);
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const domainFilters = useDomainFilters();
|
||||
const { history, isLoaded, addSearch, clearHistory, removeHistoryItem } =
|
||||
useDomainSearchHistory(projectId);
|
||||
const {
|
||||
history,
|
||||
isLoaded: historyLoaded,
|
||||
addSearch,
|
||||
removeHistoryItem,
|
||||
} = useDomainSearchHistory(projectId);
|
||||
|
||||
const currentSortOrder = resolveSortOrder(
|
||||
searchState.sort,
|
||||
@ -202,13 +206,20 @@ export function useDomainOverviewController({
|
||||
setSearchParams,
|
||||
});
|
||||
|
||||
const resetView = useCallback(() => {
|
||||
setOverview(null);
|
||||
setPendingSearch("");
|
||||
setSelectedKeywords(new Set());
|
||||
setShowFilters(false);
|
||||
domainFilters.resetFilters();
|
||||
}, [domainFilters]);
|
||||
|
||||
return {
|
||||
controlsForm,
|
||||
isLoading: domainMutation.isPending,
|
||||
overview,
|
||||
history,
|
||||
historyLoaded: isLoaded,
|
||||
clearHistory,
|
||||
historyLoaded,
|
||||
removeHistoryItem,
|
||||
pendingSearch,
|
||||
setPendingSearch,
|
||||
@ -218,6 +229,7 @@ export function useDomainOverviewController({
|
||||
showFilters,
|
||||
setShowFilters,
|
||||
filtersForm: domainFilters.filtersForm,
|
||||
resetView,
|
||||
resetFilters: domainFilters.resetFilters,
|
||||
...handlers,
|
||||
...dataState,
|
||||
|
||||
@ -49,6 +49,18 @@ export function useKeywordResearchData(addSearch: AddSearchFn) {
|
||||
setLastSearchLocationCode(locationCode);
|
||||
};
|
||||
|
||||
const resetResearch = () => {
|
||||
setRows([]);
|
||||
setHasSearched(false);
|
||||
setLastSearchError(false);
|
||||
setLastResultSource("related");
|
||||
setLastUsedFallback(false);
|
||||
setLastSearchKeyword("");
|
||||
setLastSearchLocationCode(2840);
|
||||
setResearchError(null);
|
||||
setSearchedKeyword("");
|
||||
};
|
||||
|
||||
const runSearch = (
|
||||
input: {
|
||||
projectId: string;
|
||||
@ -120,8 +132,8 @@ export function useKeywordResearchData(addSearch: AddSearchFn) {
|
||||
researchError,
|
||||
searchedKeyword,
|
||||
isLoading: researchMutation.isPending,
|
||||
setRows,
|
||||
beginSearch,
|
||||
resetResearch,
|
||||
runSearch,
|
||||
};
|
||||
}
|
||||
|
||||
@ -21,6 +21,26 @@ 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;
|
||||
|
||||
@ -20,8 +20,8 @@ function NoResultsState({ controller }: Props) {
|
||||
const { lastSearchKeyword, lastSearchLocationCode } = controller;
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex items-start justify-center px-4 md:px-6 py-6">
|
||||
<div className="w-full max-w-2xl rounded-2xl border border-base-300 bg-base-100 p-6 md:p-8 text-center space-y-4">
|
||||
<div className="pt-1">
|
||||
<div className="w-full max-w-2xl rounded-2xl border border-base-300 bg-base-100 p-6 md:p-8 text-center space-y-4 mx-auto">
|
||||
<Globe className="size-10 mx-auto text-base-content/40" />
|
||||
<div className="space-y-2">
|
||||
<p className="text-lg font-semibold text-base-content">
|
||||
@ -45,97 +45,82 @@ function NoResultsState({ controller }: Props) {
|
||||
}
|
||||
|
||||
function SearchHistoryState({ controller }: Props) {
|
||||
const {
|
||||
clearHistory,
|
||||
controlsForm,
|
||||
history,
|
||||
historyLoaded,
|
||||
onSearch,
|
||||
removeHistoryItem,
|
||||
} = controller;
|
||||
const { history, historyLoaded, onSearch, removeHistoryItem } = controller;
|
||||
|
||||
if (!historyLoaded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto px-4 md:px-6 pb-6">
|
||||
<div className="mx-auto w-full max-w-5xl space-y-6 pt-3 md:pt-5">
|
||||
{historyLoaded && history.length > 0 ? (
|
||||
<section className="rounded-2xl border border-base-300 bg-base-100 p-5 md:p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<History className="size-4 text-base-content/45" />
|
||||
<span className="text-sm text-base-content/60">
|
||||
{history.length} recent search
|
||||
{history.length !== 1 ? "es" : ""}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-ghost btn-xs text-error"
|
||||
onClick={clearHistory}
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
<div className="space-y-4 pt-1">
|
||||
{history.length > 0 ? (
|
||||
<section className="rounded-2xl border border-base-300 bg-base-100 p-5 md:p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<History className="size-4 text-base-content/45" />
|
||||
<span className="text-sm text-base-content/60">
|
||||
{history.length} recent search
|
||||
{history.length !== 1 ? "es" : ""}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
{history.map((item) => (
|
||||
<div
|
||||
key={item.timestamp}
|
||||
className="flex items-center justify-between p-3 rounded-lg border border-base-300 bg-base-100 hover:bg-base-200 transition-colors text-left group cursor-pointer"
|
||||
onClick={() => {
|
||||
controlsForm.setFieldValue("keyword", item.keyword);
|
||||
controlsForm.setFieldValue(
|
||||
"locationCode",
|
||||
item.locationCode,
|
||||
);
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
{history.map((item) => (
|
||||
<div
|
||||
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={() =>
|
||||
onSearch({
|
||||
keyword: item.keyword,
|
||||
locationCode: item.locationCode,
|
||||
});
|
||||
}}
|
||||
})
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Clock className="size-4 text-base-content/40" />
|
||||
<div>
|
||||
<p className="font-medium text-base-content">
|
||||
{item.keyword}
|
||||
</p>
|
||||
<p className="text-sm text-base-content/60">
|
||||
{item.locationName}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-base-content/40">
|
||||
{new Date(item.timestamp).toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}
|
||||
</span>
|
||||
<button
|
||||
className="btn btn-ghost btn-xs opacity-0 group-hover:opacity-100 p-1"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
removeHistoryItem(item.timestamp);
|
||||
}}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
<Clock className="size-4 shrink-0 text-base-content/40" />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium text-base-content">
|
||||
{item.keyword}
|
||||
</p>
|
||||
<p className="truncate text-sm text-base-content/60">
|
||||
{item.locationName}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span className="text-xs text-base-content/40">
|
||||
{new Date(item.timestamp).toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-xs opacity-0 group-hover:opacity-100 p-1"
|
||||
onClick={() => removeHistoryItem(item.timestamp)}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<section className="rounded-2xl border border-dashed border-base-300 bg-base-100/70 p-6 text-center text-base-content/50 space-y-3">
|
||||
<Search className="size-10 mx-auto opacity-40" />
|
||||
<p className="text-lg font-medium text-base-content/80">
|
||||
Enter a keyword to get started
|
||||
</p>
|
||||
<p className="text-sm max-w-md mx-auto">
|
||||
Search for any keyword to see volume, difficulty, CPC, and related
|
||||
keyword ideas.
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<section className="rounded-2xl border border-dashed border-base-300 bg-base-100/70 p-6 text-center text-base-content/50 space-y-3">
|
||||
<Search className="size-10 mx-auto opacity-40" />
|
||||
<p className="text-lg font-medium text-base-content/80">
|
||||
Enter a keyword to get started
|
||||
</p>
|
||||
<p className="text-sm max-w-md mx-auto">
|
||||
Search for any keyword to see volume, difficulty, CPC, and related
|
||||
keyword ideas.
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
export function KeywordResearchLoadingState() {
|
||||
return (
|
||||
<div className="flex-1 w-full px-4 md:px-6 pb-4 max-w-8xl mx-auto">
|
||||
<div className="flex-1 w-full pt-1">
|
||||
<div className="hidden md:flex h-full gap-4 mt-2">
|
||||
<div className="flex-1 flex flex-col min-w-0 gap-2">
|
||||
<div className="rounded-xl border border-base-300 bg-base-100 p-4">
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { AlertCircle, ArrowLeft } from "lucide-react";
|
||||
import { useKeywordResearchController } from "@/client/features/keywords/state/useKeywordResearchController";
|
||||
import type { KeywordResearchControllerInput } from "@/client/features/keywords/state/useKeywordResearchController";
|
||||
import { KeywordResearchEmptyState } from "./KeywordResearchEmptyState";
|
||||
@ -7,50 +7,99 @@ import { KeywordResearchResults } from "./KeywordResearchResults";
|
||||
import { KeywordResearchSearchBar } from "./KeywordResearchSearchBar";
|
||||
import type { KeywordResearchControllerState } from "./types";
|
||||
|
||||
type Props = KeywordResearchControllerInput;
|
||||
type Props = KeywordResearchControllerInput & {
|
||||
onShowRecentSearches: () => void;
|
||||
};
|
||||
|
||||
export function KeywordResearchPage(props: Props) {
|
||||
const controller = useKeywordResearchController(props);
|
||||
export function KeywordResearchPage({ onShowRecentSearches, ...input }: Props) {
|
||||
const controller = useKeywordResearchController(input);
|
||||
const handleShowRecentSearches = () => {
|
||||
controller.resetView();
|
||||
onShowRecentSearches();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
<KeywordResearchSearchBar controller={controller} />
|
||||
<KeywordResearchContent controller={controller} />
|
||||
<KeywordSaveDialog controller={controller} />
|
||||
<div className="px-4 py-4 md:px-6 md:py-6 pb-24 md:pb-8 overflow-auto">
|
||||
<div className="mx-auto max-w-7xl space-y-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Keyword Research</h1>
|
||||
<p className="text-sm text-base-content/70">
|
||||
Discover keyword ideas, search demand, and ranking opportunities.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<KeywordResearchSearchBar controller={controller} />
|
||||
<KeywordResearchContent
|
||||
controller={controller}
|
||||
onShowRecentSearches={handleShowRecentSearches}
|
||||
/>
|
||||
<KeywordSaveDialog controller={controller} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KeywordResearchContent({
|
||||
controller,
|
||||
onShowRecentSearches,
|
||||
}: {
|
||||
controller: KeywordResearchControllerState;
|
||||
onShowRecentSearches: () => void;
|
||||
}) {
|
||||
const recentSearchesButton = controller.hasSearched ? (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
if (controller.isLoading) {
|
||||
return <KeywordResearchLoadingState />;
|
||||
}
|
||||
|
||||
if (controller.researchError) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center px-4 md:px-6">
|
||||
<div className="w-full max-w-xl rounded-xl border border-error/30 bg-error/10 p-5 text-error space-y-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="mt-0.5 size-4 shrink-0" />
|
||||
<p className="text-sm">{controller.researchError}</p>
|
||||
<div className="space-y-4 pt-1">
|
||||
{recentSearchesButton}
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="w-full max-w-xl rounded-xl border border-error/30 bg-error/10 p-5 text-error space-y-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="mt-0.5 size-4 shrink-0" />
|
||||
<p className="text-sm">{controller.researchError}</p>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={() => controller.onSearch()}
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
<button className="btn btn-sm" onClick={() => controller.onSearch()}>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (controller.rows.length === 0) {
|
||||
return <KeywordResearchEmptyState controller={controller} />;
|
||||
return (
|
||||
<div className="space-y-4 pt-1">
|
||||
{recentSearchesButton}
|
||||
<KeywordResearchEmptyState controller={controller} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <KeywordResearchResults controller={controller} />;
|
||||
return (
|
||||
<div className="space-y-4 pt-1">
|
||||
{recentSearchesButton}
|
||||
<KeywordResearchResults controller={controller} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KeywordSaveDialog({
|
||||
|
||||
@ -8,7 +8,7 @@ type Props = {
|
||||
|
||||
export function KeywordResearchResults({ controller }: Props) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col overflow-hidden w-full px-4 md:px-6 pb-4 max-w-8xl mx-auto">
|
||||
<div className="flex-1 flex flex-col overflow-hidden w-full pt-1">
|
||||
<KeywordResearchDesktopResults controller={controller} />
|
||||
<KeywordResearchMobileResults controller={controller} />
|
||||
</div>
|
||||
|
||||
@ -16,102 +16,104 @@ export function KeywordResearchSearchBar({ controller }: Props) {
|
||||
const { controlsForm, handleSearchSubmit, isLoading } = controller;
|
||||
|
||||
return (
|
||||
<div className="shrink-0 px-4 md:px-6 pt-4 pb-2 max-w-8xl mx-auto w-full">
|
||||
<form
|
||||
className="bg-base-100 border border-base-300 rounded-xl px-4 py-3 flex flex-wrap items-center gap-2"
|
||||
onSubmit={handleSearchSubmit}
|
||||
>
|
||||
<div className="card border border-base-300 bg-base-100">
|
||||
<div className="card-body gap-2">
|
||||
<form
|
||||
className="w-full flex flex-wrap items-center gap-2"
|
||||
onSubmit={handleSearchSubmit}
|
||||
>
|
||||
<controlsForm.Field name="keyword">
|
||||
{(field) => {
|
||||
const keywordError = getFieldError(field.state.meta.errors);
|
||||
|
||||
return (
|
||||
<label
|
||||
className={`input input-bordered flex items-center gap-2 flex-1 min-w-0 max-w-md ${keywordError ? "input-error" : ""}`}
|
||||
>
|
||||
<Search className="size-4 shrink-0 text-base-content/60" />
|
||||
<input
|
||||
className="grow min-w-0"
|
||||
placeholder="Enter Keyword"
|
||||
value={field.state.value}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}}
|
||||
</controlsForm.Field>
|
||||
|
||||
<controlsForm.Field name="locationCode">
|
||||
{(field) => (
|
||||
<select
|
||||
className="select select-bordered select-sm w-auto"
|
||||
value={field.state.value}
|
||||
onChange={(event) =>
|
||||
field.handleChange(Number(event.target.value))
|
||||
}
|
||||
>
|
||||
{LOCATION_OPTIONS.map((option) => (
|
||||
<option key={option.code} value={option.code}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</controlsForm.Field>
|
||||
|
||||
<controlsForm.Field name="resultLimit">
|
||||
{(field) => (
|
||||
<select
|
||||
className="select select-bordered select-sm w-auto"
|
||||
value={field.state.value}
|
||||
onChange={(event) => {
|
||||
const next = Number(event.target.value);
|
||||
field.handleChange(isResultLimit(next) ? next : 150);
|
||||
}}
|
||||
>
|
||||
{RESULT_LIMITS.map((limit) => (
|
||||
<option key={limit} value={limit}>
|
||||
{limit} results
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</controlsForm.Field>
|
||||
|
||||
<controlsForm.Field name="mode">
|
||||
{(field) => (
|
||||
<select
|
||||
className="select select-bordered select-sm w-auto"
|
||||
value={field.state.value}
|
||||
onChange={(event) =>
|
||||
field.handleChange(normalizeKeywordMode(event.target.value))
|
||||
}
|
||||
>
|
||||
<option value="auto">Auto</option>
|
||||
<option value="related">Related keywords</option>
|
||||
<option value="suggestions">Suggestions</option>
|
||||
<option value="ideas">Ideas</option>
|
||||
</select>
|
||||
)}
|
||||
</controlsForm.Field>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary btn-sm px-6 font-semibold"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? "Searching..." : "Search"}
|
||||
</button>
|
||||
</form>
|
||||
<controlsForm.Field name="keyword">
|
||||
{(field) => {
|
||||
const keywordError = getFieldError(field.state.meta.errors);
|
||||
|
||||
return (
|
||||
<label
|
||||
className={`input input-bordered input-sm flex items-center gap-2 flex-1 min-w-0 max-w-md ${keywordError ? "input-error" : ""}`}
|
||||
>
|
||||
<Search className="size-3.5 shrink-0 text-base-content/50" />
|
||||
<input
|
||||
className="grow min-w-0"
|
||||
placeholder="Enter Keyword"
|
||||
value={field.state.value}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
return keywordError ? (
|
||||
<p className="text-sm text-error">{keywordError}</p>
|
||||
) : null;
|
||||
}}
|
||||
</controlsForm.Field>
|
||||
|
||||
<controlsForm.Field name="locationCode">
|
||||
{(field) => (
|
||||
<select
|
||||
className="select select-bordered select-sm w-auto"
|
||||
value={field.state.value}
|
||||
onChange={(event) =>
|
||||
field.handleChange(Number(event.target.value))
|
||||
}
|
||||
>
|
||||
{LOCATION_OPTIONS.map((option) => (
|
||||
<option key={option.code} value={option.code}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</controlsForm.Field>
|
||||
|
||||
<controlsForm.Field name="resultLimit">
|
||||
{(field) => (
|
||||
<select
|
||||
className="select select-bordered select-sm w-auto"
|
||||
value={field.state.value}
|
||||
onChange={(event) => {
|
||||
const next = Number(event.target.value);
|
||||
field.handleChange(isResultLimit(next) ? next : 150);
|
||||
}}
|
||||
>
|
||||
{RESULT_LIMITS.map((limit) => (
|
||||
<option key={limit} value={limit}>
|
||||
{limit} results
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</controlsForm.Field>
|
||||
|
||||
<controlsForm.Field name="mode">
|
||||
{(field) => (
|
||||
<select
|
||||
className="select select-bordered select-sm w-auto"
|
||||
value={field.state.value}
|
||||
onChange={(event) =>
|
||||
field.handleChange(normalizeKeywordMode(event.target.value))
|
||||
}
|
||||
>
|
||||
<option value="auto">Auto</option>
|
||||
<option value="related">Related keywords</option>
|
||||
<option value="suggestions">Suggestions</option>
|
||||
<option value="ideas">Ideas</option>
|
||||
</select>
|
||||
)}
|
||||
</controlsForm.Field>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary btn-sm px-6 font-semibold"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? "Searching..." : "Search"}
|
||||
</button>
|
||||
</form>
|
||||
<controlsForm.Field name="keyword">
|
||||
{(field) => {
|
||||
const keywordError = getFieldError(field.state.meta.errors);
|
||||
|
||||
return keywordError ? (
|
||||
<p className="mt-2 text-sm text-error">{keywordError}</p>
|
||||
) : null;
|
||||
}}
|
||||
</controlsForm.Field>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -96,10 +96,21 @@ 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,
|
||||
clearHistory: state.clearHistory,
|
||||
confirmSave,
|
||||
controlsForm: state.controlsForm,
|
||||
exportCsv,
|
||||
@ -122,6 +133,7 @@ export function useKeywordResearchController(
|
||||
overviewKeyword: state.overviewKeyword,
|
||||
removeHistoryItem: state.removeHistoryItem,
|
||||
researchError: state.researchError,
|
||||
resetView,
|
||||
resetFilters: state.resetFilters,
|
||||
rows: state.rows,
|
||||
searchedKeyword: state.searchedKeyword,
|
||||
@ -174,7 +186,6 @@ function useKeywordControllerState(input: KeywordResearchControllerInput) {
|
||||
history,
|
||||
isLoaded: historyLoaded,
|
||||
addSearch,
|
||||
clearHistory,
|
||||
removeHistoryItem,
|
||||
} = useSearchHistory(input.projectId);
|
||||
|
||||
@ -190,6 +201,7 @@ function useKeywordControllerState(input: KeywordResearchControllerInput) {
|
||||
searchedKeyword,
|
||||
isLoading,
|
||||
beginSearch,
|
||||
resetResearch,
|
||||
runSearch,
|
||||
} = useKeywordResearchData(addSearch);
|
||||
const setSearchParams = useKeywordSearchParams();
|
||||
@ -268,7 +280,6 @@ function useKeywordControllerState(input: KeywordResearchControllerInput) {
|
||||
activeSerpKeyword,
|
||||
beginSearch,
|
||||
clearSelection,
|
||||
clearHistory,
|
||||
controlsForm,
|
||||
filteredRows,
|
||||
filtersForm,
|
||||
@ -284,6 +295,7 @@ function useKeywordControllerState(input: KeywordResearchControllerInput) {
|
||||
mobileTab: uiState.mobileTab,
|
||||
overviewKeyword,
|
||||
removeHistoryItem,
|
||||
resetResearch,
|
||||
researchError,
|
||||
runSearch,
|
||||
resetFilters,
|
||||
|
||||
56
src/client/hooks/useBacklinksSearchHistory.ts
Normal file
56
src/client/hooks/useBacklinksSearchHistory.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { z } from "zod";
|
||||
import { useLocalHistoryStore } from "@/client/hooks/useLocalHistoryStore";
|
||||
import { jsonCodec } from "@/shared/json";
|
||||
|
||||
export interface BacklinksSearchHistoryItem {
|
||||
target: string;
|
||||
scope: "domain" | "page";
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
type AddBacklinksSearchInput = Omit<BacklinksSearchHistoryItem, "timestamp">;
|
||||
|
||||
const MAX_HISTORY = 20;
|
||||
|
||||
const backlinksSearchHistoryItemSchema = z.object({
|
||||
target: z.string(),
|
||||
scope: z.enum(["domain", "page"]),
|
||||
timestamp: z.number(),
|
||||
});
|
||||
|
||||
const backlinksSearchHistorySchema = z.array(backlinksSearchHistoryItemSchema);
|
||||
const backlinksSearchHistoryCodec = jsonCodec(backlinksSearchHistorySchema);
|
||||
|
||||
function isSameSearch(
|
||||
a: BacklinksSearchHistoryItem,
|
||||
b: AddBacklinksSearchInput,
|
||||
): boolean {
|
||||
return a.target === b.target && a.scope === b.scope;
|
||||
}
|
||||
|
||||
export function useBacklinksSearchHistory(projectId: string) {
|
||||
const { history, isLoaded, addItem, removeItem } = useLocalHistoryStore<
|
||||
BacklinksSearchHistoryItem,
|
||||
AddBacklinksSearchInput
|
||||
>({
|
||||
storageKey: `backlinks-search-history:${projectId}`,
|
||||
maxItems: MAX_HISTORY,
|
||||
parse: (raw) => {
|
||||
const parsed = backlinksSearchHistoryCodec.safeParse(raw);
|
||||
return parsed.success ? parsed.data : null;
|
||||
},
|
||||
isSameItem: isSameSearch,
|
||||
createItem: (item) => ({
|
||||
...item,
|
||||
timestamp: Date.now(),
|
||||
}),
|
||||
getItemKey: (item) => item.timestamp,
|
||||
});
|
||||
|
||||
return {
|
||||
history,
|
||||
isLoaded,
|
||||
addSearch: addItem,
|
||||
removeHistoryItem: removeItem,
|
||||
};
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { z } from "zod";
|
||||
import { useLocalHistoryStore } from "@/client/hooks/useLocalHistoryStore";
|
||||
import { jsonCodec } from "@/shared/json";
|
||||
|
||||
type DomainSortMode = "rank" | "traffic" | "volume" | "score" | "cpc";
|
||||
@ -21,7 +21,7 @@ const MAX_HISTORY = 20;
|
||||
const domainSearchHistoryItemSchema = z.object({
|
||||
domain: z.string(),
|
||||
subdomains: z.boolean(),
|
||||
sort: z.enum(["rank", "traffic", "volume"]),
|
||||
sort: z.enum(["rank", "traffic", "volume", "score", "cpc"]),
|
||||
tab: z.enum(["keywords", "pages"]),
|
||||
search: z.string().optional(),
|
||||
timestamp: z.number(),
|
||||
@ -30,26 +30,6 @@ const domainSearchHistoryItemSchema = z.object({
|
||||
const domainSearchHistorySchema = z.array(domainSearchHistoryItemSchema);
|
||||
const domainSearchHistoryCodec = jsonCodec(domainSearchHistorySchema);
|
||||
|
||||
function storageKey(projectId: string) {
|
||||
return `domain-search-history:${projectId}`;
|
||||
}
|
||||
|
||||
function loadHistory(projectId: string): DomainSearchHistoryItem[] {
|
||||
const raw = localStorage.getItem(storageKey(projectId));
|
||||
if (!raw) return [];
|
||||
|
||||
const parsed = domainSearchHistoryCodec.safeParse(raw);
|
||||
return parsed.success ? parsed.data.slice(0, MAX_HISTORY) : [];
|
||||
}
|
||||
|
||||
function saveHistory(projectId: string, items: DomainSearchHistoryItem[]) {
|
||||
try {
|
||||
localStorage.setItem(storageKey(projectId), JSON.stringify(items));
|
||||
} catch {
|
||||
// storage full or unavailable - silently ignore
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeSearchText(value: string | undefined): string {
|
||||
return value?.trim() ?? "";
|
||||
}
|
||||
@ -68,50 +48,28 @@ function isSameSearch(
|
||||
}
|
||||
|
||||
export function useDomainSearchHistory(projectId: string) {
|
||||
const [history, setHistory] = useState<DomainSearchHistoryItem[]>([]);
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const { history, isLoaded, addItem, removeItem, clearItems } =
|
||||
useLocalHistoryStore<DomainSearchHistoryItem, AddDomainSearchInput>({
|
||||
storageKey: `domain-search-history:${projectId}`,
|
||||
maxItems: MAX_HISTORY,
|
||||
parse: (raw) => {
|
||||
const parsed = domainSearchHistoryCodec.safeParse(raw);
|
||||
return parsed.success ? parsed.data : null;
|
||||
},
|
||||
isSameItem: isSameSearch,
|
||||
createItem: (item) => ({
|
||||
...item,
|
||||
search: normalizeSearchText(item.search) || undefined,
|
||||
timestamp: Date.now(),
|
||||
}),
|
||||
getItemKey: (item) => item.timestamp,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setHistory(loadHistory(projectId));
|
||||
setIsLoaded(true);
|
||||
}, [projectId]);
|
||||
|
||||
const addSearch = useCallback(
|
||||
(item: AddDomainSearchInput) => {
|
||||
setHistory((prev) => {
|
||||
const filtered = prev.filter(
|
||||
(existing) => !isSameSearch(existing, item),
|
||||
);
|
||||
const next = [
|
||||
{
|
||||
...item,
|
||||
search: normalizeSearchText(item.search) || undefined,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
...filtered,
|
||||
].slice(0, MAX_HISTORY);
|
||||
saveHistory(projectId, next);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[projectId],
|
||||
);
|
||||
|
||||
const removeHistoryItem = useCallback(
|
||||
(timestamp: number) => {
|
||||
setHistory((prev) => {
|
||||
const next = prev.filter((item) => item.timestamp !== timestamp);
|
||||
saveHistory(projectId, next);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[projectId],
|
||||
);
|
||||
|
||||
const clearHistory = useCallback(() => {
|
||||
setHistory([]);
|
||||
saveHistory(projectId, []);
|
||||
}, [projectId]);
|
||||
|
||||
return { history, isLoaded, addSearch, clearHistory, removeHistoryItem };
|
||||
return {
|
||||
history,
|
||||
isLoaded,
|
||||
addSearch: addItem,
|
||||
clearHistory: clearItems,
|
||||
removeHistoryItem: removeItem,
|
||||
};
|
||||
}
|
||||
|
||||
88
src/client/hooks/useLocalHistoryStore.ts
Normal file
88
src/client/hooks/useLocalHistoryStore.ts
Normal file
@ -0,0 +1,88 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
type UseLocalHistoryStoreOptions<TItem, TAddInput> = {
|
||||
storageKey: string;
|
||||
maxItems?: number;
|
||||
parse: (raw: string) => TItem[] | null;
|
||||
isSameItem: (existing: TItem, next: TAddInput) => boolean;
|
||||
createItem: (input: TAddInput) => TItem;
|
||||
getItemKey: (item: TItem) => number;
|
||||
};
|
||||
|
||||
function loadHistory<TItem>(
|
||||
storageKey: string,
|
||||
parse: (raw: string) => TItem[] | null,
|
||||
maxItems: number,
|
||||
): TItem[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(storageKey);
|
||||
if (!raw) return [];
|
||||
|
||||
const parsed = parse(raw);
|
||||
return parsed ? parsed.slice(0, maxItems) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveHistory<TItem>(storageKey: string, items: TItem[]) {
|
||||
try {
|
||||
localStorage.setItem(storageKey, JSON.stringify(items));
|
||||
} catch {
|
||||
// storage full or unavailable - silently ignore
|
||||
}
|
||||
}
|
||||
|
||||
export function useLocalHistoryStore<TItem, TAddInput>({
|
||||
storageKey,
|
||||
maxItems = 20,
|
||||
parse,
|
||||
isSameItem,
|
||||
createItem,
|
||||
getItemKey,
|
||||
}: UseLocalHistoryStoreOptions<TItem, TAddInput>) {
|
||||
const parseRef = useRef(parse);
|
||||
const [history, setHistory] = useState<TItem[]>([]);
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
parseRef.current = parse;
|
||||
}, [parse]);
|
||||
|
||||
useEffect(() => {
|
||||
setHistory(loadHistory(storageKey, parseRef.current, maxItems));
|
||||
setIsLoaded(true);
|
||||
}, [maxItems, storageKey]);
|
||||
|
||||
const addItem = useCallback(
|
||||
(input: TAddInput) => {
|
||||
setHistory((prev) => {
|
||||
const filtered = prev.filter(
|
||||
(existing) => !isSameItem(existing, input),
|
||||
);
|
||||
const next = [createItem(input), ...filtered].slice(0, maxItems);
|
||||
saveHistory(storageKey, next);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[createItem, isSameItem, maxItems, storageKey],
|
||||
);
|
||||
|
||||
const removeItem = useCallback(
|
||||
(itemKey: number) => {
|
||||
setHistory((prev) => {
|
||||
const next = prev.filter((item) => getItemKey(item) !== itemKey);
|
||||
saveHistory(storageKey, next);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[getItemKey, storageKey],
|
||||
);
|
||||
|
||||
const clearItems = useCallback(() => {
|
||||
setHistory([]);
|
||||
saveHistory(storageKey, []);
|
||||
}, [storageKey]);
|
||||
|
||||
return { history, isLoaded, addItem, removeItem, clearItems };
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { z } from "zod";
|
||||
import { useLocalHistoryStore } from "@/client/hooks/useLocalHistoryStore";
|
||||
import { jsonCodec } from "@/shared/json";
|
||||
|
||||
interface SearchHistoryItem {
|
||||
@ -21,70 +21,34 @@ const searchHistoryItemSchema = z.object({
|
||||
const searchHistorySchema = z.array(searchHistoryItemSchema);
|
||||
const searchHistoryCodec = jsonCodec(searchHistorySchema);
|
||||
|
||||
function storageKey(projectId: string) {
|
||||
return `search-history:${projectId}`;
|
||||
}
|
||||
|
||||
function loadHistory(projectId: string): SearchHistoryItem[] {
|
||||
const raw = localStorage.getItem(storageKey(projectId));
|
||||
if (!raw) return [];
|
||||
|
||||
const parsed = searchHistoryCodec.safeParse(raw);
|
||||
return parsed.success ? parsed.data.slice(0, MAX_HISTORY) : [];
|
||||
}
|
||||
|
||||
function saveHistory(projectId: string, items: SearchHistoryItem[]) {
|
||||
try {
|
||||
localStorage.setItem(storageKey(projectId), JSON.stringify(items));
|
||||
} catch {
|
||||
// storage full or unavailable — silently ignore
|
||||
}
|
||||
}
|
||||
|
||||
export function useSearchHistory(projectId: string) {
|
||||
const [history, setHistory] = useState<SearchHistoryItem[]>([]);
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const { history, isLoaded, addItem, removeItem, clearItems } =
|
||||
useLocalHistoryStore<
|
||||
SearchHistoryItem,
|
||||
Omit<SearchHistoryItem, "timestamp">
|
||||
>({
|
||||
storageKey: `search-history:${projectId}`,
|
||||
maxItems: MAX_HISTORY,
|
||||
parse: (raw) => {
|
||||
const parsed = searchHistoryCodec.safeParse(raw);
|
||||
return parsed.success ? parsed.data : null;
|
||||
},
|
||||
isSameItem: (existing, next) =>
|
||||
existing.keyword === next.keyword &&
|
||||
existing.locationCode === next.locationCode,
|
||||
createItem: (item) => ({
|
||||
...item,
|
||||
timestamp: Date.now(),
|
||||
}),
|
||||
getItemKey: (item) => item.timestamp,
|
||||
});
|
||||
|
||||
// Load from localStorage on mount / when projectId changes
|
||||
useEffect(() => {
|
||||
setHistory(loadHistory(projectId));
|
||||
setIsLoaded(true);
|
||||
}, [projectId]);
|
||||
|
||||
const addSearch = useCallback(
|
||||
(keyword: string, locationCode: number, locationName: string) => {
|
||||
setHistory((prev) => {
|
||||
// Remove any existing entry for the same keyword+location
|
||||
const filtered = prev.filter(
|
||||
(item) =>
|
||||
!(item.keyword === keyword && item.locationCode === locationCode),
|
||||
);
|
||||
const next = [
|
||||
{ keyword, locationCode, locationName, timestamp: Date.now() },
|
||||
...filtered,
|
||||
].slice(0, MAX_HISTORY);
|
||||
saveHistory(projectId, next);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[projectId],
|
||||
);
|
||||
|
||||
const removeHistoryItem = useCallback(
|
||||
(timestamp: number) => {
|
||||
setHistory((prev) => {
|
||||
const next = prev.filter((item) => item.timestamp !== timestamp);
|
||||
saveHistory(projectId, next);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[projectId],
|
||||
);
|
||||
|
||||
const clearHistory = useCallback(() => {
|
||||
setHistory([]);
|
||||
saveHistory(projectId, []);
|
||||
}, [projectId]);
|
||||
|
||||
return { history, isLoaded, addSearch, clearHistory, removeHistoryItem };
|
||||
return {
|
||||
history,
|
||||
isLoaded,
|
||||
addSearch: (keyword: string, locationCode: number, locationName: string) =>
|
||||
addItem({ keyword, locationCode, locationName }),
|
||||
clearHistory: clearItems,
|
||||
removeHistoryItem: removeItem,
|
||||
};
|
||||
}
|
||||
|
||||
@ -33,6 +33,20 @@ function DomainOverviewRoute() {
|
||||
return (
|
||||
<DomainOverviewPage
|
||||
projectId={projectId}
|
||||
onShowRecentSearches={() => {
|
||||
void navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
domain: undefined,
|
||||
subdomains: undefined,
|
||||
sort: undefined,
|
||||
order: undefined,
|
||||
tab: undefined,
|
||||
search: undefined,
|
||||
}),
|
||||
replace: true,
|
||||
});
|
||||
}}
|
||||
navigate={navigate}
|
||||
searchState={{
|
||||
domain,
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
import { createFileRoute, redirect, useNavigate } 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,
|
||||
@ -28,6 +29,7 @@ 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 = "",
|
||||
@ -41,6 +43,12 @@ function KeywordResearchPageRoute() {
|
||||
|
||||
return (
|
||||
<KeywordResearchPage
|
||||
onShowRecentSearches={() => {
|
||||
void navigate({
|
||||
search: clearKeywordSearchParams,
|
||||
replace: true,
|
||||
});
|
||||
}}
|
||||
projectId={projectId}
|
||||
keywordInput={keywordInput}
|
||||
locationCode={locationCode}
|
||||
|
||||
@ -18,9 +18,9 @@
|
||||
"deploy:preview": "npm run build && wrangler deploy --env preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-router": "^1.161.3",
|
||||
"@tanstack/react-router-devtools": "^1.161.3",
|
||||
"@tanstack/react-start": "^1.161.3",
|
||||
"@tanstack/react-router": "^1.168.10",
|
||||
"@tanstack/react-router-devtools": "^1.166.11",
|
||||
"@tanstack/react-start": "^1.167.16",
|
||||
"fumadocs-core": "^15.5.1",
|
||||
"fumadocs-mdx": "^11.6.5",
|
||||
"fumadocs-ui": "^15.5.1",
|
||||
|
||||
676
web/pnpm-lock.yaml
generated
676
web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user