metatron-open-seo/src/client/features/domain/domainOverviewControllerInternals.ts
Ben Senescu 75f9868500
fix: Refactor folder structure & services (#17)
* refactor: simplify keyword source selection flow

* fix: improve stacked keyword page layout

* fix: keep SERP tied to searched keyword

Stop silently falling back SERP lookups to related keywords and show a keyword-specific empty message so missing coverage is explicit to users.

* refactor: extract keyword controller and switch SERP to live

Move keyword research orchestration out of the route, call DataForSEO organic live SERP, and harden response normalization with runtime validation for safer typed handling.

* refactor: split keyword research page state into focused hooks

* fix: aggregate auto keyword fallback and reuse cached results

* scope keyword metrics to projectId

* refactor: modularize SEO routes and harden parsing for CI checks

* refactor: adopt zod json codecs at parse boundaries

* refactor: adopt papaparse, tldts, and zod error enums

* refactor: reorganize client and server feature layout

Group keyword UI/controller files and server repositories/services by feature while keeping serverFunctions as the stable API surface. This improves navigation and maintainability without changing runtime behavior.

* refactor: move PSI business logic into service layer

Keep serverFunctions/psi as thin transport handlers by delegating orchestration, export shaping, and source resolution to PsiAuditService. This aligns PSI with the existing service-first backend pattern without changing endpoint behavior.

* refactor: enforce 350/120 size limits across modules

* move self host

* fix: show dynamic range for search trends

Replace the static 'Past 12 months' subtitle with a computed range from the actual plotted trend points so the UI reflects data lag correctly.

* fix: bind audit workflow writes to workflow instance

* chore: make ci checks pass
2026-03-11 23:01:52 -04:00

335 lines
9.6 KiB
TypeScript

import { useEffect, useMemo, type Dispatch, type SetStateAction } from "react";
import type { UpdateMetaOptions } from "@tanstack/react-form";
import { useMutation } from "@tanstack/react-query";
import { sortBy } from "remeda";
import { toast } from "sonner";
import { getDomainOverview } from "@/serverFunctions/domain";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
import {
getDefaultSortOrder,
normalizeDomainTarget,
sortableNullableNumber,
toPageSortMode,
toSortMode,
toSortOrder,
toSortOrderSearchParam,
toSortSearchParam,
} from "@/client/features/domain/utils";
import type {
DomainActiveTab,
DomainOverviewData,
DomainSortMode,
SortOrder,
} from "@/client/features/domain/types";
import type { DomainSearchHistoryItem } from "@/client/hooks/useDomainSearchHistory";
export type SearchState = {
domain: string;
subdomains: boolean;
sort: DomainSortMode;
order?: SortOrder;
tab: DomainActiveTab;
search: string;
};
type DomainNavigate = (args: {
search: (prev: Record<string, unknown>) => Record<string, unknown>;
replace: boolean;
}) => void;
type DomainControlsFormAccess = {
state: {
values: {
domain: string;
subdomains: boolean;
sort: DomainSortMode;
};
};
setFieldValue: (
field: "domain" | "subdomains" | "sort",
updater: string | boolean,
opts?: UpdateMetaOptions,
) => void;
};
type ControlsFormLike = DomainControlsFormAccess;
export function useOverviewDataState({
overview,
pendingSearch,
sortMode,
currentSortOrder,
setSelectedKeywords,
}: {
overview: DomainOverviewData | null;
pendingSearch: string;
sortMode: DomainSortMode;
currentSortOrder: SortOrder;
setSelectedKeywords: Dispatch<SetStateAction<Set<string>>>;
}) {
const filteredKeywords = useMemo(() => {
const source = overview?.keywords ?? [];
const filtered = !pendingSearch
? source
: source.filter((row) => {
const haystack =
`${row.keyword} ${row.relativeUrl ?? ""}`.toLowerCase();
return haystack.includes(pendingSearch.toLowerCase().trim());
});
if (sortMode === "traffic") {
return sortBy(filtered, [
(row) => sortableNullableNumber(row.traffic, currentSortOrder),
currentSortOrder,
]);
}
if (sortMode === "volume") {
return sortBy(filtered, [
(row) => sortableNullableNumber(row.searchVolume, currentSortOrder),
currentSortOrder,
]);
}
return sortBy(filtered, [
(row) => sortableNullableNumber(row.position, currentSortOrder),
currentSortOrder,
]);
}, [currentSortOrder, overview?.keywords, pendingSearch, sortMode]);
const filteredPages = useMemo(() => {
const source = overview?.pages ?? [];
const filtered = !pendingSearch
? source
: source.filter((row) => {
const text = `${row.relativePath ?? ""} ${row.page}`.toLowerCase();
return text.includes(pendingSearch.toLowerCase().trim());
});
const pageSortMode = toPageSortMode(sortMode);
if (pageSortMode === "volume") {
return sortBy(filtered, [
(row) => sortableNullableNumber(row.keywords, currentSortOrder),
currentSortOrder,
]);
}
return sortBy(filtered, [
(row) => sortableNullableNumber(row.organicTraffic, currentSortOrder),
currentSortOrder,
]);
}, [currentSortOrder, overview?.pages, pendingSearch, sortMode]);
const visibleKeywords = useMemo(
() => filteredKeywords.slice(0, 100).map((row) => row.keyword),
[filteredKeywords],
);
useEffect(() => {
const visibleSet = new Set(visibleKeywords);
setSelectedKeywords((prev) => {
const next = new Set(
[...prev].filter((keyword) => visibleSet.has(keyword)),
);
if (next.size === prev.size) return prev;
return next;
});
}, [setSelectedKeywords, visibleKeywords]);
return {
filteredKeywords,
filteredPages,
visibleKeywords,
toggleKeywordSelection: (keyword: string) => {
setSelectedKeywords((prev) => {
const next = new Set(prev);
if (next.has(keyword)) next.delete(keyword);
else next.add(keyword);
return next;
});
},
toggleAllVisibleKeywords: () => {
setSelectedKeywords((prev) => {
if (
visibleKeywords.length > 0 &&
visibleKeywords.every((k) => prev.has(k))
) {
return new Set();
}
return new Set(visibleKeywords);
});
},
};
}
export function useSyncRouteState({
controlsForm,
searchState,
setPendingSearch,
navigate,
}: {
controlsForm: ControlsFormLike;
searchState: SearchState;
setPendingSearch: (value: string) => void;
navigate: DomainNavigate;
}) {
useEffect(() => {
controlsForm.setFieldValue("domain", searchState.domain);
controlsForm.setFieldValue("subdomains", searchState.subdomains);
controlsForm.setFieldValue("sort", searchState.sort);
setPendingSearch(searchState.search);
}, [controlsForm, searchState, setPendingSearch]);
useEffect(() => {
const raw = new URLSearchParams(window.location.search);
const rawSort = toSortMode(raw.get("sort"));
const rawOrder = toSortOrder(raw.get("order"));
const shouldNormalize =
raw.get("domain") === "" ||
raw.get("search") === "" ||
raw.get("subdomains") === "true" ||
raw.get("sort") === "rank" ||
(rawOrder != null &&
rawOrder === getDefaultSortOrder(rawSort ?? "rank")) ||
raw.get("tab") === "keywords";
if (!shouldNormalize) return;
navigate({
search: (prev) => {
const prevSort =
typeof prev.sort === "string" ? toSortMode(prev.sort) : undefined;
return {
...prev,
domain: prev.domain === "" ? undefined : prev.domain,
search: prev.search === "" ? undefined : prev.search,
subdomains: prev.subdomains === true ? undefined : prev.subdomains,
sort: prev.sort === "rank" ? undefined : prev.sort,
order:
prev.order != null &&
prev.order === getDefaultSortOrder(prevSort ?? "rank")
? undefined
: prev.order,
tab: prev.tab === "keywords" ? undefined : prev.tab,
};
},
replace: true,
});
}, [navigate]);
}
export function useDomainLookupMutation({
setOverview,
setOverviewError,
}: {
setOverview: (value: DomainOverviewData) => void;
setOverviewError: (value: string | null) => void;
}) {
return useMutation({
mutationFn: (data: {
domain: string;
includeSubdomains: boolean;
locationCode: number;
languageCode: string;
}) => getDomainOverview({ data }),
onError: (error) => {
setOverviewError(getStandardErrorMessage(error, "Lookup failed."));
},
onSuccess: (response) => {
setOverview(response);
if (!response.hasData) toast.info("Not enough data for this domain");
},
});
}
export function useSearchRunner({
controlsForm,
setDomainError,
setOverviewError,
setPendingSearch,
setSearchParams,
domainMutation,
addSearch,
setOverview,
setSelectedKeywords,
currentState,
currentSortOrder,
}: {
controlsForm: ControlsFormLike;
setDomainError: (value: string | null) => void;
setOverviewError: (value: string | null) => void;
setPendingSearch: (value: string) => void;
setSearchParams: (
updates: Record<string, string | boolean | undefined>,
) => void;
domainMutation: ReturnType<typeof useDomainLookupMutation>["mutate"];
addSearch: (item: Omit<DomainSearchHistoryItem, "timestamp">) => void;
setOverview: (value: DomainOverviewData) => void;
setSelectedKeywords: (value: Set<string>) => void;
currentState: SearchState;
currentSortOrder: SortOrder;
}) {
return (params?: Partial<SearchState>) => {
const values = controlsForm.state.values;
const rawTarget = params?.domain ?? values.domain;
const activeSubdomains = params?.subdomains ?? values.subdomains;
const activeSort = params?.sort ?? currentState.sort;
const activeOrder = params?.order ?? currentSortOrder;
const activeTab = params?.tab ?? currentState.tab;
const activeSearch = params?.search ?? currentState.search;
if (!rawTarget.trim()) {
setDomainError("Please enter a domain");
return;
}
const target = normalizeDomainTarget(rawTarget);
if (!target) {
setDomainError(
"Please enter a valid URL or domain (e.g. browserbase.com)",
);
return;
}
setDomainError(null);
setOverviewError(null);
setPendingSearch(activeSearch);
controlsForm.setFieldValue("domain", target);
controlsForm.setFieldValue("subdomains", activeSubdomains);
controlsForm.setFieldValue("sort", activeSort);
setSearchParams({
domain: target,
subdomains: activeSubdomains ? undefined : activeSubdomains,
sort: toSortSearchParam(activeSort),
order: toSortOrderSearchParam(activeSort, activeOrder),
tab: activeTab === "keywords" ? undefined : activeTab,
search: activeSearch.trim() || undefined,
});
domainMutation(
{
domain: target,
includeSubdomains: activeSubdomains,
locationCode: 2840,
languageCode: "en",
},
{
onSuccess: (response) => {
setOverview(response);
setSelectedKeywords(new Set());
addSearch({
domain: target,
subdomains: activeSubdomains,
sort: activeSort,
tab: activeTab,
search: activeSearch.trim() || undefined,
});
},
onError: (error) => {
setOverviewError(getStandardErrorMessage(error, "Lookup failed."));
},
},
);
};
}