* 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>
317 lines
8.9 KiB
TypeScript
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.");
|
|
}
|
|
};
|
|
}
|