metatron-open-seo/src/client/features/domain/domainOverviewControllerInternals.ts
Ben Senescu ae7712238e refactor: use TanStack Form everywhere (#49)
* refactor: move lighthouse audits to dataforseo (#43)

* refactor: move lighthouse audits to dataforseo

* chore: remove obsolete audit settings modal

* refactor: rename psi flows to lighthouse

* save

* refactor: simplify audit lighthouse storage flow

* fix: separate lighthouse metrics from actionable audits

* refactor: remove redundant audit project inputs

* feat: redesign lighthouse issues screen with score gauges and table layout

Replace flat score cards with circular SVG gauges, condense metrics into
a compact grid, and switch issue list from cards to an expandable table
with fixed column widths.


* test: harden lighthouse regression coverage

* fix: restore project-scoped audit inputs

* refactor: simplify lighthouse payload handling

* refactor: inline lighthouse server handlers

* refactor: share audit workflow types

* refactor: simplify lighthouse payload flows

* save

* refactor: drop project pagespeed api key

* fix: restore lighthouse issues loading with resilient project context

* fix: restore audit issues back navigation

* refactor: simplify project context and lighthouse error handling

* fix: tolerate DataForSEO lighthouse payload drift

* refactor: route audit lighthouse through dataforseo client

---------


* refactor: unify app forms with TanStack Form

Replace bespoke field and submit error state so validation, async submit handling, and router-synced inputs behave consistently across the app.

* fix: remove dead code from ci check

* fix: delay required form errors until input interaction

* fix: preserve detailed DataForSEO error messages

* fix: sanitize internal errors and delay domain validation

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 13:38:08 -04:00

317 lines
8.9 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;
};
};
reset: (values: {
domain: string;
subdomains: boolean;
sort: DomainSortMode;
}) => void;
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((keyword) => prev.has(keyword))
) {
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.reset({
domain: searchState.domain,
subdomains: searchState.subdomains,
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() {
return useMutation({
mutationFn: (data: {
domain: string;
includeSubdomains: boolean;
locationCode: number;
languageCode: string;
}) => getDomainOverview({ data }),
});
}
export function useSearchRunner({
controlsForm,
setPendingSearch,
setSearchParams,
domainMutation,
addSearch,
setOverview,
setSelectedKeywords,
currentState,
currentSortOrder,
}: {
controlsForm: ControlsFormLike;
setPendingSearch: (value: string) => void;
setSearchParams: (
updates: Record<string, string | boolean | undefined>,
) => void;
domainMutation: ReturnType<typeof useDomainLookupMutation>;
addSearch: (item: Omit<DomainSearchHistoryItem, "timestamp">) => void;
setOverview: (value: DomainOverviewData) => void;
setSelectedKeywords: Dispatch<SetStateAction<Set<string>>>;
currentState: SearchState;
currentSortOrder: SortOrder;
}) {
return async (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;
const target = normalizeDomainTarget(rawTarget);
if (!target) {
return;
}
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,
});
try {
const response = await domainMutation.mutateAsync({
domain: target,
includeSubdomains: activeSubdomains,
locationCode: 2840,
languageCode: "en",
});
setOverview(response);
setSelectedKeywords(new Set());
addSearch({
domain: target,
subdomains: activeSubdomains,
sort: activeSort,
tab: activeTab,
search: activeSearch.trim() || undefined,
});
if (!response.hasData) {
toast.info("Not enough data for this domain");
}
return null;
} catch (error) {
return getStandardErrorMessage(error, "Lookup failed.");
}
};
}