diff --git a/scripts/backlinks-cost-profile.ts b/scripts/backlinks-cost-profile.ts index 30722bc..8f5d415 100644 --- a/scripts/backlinks-cost-profile.ts +++ b/scripts/backlinks-cost-profile.ts @@ -42,22 +42,45 @@ async function main() { const includeTabs = parseBoolean(args.includeTabs, true); const runs = []; + const pageInput = { + ...input, + page: 1, + pageSize: 100, + sortOrder: "desc", + } as const; + for (let index = 0; index < repeat; index += 1) { const overview = await service.profileOverview(input, billingCustomer); + const rows = includeTabs + ? await service.profileBacklinksPage( + { ...pageInput, sortField: "rank", filters: {}, mode: "as_is" }, + billingCustomer, + ) + : null; const domains = includeTabs - ? await service.profileReferringDomains(input, billingCustomer) + ? await service.profileReferringDomainsPage( + { ...pageInput, sortField: "backlinks", filters: {} }, + billingCustomer, + ) : null; const pages = includeTabs - ? await service.profileTopPages(input, billingCustomer) + ? await service.profileTopPagesPage( + { ...pageInput, sortField: "backlinks", filters: {} }, + billingCustomer, + ) : null; runs.push({ run: index + 1, overview: { - backlinksRows: overview.overview.backlinks.length, trendRows: overview.overview.trends.length, newLostRows: overview.overview.newLostTrends.length, }, + backlinksTab: rows + ? { + rows: rows.rows.length, + } + : null, domainsTab: domains ? { rows: domains.rows.length, diff --git a/src/client/components/table/TablePagination.tsx b/src/client/components/table/TablePagination.tsx new file mode 100644 index 0000000..5b6495d --- /dev/null +++ b/src/client/components/table/TablePagination.tsx @@ -0,0 +1,97 @@ +import { ChevronLeft, ChevronRight } from "lucide-react"; + +type Props = { + page: number; + pageSize: number; + pageSizes: readonly number[]; + totalCount: number | null; + hasNextPage: boolean; + isLoading: boolean; + onPageChange: (nextPage: number) => void; + onPageSizeChange: (nextPageSize: number) => void; +}; + +function formatRange( + page: number, + pageSize: number, + totalCount: number | null, +) { + const start = (page - 1) * pageSize + 1; + if (totalCount == null) { + return `${start.toLocaleString()}–${(start + pageSize - 1).toLocaleString()}`; + } + if (totalCount === 0) return "0"; + const end = Math.min(totalCount, start + pageSize - 1); + return `${start.toLocaleString()}–${end.toLocaleString()} of ${totalCount.toLocaleString()}`; +} + +export function TablePagination({ + page, + pageSize, + pageSizes, + totalCount, + hasNextPage, + isLoading, + onPageChange, + onPageSizeChange, +}: Props) { + const totalPages = + totalCount != null ? Math.max(1, Math.ceil(totalCount / pageSize)) : null; + const canGoPrev = page > 1; + const canGoNext = totalPages != null ? page < totalPages : hasNextPage; + + return ( +
+
+ {formatRange(page, pageSize, totalCount)} + {isLoading ? ( + + ) : null} +
+ +
+ + +
+ + Page {page.toLocaleString()} + {totalPages != null ? ` of ${totalPages.toLocaleString()}` : ""} + +
+ + +
+
+
+
+ ); +} diff --git a/src/client/components/table/nullSafeSort.ts b/src/client/components/table/nullSafeSort.ts index 66e8333..c169abe 100644 --- a/src/client/components/table/nullSafeSort.ts +++ b/src/client/components/table/nullSafeSort.ts @@ -7,20 +7,16 @@ import type { Row } from "@tanstack/react-table"; * direction from the cell context and return a value that survives the flip. */ -export function isDescending( - row: Row, - columnId: string, -): boolean { +function isDescending(row: Row, columnId: string): boolean { const cell = row.getAllCells().find((c) => c.column.id === columnId); return cell?.column.getIsSorted() === "desc"; } /** * Compare two nullable numeric values with nulls always at the bottom, - * regardless of the column's current sort direction. Use this directly for - * tiebreakers or when the value isn't the column's accessor. + * regardless of the column's current sort direction. */ -export function compareNumericNullsLast( +function compareNumericNullsLast( a: number | null | undefined, b: number | null | undefined, descending: boolean, @@ -44,33 +40,3 @@ export function numericNullsLast( isDescending(rowA, columnId), ); } - -export function stringNullsLast( - rowA: Row, - rowB: Row, - columnId: string, -): number { - const a = rowA.getValue(columnId); - const b = rowB.getValue(columnId); - if (!a && !b) return 0; - if (!a || !b) { - const sign = isDescending(rowA, columnId) ? -1 : 1; - return (!a ? 1 : -1) * sign; - } - return a.toLowerCase().localeCompare(b.toLowerCase()); -} - -export function dateNullsLast( - rowA: Row, - rowB: Row, - columnId: string, -): number { - const a = rowA.getValue(columnId); - const b = rowB.getValue(columnId); - if (!a && !b) return 0; - if (!a || !b) { - const sign = isDescending(rowA, columnId) ? -1 : 1; - return (!a ? 1 : -1) * sign; - } - return Date.parse(a) - Date.parse(b); -} diff --git a/src/client/features/backlinks/BacklinksFilterPanel.tsx b/src/client/features/backlinks/BacklinksFilterPanel.tsx index 6dad1e9..4baaa5a 100644 --- a/src/client/features/backlinks/BacklinksFilterPanel.tsx +++ b/src/client/features/backlinks/BacklinksFilterPanel.tsx @@ -1,385 +1,234 @@ -import { RotateCcw } from "lucide-react"; +import { DomainFilterPanel } from "@/client/features/domain/components/DomainFilterPanel"; import type { BacklinksTab } from "@/types/schemas/backlinks"; +import { + BACKLINKS_FILTER_FIELDS, + REFERRING_DOMAINS_FILTER_FIELDS, + TOP_PAGES_FILTER_FIELDS, + countFilterConditions, + type BacklinksTabFilterValues, +} from "./backlinksFilterTypes"; import type { BacklinksFiltersState } from "./useBacklinksFilters"; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type AnyForm = { Field: React.ComponentType }; - -function FilterTextInput({ - form, - name, - label, - placeholder, -}: { - form: AnyForm; - name: string; - label: string; - placeholder: string; -}) { - return ( - - ); -} - -function FilterRangeInputs({ - form, - title, - minName, - maxName, - step, -}: { - form: AnyForm; - title: string; - minName: string; - maxName: string; - step?: string; -}) { - return ( -
-

- {title} -

-
- - -
-
- ); -} - -function CompactRangeInput({ - form, - name, - placeholder, - step, -}: { - form: AnyForm; - name: string; - placeholder: string; - step?: string; -}) { - return ( - - {(field: { - state: { value: string }; - handleChange: (v: string) => void; - }) => ( - field.handleChange(event.target.value)} - /> - )} - - ); -} - -function BacklinksTabFilters({ - form, - showAhrefsDrFilter, -}: { - form: BacklinksFiltersState["backlinks"]["form"]; - showAhrefsDrFilter: boolean; -}) { - return ( - <> -
- - -
- -
- - {showAhrefsDrFilter ? ( - - ) : null} - - -
- -
-
-

- Link Type -

- - {(field) => ( -
- {(["", "dofollow", "nofollow"] as const).map((value) => ( - - ))} -
- )} -
-
- -
-

- Visibility -

-
- - {(field) => ( - - )} - - - {(field) => ( - - )} - -
-
-
- - ); -} - -function ReferringDomainsFilters({ - form, - showAhrefsDrFilter, -}: { - form: BacklinksFiltersState["domains"]["form"]; - showAhrefsDrFilter: boolean; -}) { - return ( - <> -
- - -
- -
- - - {showAhrefsDrFilter ? ( - - ) : null} - -
- - ); -} - -function TopPagesFilters({ - form, -}: { - form: BacklinksFiltersState["pages"]["form"]; -}) { - return ( - <> -
- - -
- -
- - - -
- - ); -} - +/** + * Filters are applied explicitly (not per keystroke) because every change + * triggers a billed DataForSEO request. Each include/exclude term and each + * set field costs one DataForSEO filter condition, capped per request — + * DomainFilterPanel surfaces the count and gates Apply. + */ export function BacklinksFilterPanel({ activeTab, filters, - showAhrefsDrFilter, - activeFilterCount, + onApplied, }: { activeTab: BacklinksTab; filters: BacklinksFiltersState; - showAhrefsDrFilter: boolean; - activeFilterCount: number; + onApplied: () => void; }) { - const current = filters[activeTab]; + if (activeTab === "backlinks") { + const state = filters.backlinks; + return ( + { + state.apply(values); + onApplied(); + }} + onClear={() => { + state.reset(); + onApplied(); + }} + renderExtra={(draft, setValue) => ( + + )} + /> + ); + } + if (activeTab === "domains") { + const state = filters.domains; + return ( + { + state.apply(values); + onApplied(); + }} + onClear={() => { + state.reset(); + onApplied(); + }} + /> + ); + } + + const state = filters.pages; return ( -
-
-
-

Refine results

- {activeFilterCount > 0 ? ( - - {activeFilterCount} active - - ) : null} + { + state.apply(values); + onApplied(); + }} + onClear={() => { + state.reset(); + onApplied(); + }} + /> + ); +} + +function BacklinksToggleControls({ + draft, + setValue, +}: { + draft: BacklinksTabFilterValues; + setValue: (key: keyof BacklinksTabFilterValues, value: string) => void; +}) { + return ( +
+
+

+ Link Type +

+
+ {(["", "dofollow", "nofollow"] as const).map((value) => ( + + ))}
-
- {activeTab === "backlinks" ? ( - - ) : null} - {activeTab === "domains" ? ( - - ) : null} - {activeTab === "pages" ? ( - - ) : null} +
+

+ Visibility +

+
+ + +
+
); } diff --git a/src/client/features/backlinks/BacklinksHistorySection.tsx b/src/client/features/backlinks/BacklinksHistorySection.tsx index 28afaca..e6b678d 100644 --- a/src/client/features/backlinks/BacklinksHistorySection.tsx +++ b/src/client/features/backlinks/BacklinksHistorySection.tsx @@ -55,6 +55,9 @@ export function BacklinksHistorySection({ target: item.target, scope: item.scope, tab: undefined, + page: undefined, + sort: undefined, + order: 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" diff --git a/src/client/features/backlinks/BacklinksOverviewPanels.tsx b/src/client/features/backlinks/BacklinksOverviewPanels.tsx new file mode 100644 index 0000000..95fb917 --- /dev/null +++ b/src/client/features/backlinks/BacklinksOverviewPanels.tsx @@ -0,0 +1,152 @@ +import { Link } from "@tanstack/react-router"; +import { ArrowLeft } from "lucide-react"; +import { HeaderHelpLabel } from "@/client/features/keywords/components"; +import { + BacklinksNewLostChart, + BacklinksTrendChart, +} from "./BacklinksPageCharts"; +import type { BacklinksOverviewData } from "./backlinksPageTypes"; +import { formatRelativeTimestamp } from "./backlinksPageUtils"; + +type SummaryStat = { label: string; value: string; description: string }; + +export function BacklinksOverviewPanels({ + projectId, + data, + summaryStats, +}: { + projectId: string; + data: BacklinksOverviewData; + summaryStats: SummaryStat[]; +}) { + return ( + <> +
+ + + Recent searches + +
+
+ {data.scope} + Target: {data.displayTarget} + - + Updated {formatRelativeTimestamp(data.fetchedAt)} +
+ + {data.scope === "page" ? ( +
+ + Showing backlinks for this exact page. Enter a bare domain for + site-wide results. Trend charts are only shown for domain-level + lookups. + +
+ ) : null} + + ); +} + +function OverviewGrid({ + data, + summaryStats, +}: { + data: BacklinksOverviewData; + summaryStats: SummaryStat[]; +}) { + const domainScope = data.scope === "domain"; + + return ( +
+ + {domainScope ? : null} +
+ ); +} + +function SummaryStatsGrid({ + data, + summaryStats, +}: { + data: BacklinksOverviewData; + summaryStats: SummaryStat[]; +}) { + const cardClassName = `card bg-base-100 border border-base-300 ${data.scope === "domain" ? "md:col-span-2 xl:col-span-1" : ""}`; + + return ( +
+
+
+ {summaryStats.map((item) => ( +
+
+ +
+

{item.value}

+
+ ))} +
+
+
+ ); +} + +function TrendPanels({ data }: { data: BacklinksOverviewData }) { + return ( + <> + + + + + + + + ); +} + +function TrendCard({ + children, + description, + title, +}: { + children: React.ReactNode; + description: string; + title: string; +}) { + return ( +
+
+
+

{title}

+

{description}

+
+ {children} +
+
+ ); +} diff --git a/src/client/features/backlinks/BacklinksPage.tsx b/src/client/features/backlinks/BacklinksPage.tsx index c3a2fb0..f0c50b3 100644 --- a/src/client/features/backlinks/BacklinksPage.tsx +++ b/src/client/features/backlinks/BacklinksPage.tsx @@ -1,4 +1,5 @@ import { useCallback, useMemo } from "react"; +import type { SortingState, Updater } from "@tanstack/react-table"; import { BacklinksSearchCard } from "./BacklinksSearchCard"; import { BacklinksBody } from "./BacklinksPageContent"; import type { BacklinksPageProps } from "./backlinksPageTypes"; @@ -7,6 +8,7 @@ import { navigateToBacklinksSearch, useBacklinksPageData, } from "./useBacklinksPageData"; +import { useBacklinksDomainExpansion } from "./useBacklinksDomainExpansion"; import { useBacklinksFilters } from "./useBacklinksFilters"; import { useBacklinksSearchHistory } from "@/client/hooks/useBacklinksSearchHistory"; import type { @@ -14,6 +16,10 @@ import type { SearchTabInput, } from "@/client/features/search-tabs/types"; import { useSearchTabNavigation } from "@/client/features/search-tabs/useSearchTabNavigation"; +import { + BACKLINKS_DEFAULT_SORT, + DEFAULT_BACKLINKS_PAGE_SIZE, +} from "@/types/schemas/backlinks"; export function BacklinksPage({ projectId, @@ -21,18 +27,94 @@ export function BacklinksPage({ navigate, }: BacklinksPageProps) { const filters = useBacklinksFilters(); + + // Sort lives in the URL so sort changes and the page reset commit in one + // navigation (no transient fetch of the old page with the new sort). + const sorting = useMemo(() => { + const fallback = BACKLINKS_DEFAULT_SORT[searchState.tab]; + const field = searchState.sort ?? fallback.field; + const order = + searchState.order ?? (searchState.sort ? "desc" : fallback.order); + return [{ id: field, desc: order === "desc" }]; + }, [searchState.order, searchState.sort, searchState.tab]); + + const handleSortingChange = useCallback( + (updater: Updater) => { + const next = typeof updater === "function" ? updater(sorting) : updater; + const first = next[0]; + navigate({ + search: (prev) => ({ + ...prev, + sort: first?.id, + order: first ? (first.desc ? "desc" : "asc") : undefined, + page: undefined, + }), + replace: true, + }); + }, + [navigate, sorting], + ); + + const handlePageChange = useCallback( + (nextPage: number) => { + navigate({ + search: (prev) => ({ + ...prev, + page: nextPage === 1 ? undefined : nextPage, + }), + replace: true, + }); + }, + [navigate], + ); + + const handlePageSizeChange = useCallback( + (nextPageSize: number) => { + navigate({ + search: (prev) => ({ + ...prev, + size: + nextPageSize === DEFAULT_BACKLINKS_PAGE_SIZE + ? undefined + : nextPageSize, + page: undefined, + }), + replace: true, + }); + }, + [navigate], + ); + + const handleViewChange = useCallback( + (nextView: "all" | undefined) => { + navigate({ + search: (prev) => ({ ...prev, view: nextView, page: undefined }), + replace: true, + }); + }, + [navigate], + ); + + const domainExpansion = useBacklinksDomainExpansion({ + projectId, + searchState, + }); + const { accessGate, activeTabErrorMessage, + activeTabQuery, backlinksDisabledByError, overviewErrorMessage, overviewQuery, referringDomainsQuery, + rowsQuery, searchCardInitialValues, topPagesQuery, } = useBacklinksPageData({ projectId, searchState, + filters, }); const { @@ -71,6 +153,9 @@ export function BacklinksPage({ search: (prev) => ({ ...prev, tab: tab === "backlinks" ? undefined : tab, + page: undefined, + sort: undefined, + order: undefined, }), replace: true, }); @@ -134,19 +219,23 @@ export function BacklinksPage({ overviewData={overviewQuery.data} overviewError={overviewErrorMessage} overviewLoading={overviewQuery.isLoading} - referringDomains={referringDomainsQuery.data} + backlinksRowsPage={rowsQuery.data} + referringDomainsPage={referringDomainsQuery.data} + topPagesPage={topPagesQuery.data} searchState={searchState} filters={filters} + sorting={sorting} + domainExpansion={domainExpansion} tabErrorMessage={activeTabErrorMessage} - tabLoading={ - (searchState.tab === "domains" && - referringDomainsQuery.isLoading) || - (searchState.tab === "pages" && topPagesQuery.isLoading) - } - topPages={topPagesQuery.data} + tabLoading={activeTabQuery.isLoading} + tabFetching={activeTabQuery.isFetching} + onPageChange={handlePageChange} + onPageSizeChange={handlePageSizeChange} onRemoveHistoryItem={removeHistoryItem} onRetryOverview={() => void overviewQuery.refetch()} + onSortingChange={handleSortingChange} onTabChange={handleResultTabChange} + onViewChange={handleViewChange} searchTabs={ searchState.target ? { diff --git a/src/client/features/backlinks/BacklinksPageContent.tsx b/src/client/features/backlinks/BacklinksPageContent.tsx index d1604e8..63267d5 100644 --- a/src/client/features/backlinks/BacklinksPageContent.tsx +++ b/src/client/features/backlinks/BacklinksPageContent.tsx @@ -1,8 +1,7 @@ import { useMemo } from "react"; -import { - BacklinksOverviewPanels, - BacklinksResultsCard, -} from "./BacklinksPageSections"; +import type { OnChangeFn, SortingState } from "@tanstack/react-table"; +import { BacklinksOverviewPanels } from "./BacklinksOverviewPanels"; +import { BacklinksResultsCard } from "./BacklinksPageSections"; import { BacklinksErrorState, BacklinksLoadingState, @@ -13,17 +12,15 @@ import type { BacklinksSearchHistoryItem } from "@/client/hooks/useBacklinksSear import type { BacklinksOverviewData, BacklinksReferringDomainsData, + BacklinksRowsPageData, BacklinksSearchState, + BacklinksTabRows, BacklinksTopPagesData, } from "./backlinksPageTypes"; import type { UseAccessGateResult } from "@/client/features/access-gate/useAccessGate"; import { AccessGateLoadingState } from "@/client/features/access-gate/AccessGate"; import { buildSummaryStats } from "./backlinksPageUtils"; -import { - filterBacklinkRows, - filterReferringDomainRows, - filterTopPageRows, -} from "./backlinksFiltering"; +import type { BacklinksDomainExpansion } from "./useBacklinksDomainExpansion"; import type { BacklinksFiltersState } from "./useBacklinksFilters"; import { SearchTabStrip, @@ -39,15 +36,23 @@ type BacklinksBodyProps = { overviewData: BacklinksOverviewData | undefined; overviewError: string | null; overviewLoading: boolean; - referringDomains: BacklinksReferringDomainsData | undefined; + backlinksRowsPage: BacklinksRowsPageData | undefined; + referringDomainsPage: BacklinksReferringDomainsData | undefined; + topPagesPage: BacklinksTopPagesData | undefined; searchState: BacklinksSearchState; filters: BacklinksFiltersState; + sorting: SortingState; + domainExpansion: BacklinksDomainExpansion; tabErrorMessage: string | null; tabLoading: boolean; - topPages: BacklinksTopPagesData | undefined; + tabFetching: boolean; + onPageChange: (nextPage: number) => void; + onPageSizeChange: (nextPageSize: number) => void; onRemoveHistoryItem: (timestamp: number) => void; onRetryOverview: () => void; + onSortingChange: OnChangeFn; onTabChange: (tab: BacklinksSearchState["tab"]) => void; + onViewChange: (view: "all" | undefined) => void; searchTabs: { activeTabId: string | null; tabs: SearchTab[]; @@ -66,45 +71,42 @@ export function BacklinksBody({ overviewData, overviewError, overviewLoading, - referringDomains, + backlinksRowsPage, + referringDomainsPage, + topPagesPage, searchState, filters, + sorting, + domainExpansion, tabErrorMessage, tabLoading, - topPages, + tabFetching, + onPageChange, + onPageSizeChange, onRemoveHistoryItem, onRetryOverview, + onSortingChange, onTabChange, + onViewChange, searchTabs, }: BacklinksBodyProps) { - const mergedData = useMemo( - () => mergeTabData(overviewData, referringDomains, topPages), - [overviewData, referringDomains, topPages], + const tabRows = useMemo( + () => ({ + backlinks: backlinksRowsPage?.rows ?? [], + referringDomains: referringDomainsPage?.rows ?? [], + topPages: topPagesPage?.rows ?? [], + }), + [backlinksRowsPage, referringDomainsPage, topPagesPage], ); - const filteredData = useMemo(() => { - if (!mergedData) { - return { backlinks: [], referringDomains: [], topPages: [] }; - } - return { - backlinks: filterBacklinkRows( - mergedData.backlinks, - filters.backlinks.values, - ), - referringDomains: filterReferringDomainRows( - mergedData.referringDomains, - filters.domains.values, - ), - topPages: filterTopPageRows(mergedData.topPages, filters.pages.values), - }; - }, [ - mergedData, - filters.backlinks.values, - filters.domains.values, - filters.pages.values, - ]); + const activeTabPage = + searchState.tab === "backlinks" + ? backlinksRowsPage + : searchState.tab === "domains" + ? referringDomainsPage + : topPagesPage; const summaryStats = useMemo( - () => buildSummaryStats(mergedData), - [mergedData], + () => buildSummaryStats(overviewData), + [overviewData], ); const tabStrip = searchTabs ? ( {tabStrip} @@ -177,37 +179,33 @@ export function BacklinksBody({ {tabStrip} ); } - -function mergeTabData( - data: BacklinksOverviewData | undefined, - referringDomains: BacklinksReferringDomainsData | undefined, - topPages: BacklinksTopPagesData | undefined, -) { - if (!data) { - return undefined; - } - - return { - ...data, - referringDomains: referringDomains ?? data.referringDomains, - topPages: topPages ?? data.topPages, - }; -} diff --git a/src/client/features/backlinks/BacklinksPageSections.tsx b/src/client/features/backlinks/BacklinksPageSections.tsx index 110f608..e72e8f6 100644 --- a/src/client/features/backlinks/BacklinksPageSections.tsx +++ b/src/client/features/backlinks/BacklinksPageSections.tsx @@ -1,34 +1,28 @@ import { useEffect, useMemo } from "react"; -import { Link } from "@tanstack/react-router"; -import { HeaderHelpLabel } from "@/client/features/keywords/components"; -import { ArrowLeft, SlidersHorizontal } from "lucide-react"; -import { - BacklinksNewLostChart, - BacklinksTrendChart, -} from "./BacklinksPageCharts"; +import { SlidersHorizontal } from "lucide-react"; +import type { OnChangeFn, SortingState } from "@tanstack/react-table"; import { BacklinksFilterPanel } from "./BacklinksFilterPanel"; import { BacklinksTable } from "./BacklinksTable"; import { ReferringDomainsTable } from "./ReferringDomainsTable"; import { TopPagesTable } from "./TopPagesTable"; import type { - BacklinksOverviewData, BacklinksSearchState, + BacklinksTabRows, } from "./backlinksPageTypes"; -import { - TAB_DESCRIPTIONS, - formatRelativeTimestamp, -} from "./backlinksPageUtils"; +import { TAB_DESCRIPTIONS } from "./backlinksPageUtils"; import { BacklinksActionsMenu, BacklinksExportMenu, } from "./BacklinksToolbarMenus"; import { buildBacklinksTabExport } from "./export"; -import { - filterBacklinkRows, - filterReferringDomainRows, -} from "./backlinksFiltering"; +import type { BacklinksDomainExpansion } from "./useBacklinksDomainExpansion"; import type { BacklinksFiltersState } from "./useBacklinksFilters"; import { useAhrefsDomainRatings } from "./useAhrefsDomainRatings"; +import { TablePagination } from "@/client/components/table/TablePagination"; +import { + BACKLINKS_PAGE_SIZES, + type BacklinksTab, +} from "@/types/schemas/backlinks"; const BACKLINKS_RESULTS_TABS: Array<{ tab: BacklinksSearchState["tab"]; @@ -39,118 +33,66 @@ const BACKLINKS_RESULTS_TABS: Array<{ { tab: "pages", label: "Top Pages" }, ]; -export function BacklinksOverviewPanels({ - projectId, - data, - summaryStats, -}: { - projectId: string; - data: BacklinksOverviewData; - summaryStats: Array<{ label: string; value: string; description: string }>; -}) { - return ( - <> -
- - - Recent searches - -
-
- {data.scope} - Target: {data.displayTarget} - - - Updated {formatRelativeTimestamp(data.fetchedAt)} -
- - {data.scope === "page" ? ( -
- - Showing backlinks for this exact page. Enter a bare domain for - site-wide results. Trend charts are only shown for domain-level - lookups. - -
- ) : null} - - ); -} - export function BacklinksResultsCard({ projectId, activeTab, - filteredData, + tabRows, filters, + sorting, + view, + domainExpansion, isTabLoading, tabErrorMessage, exportTarget, + pagination, + onPageChange, + onPageSizeChange, + onSortingChange, onTabChange, + onViewChange, }: { projectId: string; activeTab: BacklinksSearchState["tab"]; - filteredData: { - backlinks: BacklinksOverviewData["backlinks"]; - referringDomains: BacklinksOverviewData["referringDomains"]; - topPages: BacklinksOverviewData["topPages"]; - }; + tabRows: BacklinksTabRows; filters: BacklinksFiltersState; + sorting: SortingState; + view: "all" | undefined; + domainExpansion: BacklinksDomainExpansion; isTabLoading: boolean; tabErrorMessage: string | null; exportTarget: string; + pagination: { + page: number; + pageSize: number; + totalCount: number | null; + hasNextPage: boolean; + isFetching: boolean; + }; + onPageChange: (nextPage: number) => void; + onPageSizeChange: (nextPageSize: number) => void; + onSortingChange: OnChangeFn; onTabChange: (tab: BacklinksSearchState["tab"]) => void; + onViewChange: (view: "all" | undefined) => void; }) { const { ratings: domainRatings, isLoading: isLoadingRatings, loadRatings, } = useAhrefsDomainRatings(projectId); - const showAhrefsDrFilter = domainRatings !== null && activeTab !== "pages"; - const currentFilterCount = countVisibleFilters( - filters[activeTab].values, - showAhrefsDrFilter, - ); - const visibleFilteredData = useMemo( - () => ({ - backlinks: filterBacklinkRows( - filteredData.backlinks, - filters.backlinks.values, - domainRatings, - ), - referringDomains: filterReferringDomainRows( - filteredData.referringDomains, - filters.domains.values, - domainRatings, - ), - topPages: filteredData.topPages, - }), - [ - domainRatings, - filteredData.backlinks, - filteredData.referringDomains, - filteredData.topPages, - filters.backlinks.values, - filters.domains.values, - ], - ); + const activeFilterCount = filters[activeTab].activeFilterCount; const exportTable = useMemo( () => - buildBacklinksTabExport({ tab: activeTab, rows: visibleFilteredData }), - [activeTab, visibleFilteredData], + buildBacklinksTabExport({ tab: activeTab, rows: tabRows, domainRatings }), + [activeTab, domainRatings, tabRows], ); - // Domains keyed by both tables that the DR column can enrich. The Referring - // Domains list loads lazily, so this grows once that tab is opened. + // Domains keyed by both tables that the DR column can enrich. Each table + // holds the currently loaded page, so this changes as the user paginates. const ratableDomains = useMemo( - () => collectRatableDomains(visibleFilteredData), - [visibleFilteredData], + () => collectRatableDomains(tabRows), + [tabRows], ); // Once the user has opted in, keep newly loaded domains enriched without a - // re-click (e.g. after switching to the lazily-loaded Referring Domains tab). + // re-click (e.g. after paging or switching to the Referring Domains tab). // KV-cached, so re-requesting already-known domains is nearly free. useEffect(() => { if (!domainRatings) return; @@ -184,7 +126,6 @@ export function BacklinksResultsCard({ @@ -198,7 +139,7 @@ export function BacklinksResultsCard({
-
+
+ {activeTab === "backlinks" ? ( +
+ + +
+ ) : null}
{filters.showFilters ? ( onPageChange(1)} /> ) : null} @@ -229,154 +197,72 @@ export function BacklinksResultsCard({ {tabErrorMessage}
) : null} - {activeTab === "backlinks" ? ( - + {isTabLoading && !tabErrorMessage ? ( + ) : null} - {activeTab === "domains" && isTabLoading && !tabErrorMessage ? ( - - ) : null} - {activeTab === "domains" && !isTabLoading && !tabErrorMessage ? ( - - ) : null} - {activeTab === "pages" && isTabLoading && !tabErrorMessage ? ( - - ) : null} - {activeTab === "pages" && !isTabLoading && !tabErrorMessage ? ( - + {!isTabLoading && !tabErrorMessage ? ( + <> + {activeTab === "backlinks" ? ( + + ) : null} + {activeTab === "domains" ? ( + + ) : null} + {activeTab === "pages" ? ( + + ) : null} + ) : null}
+ + {/* Kept visible on tab errors so a failing page still offers a way back. */} + ); } +const TAB_LOADING_LABELS: Record = { + backlinks: "Loading backlinks", + domains: "Loading referring domains", + pages: "Loading top pages", +}; + /** Unique domains the DR column keys on, from both the backlinks and referring * domains tables, normalized to match how each table renders its domain. */ -function collectRatableDomains(filteredData: { - backlinks: BacklinksOverviewData["backlinks"]; - referringDomains: BacklinksOverviewData["referringDomains"]; -}): string[] { +function collectRatableDomains(tabRows: BacklinksTabRows): string[] { const domains = [ - ...filteredData.backlinks.map((row) => - row.domainFrom?.replace(/^www\./, ""), - ), - ...filteredData.referringDomains.map((row) => row.domain), + ...tabRows.backlinks.map((row) => row.domainFrom?.replace(/^www\./, "")), + ...tabRows.referringDomains.map((row) => row.domain), ]; return [ ...new Set(domains.filter((domain): domain is string => Boolean(domain))), ]; } -function countVisibleFilters( - values: Record, - showAhrefsDrFilter: boolean, -) { - return Object.entries(values).filter(([key, value]) => { - if ( - !showAhrefsDrFilter && - (key === "minAhrefsDr" || key === "maxAhrefsDr") - ) { - return false; - } - return value.trim() !== ""; - }).length; -} - -function OverviewGrid({ - data, - summaryStats, -}: { - data: BacklinksOverviewData; - summaryStats: Array<{ label: string; value: string; description: string }>; -}) { - const domainScope = data.scope === "domain"; - - return ( -
- - {domainScope ? : null} -
- ); -} - -function SummaryStatsGrid({ - data, - summaryStats, -}: { - data: BacklinksOverviewData; - summaryStats: Array<{ label: string; value: string; description: string }>; -}) { - const cardClassName = `card bg-base-100 border border-base-300 ${data.scope === "domain" ? "md:col-span-2 xl:col-span-1" : ""}`; - - return ( -
-
-
- {summaryStats.map((item) => ( -
-
- -
-

{item.value}

-
- ))} -
-
-
- ); -} - -function TrendPanels({ data }: { data: BacklinksOverviewData }) { - return ( - <> - - - - - - - - ); -} - -function TrendCard({ - children, - description, - title, -}: { - children: React.ReactNode; - description: string; - title: string; -}) { - return ( -
-
-
-

{title}

-

{description}

-
- {children} -
-
- ); -} - function TabLink({ activeTab, label, diff --git a/src/client/features/backlinks/BacklinksTable.tsx b/src/client/features/backlinks/BacklinksTable.tsx index e2a7f27..43663e6 100644 --- a/src/client/features/backlinks/BacklinksTable.tsx +++ b/src/client/features/backlinks/BacklinksTable.tsx @@ -1,37 +1,108 @@ import { useMemo } from "react"; +import type { OnChangeFn, SortingState } from "@tanstack/react-table"; import { AppDataTable, useAppTable, } from "@/client/components/table/AppDataTable"; import { EmptyTableState } from "./BacklinksPageEmptyTableState"; -import { buildBacklinksColumns } from "./BacklinksTableColumns"; -import type { BacklinksOverviewData } from "./backlinksPageTypes"; -import { groupBacklinksByDomain } from "./backlinksPageUtils"; +import { + buildBacklinksColumns, + type BacklinksDisplayRow, +} from "./BacklinksTableColumns"; +import type { BacklinksRow } from "./backlinksPageTypes"; +import type { BacklinksDomainExpansion } from "./useBacklinksDomainExpansion"; import type { DomainRatings } from "./useAhrefsDomainRatings"; +/** Interleaves expanded domains' extra links beneath their page row. */ +function buildDisplayRows( + rows: BacklinksRow[], + expansion: BacklinksDomainExpansion | null, +): BacklinksDisplayRow[] { + if (!expansion) { + return rows.map((row) => ({ + kind: "link", + row, + depth: 0, + expandable: false, + expanded: false, + })); + } + + const out: BacklinksDisplayRow[] = []; + for (const row of rows) { + const domain = row.domainFrom; + const expanded = Boolean(domain && expansion.expandedDomains.has(domain)); + out.push({ + kind: "link", + row, + depth: 0, + expandable: Boolean(domain), + expanded, + }); + if (!expanded || !domain) continue; + + const entry = expansion.entriesByDomain[domain]; + if (!entry || entry.status === "loading") { + out.push({ kind: "status", domain, status: "loading" }); + } else if (entry.status === "error") { + out.push({ kind: "status", domain, status: "error" }); + } else { + // The page row already shows the domain's strongest link; list the rest. + const children = entry.rows.filter( + (child) => + !( + child.urlFrom === row.urlFrom && + child.urlTo === row.urlTo && + child.anchor === row.anchor + ), + ); + if (children.length === 0) { + out.push({ kind: "status", domain, status: "empty" }); + } else { + for (const child of children) { + out.push({ + kind: "link", + row: child, + depth: 1, + expandable: false, + expanded: false, + }); + } + } + } + } + return out; +} + export function BacklinksTable({ rows, domainRatings, + sorting, + onSortingChange, + expansion, }: { - rows: BacklinksOverviewData["backlinks"]; + rows: BacklinksRow[]; domainRatings: DomainRatings | null; + sorting: SortingState; + onSortingChange: OnChangeFn; + /** Present in the one-per-domain view; null when listing all links. */ + expansion: BacklinksDomainExpansion | null; }) { - const groupedData = useMemo(() => groupBacklinksByDomain(rows), [rows]); const columns = useMemo( - () => buildBacklinksColumns(domainRatings), - [domainRatings], + () => buildBacklinksColumns(domainRatings, expansion?.toggleDomain), + [domainRatings, expansion?.toggleDomain], + ); + const displayRows = useMemo( + () => buildDisplayRows(rows, expansion), + [rows, expansion], ); const table = useAppTable({ - data: groupedData, + data: displayRows, columns, - initialState: { - sorting: [{ id: "firstSeen", desc: true }], - }, - getSubRows: (row) => row.subRows, - withSorting: true, - withExpanded: true, - getRowCanExpand: (row) => row.depth === 0, + state: { sorting }, + onSortingChange, + manualSorting: true, }); if (rows.length === 0) { @@ -42,13 +113,11 @@ export function BacklinksTable({ ({ - className: - row.depth === 0 - ? "cursor-pointer bg-base-200/50 transition-colors hover:bg-base-200/80" - : "bg-base-100", - onClick: row.depth === 0 ? row.getToggleExpandedHandler() : undefined, - })} + getRowClassName={(row) => + row.original.kind !== "link" || row.original.depth > 0 + ? "bg-base-200/30" + : undefined + } /> ); } diff --git a/src/client/features/backlinks/BacklinksTableColumns.tsx b/src/client/features/backlinks/BacklinksTableColumns.tsx index e615508..ac1df4d 100644 --- a/src/client/features/backlinks/BacklinksTableColumns.tsx +++ b/src/client/features/backlinks/BacklinksTableColumns.tsx @@ -1,14 +1,10 @@ import type { ColumnDef } from "@tanstack/react-table"; import { ChevronRight } from "lucide-react"; import { SortableHeader } from "@/client/components/table/SortableHeader"; -import { - dateNullsLast, - numericNullsLast, - stringNullsLast, -} from "@/client/components/table/nullSafeSort"; import { HeaderHelpLabel } from "@/client/features/keywords/components"; import { BacklinksSourceLink } from "./BacklinksPageLinks"; -import type { BacklinksRow, GroupedBacklinkDomain } from "./backlinksPageTypes"; +import type { BacklinksRow } from "./backlinksPageTypes"; +import type { BacklinksRowsSortField } from "@/types/schemas/backlinks"; import { formatCompactDate, formatDecimal, @@ -16,6 +12,21 @@ import { } from "./backlinksPageUtils"; import type { DomainRatings } from "./useAhrefsDomainRatings"; +/** + * Row model for the backlinks table. In the one-per-domain view, depth-0 rows + * are each domain's strongest link and can expand into the domain's remaining + * links (depth-1) plus a transient status row while they load. + */ +export type BacklinksDisplayRow = + | { + kind: "link"; + row: BacklinksRow; + depth: 0 | 1; + expandable: boolean; + expanded: boolean; + } + | { kind: "status"; domain: string; status: "loading" | "error" | "empty" }; + function BacklinkFlags({ row }: { row: BacklinksRow }) { return (
@@ -39,332 +50,286 @@ function BacklinkFlags({ row }: { row: BacklinksRow }) { ); } -function DomainFlagBadges({ group }: { group: GroupedBacklinkDomain }) { - const badges: Array<{ label: string; className: string }> = []; - if (group.lostCount > 0) { - badges.push({ - label: `${group.lostCount} Lost`, - className: - "badge badge-sm badge-error badge-outline min-w-fit whitespace-nowrap", - }); +function StatusCell({ status }: { status: "loading" | "error" | "empty" }) { + if (status === "loading") { + return ( + + + Loading links… + + ); } - if (group.brokenCount > 0) { - badges.push({ - label: `${group.brokenCount} Broken`, - className: - "badge badge-sm badge-warning badge-outline min-w-fit whitespace-nowrap", - }); - } - if (group.nofollowCount > 0) { - badges.push({ - label: `${group.nofollowCount} Nofollow`, - className: "badge badge-sm badge-outline min-w-fit whitespace-nowrap", - }); - } - if (badges.length === 0) return null; - return ( -
- {badges.map((badge) => ( - - {badge.label} - - ))} + + {status === "error" + ? "Couldn't load this domain's links." + : "No other links from this domain."} + + ); +} + +function SourceCell({ + displayRow, + onToggleDomain, +}: { + displayRow: BacklinksDisplayRow; + onToggleDomain?: (domain: string) => void; +}) { + if (displayRow.kind === "status") { + return ; + } + + const { row, depth, expandable, expanded } = displayRow; + if (depth > 0) { + return ( +
+ {row.urlFrom ? ( + + ) : ( + - + )} +
+ ); + } + + const domainLabel = row.domainFrom?.replace(/^www\./, "") ?? "-"; + return ( +
+ {expandable && row.domainFrom && onToggleDomain ? ( + + ) : null} +
+
{domainLabel}
+ {row.urlFrom ? ( + + ) : null} +
); } -const baseBacklinksColumns: ColumnDef[] = [ - { - id: "source", - accessorKey: "domain", - header: ({ column }) => ( - - ), - size: 250, - minSize: 180, - cell: ({ row }) => { - if (row.depth > 0) { - const child = row.original._backlink; - return ( -
- {child?.urlFrom ? ( - - ) : ( - - - )} -
- ); - } +/** Renders nothing for status rows, the link cell otherwise. */ +function linkCell( + render: (row: BacklinksRow) => React.ReactNode, +): (ctx: { row: { original: BacklinksDisplayRow } }) => React.ReactNode { + return ({ row }) => + row.original.kind === "link" ? render(row.original.row) : null; +} - const group = row.original; - return ( -
- -
-
{group.domain}
-
- {group.backlinkCount}{" "} - {group.backlinkCount === 1 ? "backlink" : "backlinks"} ·{" "} - {group.targetCount} {group.targetCount === 1 ? "page" : "pages"} -
-
+function buildBaseColumns( + onToggleDomain?: (domain: string) => void, +): ColumnDef[] { + // Sortable column ids ("rank", "domainRank", "spamScore", "firstSeen") map + // to server-side sort fields — sorting re-queries DataForSEO across the + // full backlink profile, not just the loaded page. + return [ + { + id: "source", + enableSorting: false, + header: () => ( + + ), + size: 250, + minSize: 180, + cell: ({ row }) => ( + + ), + }, + { + id: "target", + enableSorting: false, + header: () => ( + + ), + size: 220, + minSize: 150, + cell: linkCell((row) => ( +
+ {row.urlTo ? ( + + ) : ( + "-" + )}
- ); + )), }, - sortingFn: stringNullsLast, - }, - { - id: "target", - header: () => ( - - ), - size: 220, - minSize: 150, - enableSorting: false, - cell: ({ row }) => { - if (row.depth > 0) { - const child = row.original._backlink; - return ( -
- {child?.urlTo ? ( - - ) : ( - "-" - )} -
- ); - } - - return null; + { + id: "anchor", + enableSorting: false, + header: () => ( + + ), + size: 150, + minSize: 100, + cell: linkCell((row) => ( +
+ {row.anchor || "No anchor text"} + {row.itemType ? ( +
{row.itemType}
+ ) : null} +
+ )), }, - }, - { - id: "anchor", - header: () => ( - - ), - size: 150, - minSize: 100, - enableSorting: false, - cell: ({ row }) => { - if (row.depth > 0) { - const child = row.original._backlink; - return ( -
- {child?.anchor || "No anchor text"} - {child?.itemType ? ( -
- {child.itemType} -
- ) : null} -
- ); - } - - return null; - }, - }, - { - id: "flags", - header: () => ( - - ), - size: 130, - minSize: 80, - enableSorting: false, - cell: ({ row }) => { - if (row.depth > 0) { - const child = row.original._backlink; - const hasFlags = - child?.isLost || - child?.isBroken || - child?.isDofollow === false || - (child?.linksCount != null && child.linksCount > 1); - return hasFlags && child ? : null; - } - - return ; - }, - }, - { - id: "linkAuthority", - header: () => ( - + { + id: "flags", + enableSorting: false, + header: () => ( + ), + size: 130, + minSize: 80, + cell: linkCell((row) => ), + }, + { + id: "rank" satisfies BacklinksRowsSortField, + accessorFn: (displayRow) => + displayRow.kind === "link" ? displayRow.row.rank : null, + header: ({ column }) => ( + - - ), - size: 70, - minSize: 50, - enableSorting: false, - cell: ({ row }) => { - if (row.depth > 0) { - const child = row.original._backlink; + ), + size: 70, + minSize: 50, + sortDescFirst: true, + cell: linkCell((row) => ( +
+ {formatNumber(row.rank)} +
+ )), + }, + { + id: "domainRank" satisfies BacklinksRowsSortField, + accessorFn: (displayRow) => + displayRow.kind === "link" ? displayRow.row.domainFromRank : null, + header: ({ column }) => ( + + ), + size: 70, + minSize: 50, + sortDescFirst: true, + cell: linkCell((row) => ( +
+ {formatNumber(row.domainFromRank)} +
+ )), + }, + { + id: "spamScore" satisfies BacklinksRowsSortField, + accessorFn: (displayRow) => + displayRow.kind === "link" ? displayRow.row.spamScore : null, + header: ({ column }) => ( + + ), + size: 70, + minSize: 50, + sortDescFirst: true, + cell: linkCell((row) => { + const value = row.spamScore; return (
- - {formatNumber(child?.rank)} - + {value != null && value > 0 ? Math.round(value) : null}
); - } - - return null; + }), }, - }, - { - id: "domainAuthority", - accessorKey: "domainAuthority", - header: ({ column }) => ( - - ), - size: 70, - minSize: 50, - cell: ({ row }) => { - if (row.depth > 0) return null; - - return ( -
- {formatNumber(row.original.domainAuthority)} -
- ); - }, - sortingFn: numericNullsLast, - sortDescFirst: true, - }, - { - id: "spamScore", - accessorKey: "spamScore", - header: ({ column }) => ( - - ), - size: 70, - minSize: 50, - cell: ({ row }) => { - const value = - row.depth > 0 - ? row.original._backlink?.spamScore - : row.original.spamScore; - - return ( -
- {value != null && value > 0 ? Math.round(value) : null} -
- ); - }, - sortingFn: numericNullsLast, - sortDescFirst: true, - }, - { - id: "firstSeen", - accessorKey: "firstSeen", - header: ({ column }) => ( - - ), - size: 110, - minSize: 80, - cell: ({ row }) => { - if (row.depth > 0) { - const child = row.original._backlink; - return ( -
-
{formatCompactDate(child?.firstSeen)}
- {child?.lastSeen ? ( -
- Last {formatCompactDate(child.lastSeen)} -
- ) : null} -
- ); - } - - return ( + { + id: "firstSeen" satisfies BacklinksRowsSortField, + accessorFn: (displayRow) => + displayRow.kind === "link" ? displayRow.row.firstSeen : null, + header: ({ column }) => ( + + ), + size: 110, + minSize: 80, + sortDescFirst: true, + cell: linkCell((row) => (
- {formatCompactDate(row.original.firstSeen)} +
{formatCompactDate(row.firstSeen)}
+ {row.lastSeen ? ( +
+ Last {formatCompactDate(row.lastSeen)} +
+ ) : null}
- ); + )), }, - sortingFn: dateNullsLast, - sortDescFirst: true, - }, -]; + ]; +} /** - * Columns for the grouped backlinks table. When `domainRatings` is provided - * (the user clicked "Ahrefs DR"), an Ahrefs DR column is inserted after DA; - * otherwise it stays hidden. + * Columns for the backlinks table. When `domainRatings` is provided (the user + * clicked "Ahrefs DR"), an Ahrefs DR column is inserted after DA; otherwise it + * stays hidden. DR is loaded client-side from Ahrefs, so it can't participate + * in server-side sorting. */ export function buildBacklinksColumns( domainRatings: DomainRatings | null, -): ColumnDef[] { - if (!domainRatings) return baseBacklinksColumns; + onToggleDomain?: (domain: string) => void, +): ColumnDef[] { + const baseColumns = buildBaseColumns(onToggleDomain); + if (!domainRatings) return baseColumns; const ratings = domainRatings; - const drColumn: ColumnDef = { + const drColumn: ColumnDef = { id: "ahrefsDr", - accessorFn: (row) => ratings[row.domain] ?? null, - header: ({ column }) => ( - + enableSorting: false, + header: () => ( + + + ), size: 90, minSize: 70, - cell: ({ row }) => { - if (row.depth > 0) return null; - const dr = ratings[row.original.domain] ?? null; + cell: linkCell((row) => { + const domain = row.domainFrom?.replace(/^www\./, ""); + const dr = domain ? (ratings[domain] ?? null) : null; return (
{dr == null ? "—" : formatDecimal(dr)}
); - }, - sortingFn: numericNullsLast, - sortDescFirst: true, + }), }; const insertAt = - baseBacklinksColumns.findIndex( - (column) => column.id === "domainAuthority", - ) + 1; + baseColumns.findIndex((column) => column.id === "domainRank") + 1; return [ - ...baseBacklinksColumns.slice(0, insertAt), + ...baseColumns.slice(0, insertAt), drColumn, - ...baseBacklinksColumns.slice(insertAt), + ...baseColumns.slice(insertAt), ]; } diff --git a/src/client/features/backlinks/BacklinksToolbarMenus.tsx b/src/client/features/backlinks/BacklinksToolbarMenus.tsx index e63f9b0..6164702 100644 --- a/src/client/features/backlinks/BacklinksToolbarMenus.tsx +++ b/src/client/features/backlinks/BacklinksToolbarMenus.tsx @@ -8,26 +8,17 @@ import { } from "lucide-react"; import type { CsvValue } from "@/client/lib/csv"; import { exportTableToSheets } from "@/client/lib/exportToSheets"; -import type { - BacklinksOverviewData, - BacklinksSearchState, -} from "./backlinksPageTypes"; +import type { BacklinksSearchState } from "./backlinksPageTypes"; import { exportBacklinksTabCsv } from "./export"; export function BacklinksExportMenu({ activeTab, exportTarget, - filteredData, headers, rows, }: { activeTab: BacklinksSearchState["tab"]; exportTarget: string; - filteredData: { - backlinks: BacklinksOverviewData["backlinks"]; - referringDomains: BacklinksOverviewData["referringDomains"]; - topPages: BacklinksOverviewData["topPages"]; - }; headers: string[]; rows: CsvValue[][]; }) { @@ -86,7 +77,8 @@ export function BacklinksExportMenu({ exportBacklinksTabCsv({ tab: activeTab, target: exportTarget, - rows: filteredData, + headers, + rows, }) } disabled={rows.length === 0} diff --git a/src/client/features/backlinks/ReferringDomainsTable.tsx b/src/client/features/backlinks/ReferringDomainsTable.tsx index 11c2777..a7a8df5 100644 --- a/src/client/features/backlinks/ReferringDomainsTable.tsx +++ b/src/client/features/backlinks/ReferringDomainsTable.tsx @@ -1,24 +1,16 @@ -import { - createColumnHelper, - type SortingFn, - type SortingState, -} from "@tanstack/react-table"; -import { useMemo, useState } from "react"; +import { createColumnHelper } from "@tanstack/react-table"; +import type { OnChangeFn, SortingState } from "@tanstack/react-table"; +import { useMemo } from "react"; import { SafeExternalLink } from "@/client/components/SafeExternalLink"; import { AppDataTable, useAppTable, } from "@/client/components/table/AppDataTable"; import { SortableHeader } from "@/client/components/table/SortableHeader"; -import { - compareNumericNullsLast, - dateNullsLast, - isDescending, - numericNullsLast, - stringNullsLast, -} from "@/client/components/table/nullSafeSort"; +import { HeaderHelpLabel } from "@/client/features/keywords/components"; import { EmptyTableState } from "./BacklinksPageEmptyTableState"; -import type { BacklinksOverviewData } from "./backlinksPageTypes"; +import type { ReferringDomainRow } from "./backlinksPageTypes"; +import type { ReferringDomainsSortField } from "@/types/schemas/backlinks"; import { formatCompactDate, formatDecimal, @@ -26,30 +18,13 @@ import { } from "./backlinksPageUtils"; import type { DomainRatings } from "./useAhrefsDomainRatings"; -type ReferringDomainRow = BacklinksOverviewData["referringDomains"][number]; - const columnHelper = createColumnHelper(); -// Nulls always to the bottom in both directions, same as the pre-TanStack -// implementation. Secondary compare on brokenPages must also keep nulls last — -// coercing to 0 would mix unknown values with real zeroes. -const sortByIssues: SortingFn = (left, right, columnId) => { - const descending = isDescending(left, columnId); - const primary = compareNumericNullsLast( - left.original.brokenBacklinks, - right.original.brokenBacklinks, - descending, - ); - if (primary !== 0) return primary; - return compareNumericNullsLast( - left.original.brokenPages, - right.original.brokenPages, - descending, - ); -}; - +// Column ids map to server-side sort fields; sorting re-queries DataForSEO +// across all referring domains, not just the loaded page. const baseColumns = [ columnHelper.accessor("domain", { + id: "domain" satisfies ReferringDomainsSortField, header: ({ column }) => ( ); }, - sortingFn: stringNullsLast, }), columnHelper.accessor("backlinks", { + id: "backlinks" satisfies ReferringDomainsSortField, header: ({ column }) => ( ), cell: ({ getValue }) => formatNumber(getValue()), - sortingFn: numericNullsLast, sortDescFirst: true, }), columnHelper.accessor("referringPages", { + id: "referringPages" satisfies ReferringDomainsSortField, header: ({ column }) => ( ), cell: ({ getValue }) => formatNumber(getValue()), - sortingFn: numericNullsLast, sortDescFirst: true, }), columnHelper.accessor("rank", { + id: "rank" satisfies ReferringDomainsSortField, header: ({ column }) => ( ), cell: ({ getValue }) => formatNumber(getValue()), - sortingFn: numericNullsLast, sortDescFirst: true, }), columnHelper.accessor("spamScore", { + id: "spamScore" satisfies ReferringDomainsSortField, header: ({ column }) => ( ), cell: ({ getValue }) => formatDecimal(getValue()), - sortingFn: numericNullsLast, sortDescFirst: true, }), columnHelper.accessor("firstSeen", { + id: "firstSeen" satisfies ReferringDomainsSortField, header: ({ column }) => ( ), cell: ({ getValue }) => formatCompactDate(getValue()), - sortingFn: dateNullsLast, sortDescFirst: true, }), - columnHelper.display({ - id: "issues", + columnHelper.accessor("brokenBacklinks", { + id: "brokenBacklinks" satisfies ReferringDomainsSortField, header: ({ column }) => (
), - enableSorting: true, - sortingFn: sortByIssues, sortDescFirst: true, }), ]; @@ -156,36 +128,29 @@ const baseColumns = [ /** * Columns for the referring domains table. When `domainRatings` is provided * (the user clicked "Ahrefs DR"), an Ahrefs DR column is inserted after Rank; - * otherwise it stays hidden. + * otherwise it stays hidden. DR is loaded client-side from Ahrefs, so it can't + * participate in server-side sorting. */ function buildReferringDomainColumns(domainRatings: DomainRatings | null) { if (!domainRatings) return baseColumns; const ratings = domainRatings; - const drColumn = columnHelper.accessor( - (row) => (row.domain ? (ratings[row.domain] ?? null) : null), - { - id: "ahrefsDr", - header: ({ column }) => ( - - ), - cell: ({ getValue }) => { - const dr = getValue(); - return dr == null ? "—" : formatDecimal(dr); - }, - sortingFn: numericNullsLast, - sortDescFirst: true, + const drColumn = columnHelper.display({ + id: "ahrefsDr", + header: () => ( + + ), + cell: ({ row }) => { + const domain = row.original.domain; + const dr = domain ? (ratings[domain] ?? null) : null; + return dr == null ? "—" : formatDecimal(dr); }, - ); + }); - const insertAt = - baseColumns.findIndex( - (column) => "accessorKey" in column && column.accessorKey === "rank", - ) + 1; + const insertAt = baseColumns.findIndex((column) => column.id === "rank") + 1; return [ ...baseColumns.slice(0, insertAt), drColumn, @@ -193,8 +158,6 @@ function buildReferringDomainColumns(domainRatings: DomainRatings | null) { ]; } -const DEFAULT_SORTING: SortingState = [{ id: "backlinks", desc: true }]; - function getDomainWebsiteHref(domain: string) { try { return new URL(domain).toString(); @@ -206,11 +169,14 @@ function getDomainWebsiteHref(domain: string) { export function ReferringDomainsTable({ rows, domainRatings, + sorting, + onSortingChange, }: { - rows: BacklinksOverviewData["referringDomains"]; + rows: ReferringDomainRow[]; domainRatings: DomainRatings | null; + sorting: SortingState; + onSortingChange: OnChangeFn; }) { - const [sorting, setSorting] = useState(DEFAULT_SORTING); const columns = useMemo( () => buildReferringDomainColumns(domainRatings), [domainRatings], @@ -220,8 +186,8 @@ export function ReferringDomainsTable({ data: rows, columns, state: { sorting }, - onSortingChange: setSorting, - withSorting: true, + onSortingChange, + manualSorting: true, }); if (rows.length === 0) { diff --git a/src/client/features/backlinks/TopPagesTable.tsx b/src/client/features/backlinks/TopPagesTable.tsx index e8890e3..56fd268 100644 --- a/src/client/features/backlinks/TopPagesTable.tsx +++ b/src/client/features/backlinks/TopPagesTable.tsx @@ -1,28 +1,27 @@ -import { createColumnHelper, type SortingState } from "@tanstack/react-table"; -import { useState } from "react"; +import { createColumnHelper } from "@tanstack/react-table"; +import type { OnChangeFn, SortingState } from "@tanstack/react-table"; import { SafeExternalLink } from "@/client/components/SafeExternalLink"; import { AppDataTable, useAppTable, } from "@/client/components/table/AppDataTable"; import { SortableHeader } from "@/client/components/table/SortableHeader"; -import { - numericNullsLast, - stringNullsLast, -} from "@/client/components/table/nullSafeSort"; +import { HeaderHelpLabel } from "@/client/features/keywords/components"; import { EmptyTableState } from "./BacklinksPageEmptyTableState"; -import type { BacklinksOverviewData } from "./backlinksPageTypes"; +import type { TopPageRow } from "./backlinksPageTypes"; +import type { TopPagesSortField } from "@/types/schemas/backlinks"; import { formatNumber } from "./backlinksPageUtils"; -type TopPageRow = BacklinksOverviewData["topPages"][number]; - const columnHelper = createColumnHelper(); +// Column ids map to server-side sort fields; sorting re-queries DataForSEO +// across all pages, not just the loaded page of results. const columns = [ columnHelper.accessor("page", { - header: ({ column }) => ( - ( + @@ -39,9 +38,9 @@ const columns = [ "-" ); }, - sortingFn: stringNullsLast, }), columnHelper.accessor("backlinks", { + id: "backlinks" satisfies TopPagesSortField, header: ({ column }) => ( ), cell: ({ getValue }) => formatNumber(getValue()), - sortingFn: numericNullsLast, sortDescFirst: true, }), columnHelper.accessor("referringDomains", { + id: "referringDomains" satisfies TopPagesSortField, header: ({ column }) => ( ), cell: ({ getValue }) => formatNumber(getValue()), - sortingFn: numericNullsLast, sortDescFirst: true, }), columnHelper.accessor("rank", { + id: "rank" satisfies TopPagesSortField, header: ({ column }) => ( ), cell: ({ getValue }) => formatNumber(getValue()), - sortingFn: numericNullsLast, sortDescFirst: true, }), columnHelper.accessor("brokenBacklinks", { + id: "brokenBacklinks" satisfies TopPagesSortField, header: ({ column }) => ( ), cell: ({ getValue }) => formatNumber(getValue()), - sortingFn: numericNullsLast, sortDescFirst: true, }), ]; -const DEFAULT_SORTING: SortingState = [{ id: "backlinks", desc: true }]; - export function TopPagesTable({ rows, + sorting, + onSortingChange, }: { - rows: BacklinksOverviewData["topPages"]; + rows: TopPageRow[]; + sorting: SortingState; + onSortingChange: OnChangeFn; }) { - const [sorting, setSorting] = useState(DEFAULT_SORTING); - const table = useAppTable({ data: rows, columns, state: { sorting }, - onSortingChange: setSorting, - withSorting: true, + onSortingChange, + manualSorting: true, }); if (rows.length === 0) { diff --git a/src/client/features/backlinks/backlinksFilterTypes.ts b/src/client/features/backlinks/backlinksFilterTypes.ts index b621ecf..cf9fdb4 100644 --- a/src/client/features/backlinks/backlinksFilterTypes.ts +++ b/src/client/features/backlinks/backlinksFilterTypes.ts @@ -1,10 +1,14 @@ +import type { + BacklinksRowsFilters, + ReferringDomainsFilters, + TopPagesFilters, +} from "@/types/schemas/backlinks"; + export type BacklinksTabFilterValues = { include: string; exclude: string; minDomainRank: string; maxDomainRank: string; - minAhrefsDr: string; - maxAhrefsDr: string; minLinkAuthority: string; maxLinkAuthority: string; minSpamScore: string; @@ -21,8 +25,6 @@ export type ReferringDomainsFilterValues = { maxBacklinks: string; minRank: string; maxRank: string; - minAhrefsDr: string; - maxAhrefsDr: string; minSpamScore: string; maxSpamScore: string; }; @@ -43,8 +45,6 @@ export const EMPTY_BACKLINKS_FILTERS: BacklinksTabFilterValues = { exclude: "", minDomainRank: "", maxDomainRank: "", - minAhrefsDr: "", - maxAhrefsDr: "", minLinkAuthority: "", maxLinkAuthority: "", minSpamScore: "", @@ -61,8 +61,6 @@ export const EMPTY_REFERRING_DOMAINS_FILTERS: ReferringDomainsFilterValues = { maxBacklinks: "", minRank: "", maxRank: "", - minAhrefsDr: "", - maxAhrefsDr: "", minSpamScore: "", maxSpamScore: "", }; @@ -77,3 +75,116 @@ export const EMPTY_TOP_PAGES_FILTERS: TopPagesFilterValues = { minRank: "", maxRank: "", }; + +export const BACKLINKS_FILTER_FIELDS = [ + "include", + "exclude", + "minDomainRank", + "maxDomainRank", + "minLinkAuthority", + "maxLinkAuthority", + "minSpamScore", + "maxSpamScore", + "linkType", + "hideLost", + "hideBroken", +] as const satisfies ReadonlyArray; +export const REFERRING_DOMAINS_FILTER_FIELDS = [ + "include", + "exclude", + "minBacklinks", + "maxBacklinks", + "minRank", + "maxRank", + "minSpamScore", + "maxSpamScore", +] as const satisfies ReadonlyArray; +export const TOP_PAGES_FILTER_FIELDS = [ + "include", + "exclude", + "minBacklinks", + "maxBacklinks", + "minReferringDomains", + "maxReferringDomains", + "minRank", + "maxRank", +] as const satisfies ReadonlyArray; + +export function countActiveFilters(values: Record): number { + return Object.values(values).filter((v) => v.trim() !== "").length; +} + +/** + * Mirrors how the server translates filters to DataForSEO conditions: each + * include/exclude term is one condition, every other non-empty field is one. + * Used to enforce DataForSEO's per-request condition budget before applying. + */ +export function countFilterConditions(values: Record): number { + let n = 0; + for (const [key, value] of Object.entries(values)) { + if (key === "include" || key === "exclude") { + for (const term of value.split(/[,+]/)) if (term.trim()) n += 1; + continue; + } + if (value.trim() !== "") n += 1; + } + return n; +} + +function toNumberOrUndefined(value: string): number | undefined { + const trimmed = value.trim(); + if (trimmed === "") return undefined; + const parsed = Number(trimmed); + return Number.isFinite(parsed) ? parsed : undefined; +} + +export function toBacklinksFiltersPayload( + values: BacklinksTabFilterValues, +): BacklinksRowsFilters { + return { + include: values.include.trim() || undefined, + exclude: values.exclude.trim() || undefined, + minDomainRank: toNumberOrUndefined(values.minDomainRank), + maxDomainRank: toNumberOrUndefined(values.maxDomainRank), + minLinkAuthority: toNumberOrUndefined(values.minLinkAuthority), + maxLinkAuthority: toNumberOrUndefined(values.maxLinkAuthority), + minSpamScore: toNumberOrUndefined(values.minSpamScore), + maxSpamScore: toNumberOrUndefined(values.maxSpamScore), + linkType: + values.linkType === "dofollow" || values.linkType === "nofollow" + ? values.linkType + : undefined, + hideLost: values.hideLost === "true" ? true : undefined, + hideBroken: values.hideBroken === "true" ? true : undefined, + }; +} + +export function toReferringDomainsFiltersPayload( + values: ReferringDomainsFilterValues, +): ReferringDomainsFilters { + return { + include: values.include.trim() || undefined, + exclude: values.exclude.trim() || undefined, + minBacklinks: toNumberOrUndefined(values.minBacklinks), + maxBacklinks: toNumberOrUndefined(values.maxBacklinks), + minRank: toNumberOrUndefined(values.minRank), + maxRank: toNumberOrUndefined(values.maxRank), + minSpamScore: toNumberOrUndefined(values.minSpamScore), + maxSpamScore: toNumberOrUndefined(values.maxSpamScore), + }; +} + +export function toTopPagesFiltersPayload( + values: TopPagesFilterValues, +): TopPagesFilters { + return { + include: values.include.trim() || undefined, + exclude: values.exclude.trim() || undefined, + minBacklinks: toNumberOrUndefined(values.minBacklinks), + maxBacklinks: toNumberOrUndefined(values.maxBacklinks), + minReferringDomains: toNumberOrUndefined(values.minReferringDomains), + maxReferringDomains: toNumberOrUndefined(values.maxReferringDomains), + minRank: toNumberOrUndefined(values.minRank), + maxRank: toNumberOrUndefined(values.maxRank), + }; +} diff --git a/src/client/features/backlinks/backlinksFiltering.test.ts b/src/client/features/backlinks/backlinksFiltering.test.ts deleted file mode 100644 index 351c389..0000000 --- a/src/client/features/backlinks/backlinksFiltering.test.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { BacklinksOverviewData } from "./backlinksPageTypes"; -import { - EMPTY_BACKLINKS_FILTERS, - EMPTY_REFERRING_DOMAINS_FILTERS, -} from "./backlinksFilterTypes"; -import { - filterBacklinkRows, - filterReferringDomainRows, -} from "./backlinksFiltering"; - -type BacklinkRow = BacklinksOverviewData["backlinks"][number]; -type ReferringDomainRow = BacklinksOverviewData["referringDomains"][number]; - -function makeBacklinkRow(overrides: Partial = {}): BacklinkRow { - return { - urlFrom: "https://example.com/post", - urlTo: "https://target.example/page", - domainFrom: "example.com", - anchor: "Example", - itemType: "anchor", - rank: 10, - domainFromRank: 20, - pageFromRank: 10, - spamScore: 2, - relAttributes: [], - firstSeen: null, - lastSeen: null, - linksCount: 1, - isDofollow: true, - isLost: false, - isBroken: false, - ...overrides, - }; -} - -function makeReferringDomainRow( - overrides: Partial = {}, -): ReferringDomainRow { - return { - domain: "example.com", - backlinks: 10, - referringPages: 5, - rank: 20, - spamScore: 2, - firstSeen: null, - brokenBacklinks: 0, - brokenPages: 0, - ...overrides, - }; -} - -describe("filterBacklinkRows", () => { - it("ignores Ahrefs DR range until ratings are loaded for the backlinks table", () => { - const rows = [ - makeBacklinkRow({ domainFrom: "low.example" }), - makeBacklinkRow({ domainFrom: "high.example" }), - ]; - - expect( - filterBacklinkRows(rows, { - ...EMPTY_BACKLINKS_FILTERS, - minAhrefsDr: "50", - }), - ).toEqual(rows); - }); - - it("filters by loaded Ahrefs DR range for the backlinks table", () => { - const rows = [ - makeBacklinkRow({ domainFrom: "low.example" }), - makeBacklinkRow({ domainFrom: "www.high.example" }), - makeBacklinkRow({ domainFrom: "unknown.example" }), - ]; - const ratings = { - "low.example": 12, - "high.example": 64, - "unknown.example": null, - }; - - expect( - filterBacklinkRows( - rows, - { - ...EMPTY_BACKLINKS_FILTERS, - minAhrefsDr: "50", - }, - ratings, - ), - ).toEqual([rows[1], rows[2]]); - - expect( - filterBacklinkRows( - rows, - { - ...EMPTY_BACKLINKS_FILTERS, - maxAhrefsDr: "50", - }, - ratings, - ), - ).toEqual([rows[0], rows[2]]); - }); -}); - -describe("filterReferringDomainRows", () => { - it("filters by spam score range", () => { - const rows = [ - makeReferringDomainRow({ domain: "clean.example", spamScore: 1 }), - makeReferringDomainRow({ domain: "risky.example", spamScore: 7 }), - makeReferringDomainRow({ domain: "unknown.example", spamScore: null }), - ]; - - expect( - filterReferringDomainRows(rows, { - ...EMPTY_REFERRING_DOMAINS_FILTERS, - maxSpamScore: "3", - }), - ).toEqual([rows[0], rows[2]]); - - expect( - filterReferringDomainRows(rows, { - ...EMPTY_REFERRING_DOMAINS_FILTERS, - minSpamScore: "3", - }), - ).toEqual([rows[1], rows[2]]); - }); - - it("ignores Ahrefs DR range until ratings are loaded for referring domains", () => { - const rows = [ - makeReferringDomainRow({ domain: "low.example" }), - makeReferringDomainRow({ domain: "high.example" }), - ]; - - expect( - filterReferringDomainRows(rows, { - ...EMPTY_REFERRING_DOMAINS_FILTERS, - minAhrefsDr: "50", - }), - ).toEqual(rows); - }); - - it("filters by loaded Ahrefs DR range for referring domains", () => { - const rows = [ - makeReferringDomainRow({ domain: "low.example" }), - makeReferringDomainRow({ domain: "high.example" }), - makeReferringDomainRow({ domain: "unknown.example" }), - ]; - const ratings = { - "low.example": 12, - "high.example": 64, - "unknown.example": null, - }; - - expect( - filterReferringDomainRows( - rows, - { - ...EMPTY_REFERRING_DOMAINS_FILTERS, - minAhrefsDr: "50", - }, - ratings, - ), - ).toEqual([rows[1], rows[2]]); - - expect( - filterReferringDomainRows( - rows, - { - ...EMPTY_REFERRING_DOMAINS_FILTERS, - maxAhrefsDr: "50", - }, - ratings, - ), - ).toEqual([rows[0], rows[2]]); - }); -}); diff --git a/src/client/features/backlinks/backlinksFiltering.ts b/src/client/features/backlinks/backlinksFiltering.ts deleted file mode 100644 index f128035..0000000 --- a/src/client/features/backlinks/backlinksFiltering.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { parseTerms } from "@/client/features/keywords/utils"; -import type { BacklinksOverviewData } from "./backlinksPageTypes"; -import type { - BacklinksTabFilterValues, - ReferringDomainsFilterValues, - TopPagesFilterValues, -} from "./backlinksFilterTypes"; -import type { DomainRatings } from "./useAhrefsDomainRatings"; - -function passesNumericFilter( - value: number | null | undefined, - min: string, - max: string, -): boolean { - if (value == null) return true; - const minN = Number(min); - if (min && !Number.isNaN(minN) && value < minN) return false; - const maxN = Number(max); - if (max && !Number.isNaN(maxN) && value > maxN) return false; - return true; -} - -function passesTextFilter( - haystack: string, - includeTerms: string[], - excludeTerms: string[], -): boolean { - const lower = haystack.toLowerCase(); - if ( - includeTerms.length > 0 && - !includeTerms.some((term) => lower.includes(term)) - ) { - return false; - } - if (excludeTerms.some((term) => lower.includes(term))) { - return false; - } - return true; -} - -export function filterBacklinkRows( - rows: BacklinksOverviewData["backlinks"], - filters: BacklinksTabFilterValues, - domainRatings?: DomainRatings | null, -): BacklinksOverviewData["backlinks"] { - const includeTerms = parseTerms(filters.include); - const excludeTerms = parseTerms(filters.exclude); - - return rows.filter((row) => { - const textFields = [row.domainFrom, row.urlFrom, row.urlTo, row.anchor] - .filter((v): v is string => Boolean(v)) - .join(" "); - - if (!passesTextFilter(textFields, includeTerms, excludeTerms)) return false; - if ( - !passesNumericFilter( - row.domainFromRank, - filters.minDomainRank, - filters.maxDomainRank, - ) - ) - return false; - if ( - domainRatings && - !passesNumericFilter( - row.domainFrom - ? domainRatings[row.domainFrom.replace(/^www\./, "")] - : null, - filters.minAhrefsDr, - filters.maxAhrefsDr, - ) - ) - return false; - if ( - !passesNumericFilter( - row.rank, - filters.minLinkAuthority, - filters.maxLinkAuthority, - ) - ) - return false; - if ( - !passesNumericFilter( - row.spamScore, - filters.minSpamScore, - filters.maxSpamScore, - ) - ) - return false; - - if (filters.linkType === "dofollow" && row.isDofollow !== true) - return false; - if (filters.linkType === "nofollow" && row.isDofollow !== false) - return false; - - if (filters.hideLost === "true" && row.isLost) return false; - if (filters.hideBroken === "true" && row.isBroken) return false; - - return true; - }); -} - -export function filterReferringDomainRows( - rows: BacklinksOverviewData["referringDomains"], - filters: ReferringDomainsFilterValues, - domainRatings?: DomainRatings | null, -): BacklinksOverviewData["referringDomains"] { - const includeTerms = parseTerms(filters.include); - const excludeTerms = parseTerms(filters.exclude); - - return rows.filter((row) => { - if (!passesTextFilter(row.domain ?? "", includeTerms, excludeTerms)) - return false; - if ( - !passesNumericFilter( - row.backlinks, - filters.minBacklinks, - filters.maxBacklinks, - ) - ) - return false; - if (!passesNumericFilter(row.rank, filters.minRank, filters.maxRank)) - return false; - if ( - domainRatings && - !passesNumericFilter( - row.domain ? domainRatings[row.domain] : null, - filters.minAhrefsDr, - filters.maxAhrefsDr, - ) - ) - return false; - if ( - !passesNumericFilter( - row.spamScore, - filters.minSpamScore, - filters.maxSpamScore, - ) - ) - return false; - return true; - }); -} - -export function filterTopPageRows( - rows: BacklinksOverviewData["topPages"], - filters: TopPagesFilterValues, -): BacklinksOverviewData["topPages"] { - const includeTerms = parseTerms(filters.include); - const excludeTerms = parseTerms(filters.exclude); - - return rows.filter((row) => { - if (!passesTextFilter(row.page ?? "", includeTerms, excludeTerms)) - return false; - if ( - !passesNumericFilter( - row.backlinks, - filters.minBacklinks, - filters.maxBacklinks, - ) - ) - return false; - if ( - !passesNumericFilter( - row.referringDomains, - filters.minReferringDomains, - filters.maxReferringDomains, - ) - ) - return false; - if (!passesNumericFilter(row.rank, filters.minRank, filters.maxRank)) - return false; - return true; - }); -} - -export function countActiveFilters(values: Record): number { - return Object.values(values).filter((v) => v.trim() !== "").length; -} diff --git a/src/client/features/backlinks/backlinksPageTypes.ts b/src/client/features/backlinks/backlinksPageTypes.ts index 3c53a9b..78606c8 100644 --- a/src/client/features/backlinks/backlinksPageTypes.ts +++ b/src/client/features/backlinks/backlinksPageTypes.ts @@ -1,16 +1,21 @@ import type { + BacklinksSortOrder, BacklinksTab, BacklinksTargetScope, } from "@/types/schemas/backlinks"; import type { getBacklinksOverview, getBacklinksReferringDomains, + getBacklinksRows, getBacklinksTopPages, } from "@/serverFunctions/backlinks"; export type BacklinksOverviewData = Awaited< ReturnType >; +export type BacklinksRowsPageData = Awaited< + ReturnType +>; export type BacklinksReferringDomainsData = Awaited< ReturnType >; @@ -18,10 +23,21 @@ export type BacklinksTopPagesData = Awaited< ReturnType >; +export type BacklinksRow = BacklinksRowsPageData["rows"][number]; +export type ReferringDomainRow = BacklinksReferringDomainsData["rows"][number]; +export type TopPageRow = BacklinksTopPagesData["rows"][number]; + export type BacklinksSearchState = { target: string; scope: BacklinksTargetScope; tab: BacklinksTab; + page: number; + pageSize: number; + /** Sort column id for the active tab; falls back to the tab's default. */ + sort?: string; + order?: BacklinksSortOrder; + /** Backlinks tab only: "all" lists every link; default is one per domain. */ + view?: "all"; }; export type BacklinksNavigate = (args: { @@ -35,20 +51,9 @@ export type BacklinksPageProps = { navigate: BacklinksNavigate; }; -export type BacklinksRow = BacklinksOverviewData["backlinks"][number]; - -export type GroupedBacklinkDomain = { - domain: string; - domainAuthority: number | null; - spamScore: number | null; - firstSeen: string | null; - backlinkCount: number; - targetCount: number; - lostCount: number; - brokenCount: number; - nofollowCount: number; - /** Child rows for TanStack Table's getSubRows — each wraps a BacklinksRow */ - subRows: GroupedBacklinkDomain[]; - /** Set on child rows only — the original backlink data */ - _backlink?: BacklinksRow; +/** Page rows for all three tabs; tabs that haven't loaded yet are empty. */ +export type BacklinksTabRows = { + backlinks: BacklinksRow[]; + referringDomains: ReferringDomainRow[]; + topPages: TopPageRow[]; }; diff --git a/src/client/features/backlinks/backlinksPageUtils.test.ts b/src/client/features/backlinks/backlinksPageUtils.test.ts deleted file mode 100644 index d9789c2..0000000 --- a/src/client/features/backlinks/backlinksPageUtils.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { BacklinksRow } from "./backlinksPageTypes"; -import { groupBacklinksByDomain } from "./backlinksPageUtils"; - -function makeBacklinkRow(overrides: Partial = {}): BacklinksRow { - return { - domainFrom: "source.example", - urlFrom: "https://source.example/post", - urlTo: "https://target.example/", - anchor: null, - itemType: null, - isDofollow: true, - relAttributes: [], - rank: null, - domainFromRank: null, - pageFromRank: null, - spamScore: null, - firstSeen: null, - lastSeen: null, - isLost: false, - isBroken: false, - linksCount: null, - ...overrides, - }; -} - -describe("groupBacklinksByDomain", () => { - it("sums grouped backlink totals from linksCount", () => { - const groups = groupBacklinksByDomain([ - makeBacklinkRow({ linksCount: 5 }), - makeBacklinkRow({ - urlFrom: "https://source.example/second-post", - urlTo: "https://target.example/pricing", - }), - ]); - - expect(groups).toHaveLength(1); - expect(groups[0]?.backlinkCount).toBe(6); - }); - - it("falls back to one backlink when linksCount is missing", () => { - const groups = groupBacklinksByDomain([ - makeBacklinkRow({ linksCount: null }), - makeBacklinkRow({ - domainFrom: "other.example", - urlFrom: "https://other.example/post", - linksCount: 0, - }), - ]); - - expect(groups.map((group) => group.backlinkCount)).toEqual([1, 1]); - }); -}); diff --git a/src/client/features/backlinks/backlinksPageUtils.ts b/src/client/features/backlinks/backlinksPageUtils.ts index a413ae5..94a3338 100644 --- a/src/client/features/backlinks/backlinksPageUtils.ts +++ b/src/client/features/backlinks/backlinksPageUtils.ts @@ -1,9 +1,5 @@ import type { BacklinksTab } from "@/types/schemas/backlinks"; -import type { - BacklinksOverviewData, - BacklinksRow, - GroupedBacklinkDomain, -} from "./backlinksPageTypes"; +import type { BacklinksOverviewData } from "./backlinksPageTypes"; export const TAB_DESCRIPTIONS: Record = { backlinks: @@ -109,70 +105,6 @@ export function formatRelativeTimestamp(value: string) { }); } -export function groupBacklinksByDomain( - rows: BacklinksRow[], -): GroupedBacklinkDomain[] { - const groups = new Map(); - - for (const row of rows) { - const key = row.domainFrom?.replace(/^www\./, "") ?? "unknown"; - const existing = groups.get(key); - if (existing) { - existing.push(row); - } else { - groups.set(key, [row]); - } - } - - return Array.from(groups.entries()).map(([domain, children]) => ({ - domain, - domainAuthority: maxNullable(children.map((r) => r.domainFromRank)), - spamScore: maxNullable(children.map((r) => r.spamScore)), - firstSeen: minDateString(children.map((r) => r.firstSeen)), - backlinkCount: children.reduce( - (total, child) => total + getBacklinkCount(child), - 0, - ), - targetCount: new Set(children.map((r) => r.urlTo).filter(Boolean)).size, - lostCount: children.filter((r) => r.isLost).length, - brokenCount: children.filter((r) => r.isBroken).length, - nofollowCount: children.filter((r) => r.isDofollow === false).length, - subRows: children.map((child) => ({ - domain: child.domainFrom?.replace(/^www\./, "") ?? "unknown", - domainAuthority: child.domainFromRank, - spamScore: child.spamScore, - firstSeen: child.firstSeen, - backlinkCount: 1, - targetCount: 1, - lostCount: child.isLost ? 1 : 0, - brokenCount: child.isBroken ? 1 : 0, - nofollowCount: child.isDofollow === false ? 1 : 0, - subRows: [], - _backlink: child, - })), - })); -} - -function getBacklinkCount(row: BacklinksRow) { - return row.linksCount != null && row.linksCount > 0 ? row.linksCount : 1; -} - -function maxNullable(values: (number | null)[]): number | null { - let result: number | null = null; - for (const v of values) { - if (v != null && (result == null || v > result)) result = v; - } - return result; -} - -function minDateString(values: (string | null)[]): string | null { - let result: string | null = null; - for (const v of values) { - if (v && (result == null || v < result)) result = v; - } - return result; -} - export function extractUrlPath(url: string) { try { const parsed = new URL(url); diff --git a/src/client/features/backlinks/export.test.ts b/src/client/features/backlinks/export.test.ts index c2f249f..592cf86 100644 --- a/src/client/features/backlinks/export.test.ts +++ b/src/client/features/backlinks/export.test.ts @@ -1,74 +1,134 @@ import { describe, expect, it } from "vitest"; -import { buildBacklinksTabCsvFile } from "./export"; +import { buildCsv } from "@/client/lib/csv"; +import type { BacklinksRow, ReferringDomainRow } from "./backlinksPageTypes"; +import { + buildBacklinksTabCsvFilename, + buildBacklinksTabExport, +} from "./export"; -describe("buildBacklinksTabCsvFile", () => { +function makeBacklinkRow(overrides: Partial = {}): BacklinksRow { + return { + domainFrom: "example.org", + urlFrom: "https://example.org/post", + urlTo: "https://example.com/path", + anchor: "Example", + itemType: "organic", + isDofollow: true, + relAttributes: ["noopener", "noreferrer"], + rank: 123, + domainFromRank: 45, + pageFromRank: 12, + spamScore: 10, + firstSeen: "2025-01-01", + lastSeen: "2025-01-15", + isLost: false, + isBroken: false, + linksCount: 2, + ...overrides, + }; +} + +function makeReferringDomainRow( + overrides: Partial = {}, +): ReferringDomainRow { + return { + domain: "source.com", + backlinks: 12, + referringPages: 7, + rank: 101, + spamScore: 4, + firstSeen: "2024-05-10", + brokenBacklinks: 1, + brokenPages: 0, + ...overrides, + }; +} + +function buildTabCsv( + ...args: Parameters +): string { + const { headers, rows } = buildBacklinksTabExport(...args); + return buildCsv(headers, rows); +} + +describe("buildBacklinksTabCsvFilename", () => { + it("normalizes the target into the filename per tab", () => { + expect( + buildBacklinksTabCsvFilename("backlinks", "https://Example.com/path?q=1"), + ).toBe("backlinks-backlinks-example.com-path-q-1.csv"); + expect(buildBacklinksTabCsvFilename("domains", "Example.com")).toBe( + "backlinks-referring-domains-example.com.csv", + ); + expect(buildBacklinksTabCsvFilename("pages", "docs.example.com")).toBe( + "backlinks-top-pages-docs.example.com.csv", + ); + }); +}); + +describe("buildBacklinksTabExport", () => { it("builds backlinks csv with backlink-specific columns", () => { - const file = buildBacklinksTabCsvFile({ + const content = buildTabCsv({ tab: "backlinks", - target: "https://Example.com/path?q=1", rows: { - backlinks: [ - { - domainFrom: "example.org", - urlFrom: "https://example.org/post", - urlTo: "https://example.com/path", - anchor: "Example", - itemType: "organic", - isDofollow: true, - relAttributes: ["noopener", "noreferrer"], - rank: 123, - domainFromRank: 45, - pageFromRank: 12, - spamScore: 10, - firstSeen: "2025-01-01", - lastSeen: "2025-01-15", - isLost: false, - isBroken: false, - linksCount: 2, - }, - ], + backlinks: [makeBacklinkRow()], referringDomains: [], topPages: [], }, }); - expect(file.filename).toBe("backlinks-backlinks-example.com-path-q-1.csv"); - expect(file.content).toContain('"Domain","Source URL","Target URL"'); - expect(file.content).toContain('"example.org"'); - expect(file.content).toContain('"noopener, noreferrer"'); + expect(content).toContain('"Domain","Source URL","Target URL"'); + expect(content).not.toContain('"Ahrefs DR"'); + expect(content).toContain('"example.org"'); + expect(content).toContain('"noopener, noreferrer"'); }); - it("builds referring domains csv", () => { - const file = buildBacklinksTabCsvFile({ + it("includes an Ahrefs DR column when ratings are loaded", () => { + const content = buildTabCsv({ tab: "domains", - target: "Example.com", + domainRatings: { "source.com": 71.5, "other.com": null }, rows: { backlinks: [], - referringDomains: [ - { - domain: "source.com", - backlinks: 12, - referringPages: 7, - rank: 101, - spamScore: 4, - firstSeen: "2024-05-10", - brokenBacklinks: 1, - brokenPages: 0, - }, - ], + referringDomains: [makeReferringDomainRow()], topPages: [], }, }); - expect(file.filename).toBe("backlinks-referring-domains-example.com.csv"); - expect(file.content).toContain('"Domain","Backlinks","Referring Pages"'); - expect(file.content).toContain('"source.com"'); + expect(content).toContain('"Rank","Ahrefs DR","Spam Score"'); + expect(content).toContain('"71.5"'); + }); + + it("keys backlink Ahrefs DR off the www-stripped source domain", () => { + const content = buildTabCsv({ + tab: "backlinks", + domainRatings: { "example.org": 33 }, + rows: { + backlinks: [makeBacklinkRow({ domainFrom: "www.example.org" })], + referringDomains: [], + topPages: [], + }, + }); + + expect(content).toContain('"Domain Rank","Ahrefs DR","Source Page Rank"'); + expect(content).toContain('"33"'); + }); + + it("builds referring domains csv", () => { + const content = buildTabCsv({ + tab: "domains", + rows: { + backlinks: [], + referringDomains: [makeReferringDomainRow()], + topPages: [], + }, + }); + + expect(content).toContain('"Domain","Backlinks","Referring Pages"'); + expect(content).toContain('"source.com"'); }); it("builds top pages csv", () => { - const file = buildBacklinksTabCsvFile({ + const content = buildTabCsv({ tab: "pages", - target: "docs.example.com", rows: { backlinks: [], referringDomains: [], @@ -84,45 +144,34 @@ describe("buildBacklinksTabCsvFile", () => { }, }); - expect(file.filename).toBe("backlinks-top-pages-docs.example.com.csv"); - expect(file.content).toContain( + expect(content).toContain( '"Page","Backlinks","Referring Domains","Rank","Broken Backlinks"', ); - expect(file.content).toContain('"https://docs.example.com/start"'); + expect(content).toContain('"https://docs.example.com/start"'); }); + it("sanitizes formula-like cell values to prevent CSV injection", () => { - const file = buildBacklinksTabCsvFile({ + const content = buildTabCsv({ tab: "backlinks", - target: "example.com", rows: { backlinks: [ - { + makeBacklinkRow({ domainFrom: "=cmd|' /C calc'!A0", urlFrom: "+https://evil.example/source", urlTo: "@https://evil.example/target", anchor: "\tformula", - itemType: "organic", - isDofollow: true, relAttributes: [], - rank: 1, - domainFromRank: 1, - pageFromRank: 1, - spamScore: 0, - firstSeen: "2025-01-01", - lastSeen: "2025-01-01", - isLost: false, - isBroken: false, linksCount: 1, - }, + }), ], referringDomains: [], topPages: [], }, }); - expect(file.content).toContain("\"'=cmd|' /C calc'!A0\""); - expect(file.content).toContain('"\'+https://evil.example/source"'); - expect(file.content).toContain('"\'@https://evil.example/target"'); - expect(file.content).toContain('"\'\tformula"'); + expect(content).toContain("\"'=cmd|' /C calc'!A0\""); + expect(content).toContain('"\'+https://evil.example/source"'); + expect(content).toContain('"\'@https://evil.example/target"'); + expect(content).toContain('"\'\tformula"'); }); }); diff --git a/src/client/features/backlinks/export.ts b/src/client/features/backlinks/export.ts index 33d231d..0e989ea 100644 --- a/src/client/features/backlinks/export.ts +++ b/src/client/features/backlinks/export.ts @@ -1,20 +1,25 @@ import { buildCsv, type CsvValue, downloadCsv } from "@/client/lib/csv"; import type { - BacklinksOverviewData, BacklinksSearchState, + BacklinksTabRows, } from "./backlinksPageTypes"; +import type { DomainRatings } from "./useAhrefsDomainRatings"; -type BacklinksFilteredData = { - backlinks: BacklinksOverviewData["backlinks"]; - referringDomains: BacklinksOverviewData["referringDomains"]; - topPages: BacklinksOverviewData["topPages"]; -}; - +/** + * Builds the export table for the active tab. When `domainRatings` is loaded + * (the user clicked "Ahrefs DR"), an Ahrefs DR column is included for the + * Backlinks and Referring Domains tabs, matching the on-screen table. + */ export function buildBacklinksTabExport(args: { tab: BacklinksSearchState["tab"]; - rows: BacklinksFilteredData; + rows: BacklinksTabRows; + domainRatings?: DomainRatings | null; }): { headers: string[]; rows: CsvValue[][] } { - const { tab, rows } = args; + const { tab, rows, domainRatings } = args; + const ratingFor = (domain: string | null | undefined): CsvValue => { + if (!domainRatings || !domain) return null; + return domainRatings[domain.replace(/^www\./, "")] ?? null; + }; if (tab === "backlinks") { return { @@ -27,6 +32,7 @@ export function buildBacklinksTabExport(args: { "Dofollow", "Rel Attributes", "Domain Rank", + ...(domainRatings ? ["Ahrefs DR"] : []), "Source Page Rank", "Target Rank", "Spam Score", @@ -45,6 +51,7 @@ export function buildBacklinksTabExport(args: { row.isDofollow, row.relAttributes.join(", "), row.domainFromRank, + ...(domainRatings ? [ratingFor(row.domainFrom)] : []), row.pageFromRank, row.rank, row.spamScore, @@ -64,6 +71,7 @@ export function buildBacklinksTabExport(args: { "Backlinks", "Referring Pages", "Rank", + ...(domainRatings ? ["Ahrefs DR"] : []), "Spam Score", "First Seen", "Broken Backlinks", @@ -74,6 +82,7 @@ export function buildBacklinksTabExport(args: { row.backlinks, row.referringPages, row.rank, + ...(domainRatings ? [ratingFor(row.domain)] : []), row.spamScore, row.firstSeen, row.brokenBacklinks, @@ -100,38 +109,28 @@ export function buildBacklinksTabExport(args: { }; } -export function buildBacklinksTabCsvFile(args: { - tab: BacklinksSearchState["tab"]; - target: string; - rows: BacklinksFilteredData; -}) { - const { headers, rows } = buildBacklinksTabExport({ - tab: args.tab, - rows: args.rows, - }); - const filenamePrefix = - args.tab === "backlinks" - ? "backlinks" - : args.tab === "domains" - ? "referring-domains" - : "top-pages"; - - return { - filename: buildFilename(filenamePrefix, args.target), - content: buildCsv(headers, rows), - }; -} - export function exportBacklinksTabCsv(args: { tab: BacklinksSearchState["tab"]; target: string; - rows: BacklinksFilteredData; + headers: string[]; + rows: CsvValue[][]; }) { - const file = buildBacklinksTabCsvFile(args); - downloadCsv(file.filename, file.content); + downloadCsv( + buildBacklinksTabCsvFilename(args.tab, args.target), + buildCsv(args.headers, args.rows), + ); } -function buildFilename(tabPrefix: string, target: string) { +export function buildBacklinksTabCsvFilename( + tab: BacklinksSearchState["tab"], + target: string, +) { + const tabPrefix = + tab === "backlinks" + ? "backlinks" + : tab === "domains" + ? "referring-domains" + : "top-pages"; const normalizedTarget = target .toLowerCase() .trim() diff --git a/src/client/features/backlinks/useBacklinksDomainExpansion.ts b/src/client/features/backlinks/useBacklinksDomainExpansion.ts new file mode 100644 index 0000000..42763e8 --- /dev/null +++ b/src/client/features/backlinks/useBacklinksDomainExpansion.ts @@ -0,0 +1,93 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useQueries } from "@tanstack/react-query"; +import { getBacklinksRows } from "@/serverFunctions/backlinks"; +import type { BacklinksRow, BacklinksSearchState } from "./backlinksPageTypes"; + +const DOMAIN_LINKS_PAGE_SIZE = 100; +const DOMAIN_LINKS_STALE_TIME_MS = 5 * 60 * 1000; + +export type BacklinksDomainEntry = + | { status: "loading" } + | { status: "error" } + | { status: "ready"; rows: BacklinksRow[] }; + +export type BacklinksDomainExpansion = { + expandedDomains: ReadonlySet; + /** One entry per expanded domain; keyed by the row's raw domainFrom. */ + entriesByDomain: Record; + toggleDomain: (domain: string) => void; +}; + +/** + * Lazily loads the full link list for referring domains the user expands in + * the one-per-domain backlinks view. Each expansion is one billed DataForSEO + * request (capped at 100 links), cached client-side and in R2. + */ +export function useBacklinksDomainExpansion({ + projectId, + searchState, +}: { + projectId: string; + searchState: BacklinksSearchState; +}): BacklinksDomainExpansion { + const { target, scope } = searchState; + const [expanded, setExpanded] = useState([]); + + // Collapse everything when the lookup changes. + useEffect(() => { + setExpanded([]); + }, [projectId, target, scope]); + + const queries = useQueries({ + queries: expanded.map((domain) => ({ + queryKey: [ + "backlinksDomainLinks", + projectId, + scope, + target, + domain, + ] as const, + staleTime: DOMAIN_LINKS_STALE_TIME_MS, + queryFn: () => + getBacklinksRows({ + data: { + projectId, + target, + scope, + page: 1, + pageSize: DOMAIN_LINKS_PAGE_SIZE, + sortField: "rank", + sortOrder: "desc", + filters: { domainFrom: domain }, + mode: "as_is", + }, + }), + })), + }); + + const entriesByDomain = useMemo(() => { + const map: Record = {}; + expanded.forEach((domain, index) => { + const query = queries[index]; + if (!query) return; + map[domain] = query.data + ? { status: "ready", rows: query.data.rows } + : query.error + ? { status: "error" } + : { status: "loading" }; + }); + return map; + }, [expanded, queries]); + + const expandedDomains = useMemo(() => new Set(expanded), [expanded]); + + const toggleDomain = useCallback((domain: string) => { + setExpanded((current) => + current.includes(domain) + ? current.filter((entry) => entry !== domain) + : [...current, domain], + ); + }, []); + + return { expandedDomains, entriesByDomain, toggleDomain }; +} diff --git a/src/client/features/backlinks/useBacklinksFilters.ts b/src/client/features/backlinks/useBacklinksFilters.ts index 479d8b6..442f3cf 100644 --- a/src/client/features/backlinks/useBacklinksFilters.ts +++ b/src/client/features/backlinks/useBacklinksFilters.ts @@ -1,14 +1,15 @@ -import { useCallback, useEffect, useState } from "react"; -import { useForm, useStore } from "@tanstack/react-form"; +import { useCallback, useState } from "react"; +import { MAX_DATAFORSEO_FILTER_CONDITIONS } from "@/types/schemas/domain"; import { EMPTY_BACKLINKS_FILTERS, EMPTY_REFERRING_DOMAINS_FILTERS, EMPTY_TOP_PAGES_FILTERS, + countActiveFilters, + countFilterConditions, type BacklinksTabFilterValues, type ReferringDomainsFilterValues, type TopPagesFilterValues, } from "./backlinksFilterTypes"; -import { countActiveFilters } from "./backlinksFiltering"; const STORAGE_KEY_PREFIX = "backlinks-filters:"; @@ -36,6 +37,13 @@ function loadFromStorage(tab: string, fallback: T): T { } } + // Filters persisted before the server-side-filtering change had no + // condition budget; values over the DataForSEO cap would fail every + // query on load, so start fresh instead. + if (countFilterConditions(result) > MAX_DATAFORSEO_FILTER_CONDITIONS) { + return fallbackClone; + } + return result; } catch { return fallbackClone; @@ -50,24 +58,30 @@ function saveToStorage(tab: string, values: FilterValues) { } } +/** + * Holds the *applied* filters for one tab. Draft edits live inside the filter + * panel; values here are what the server queries use, persisted per tab. + */ function useTabFilters(tab: string, emptyValues: T) { - const [defaultValues] = useState(() => + const [values, setValues] = useState(() => loadFromStorage(tab, { ...emptyValues }), ); - const form = useForm({ defaultValues }); - const values = useStore(form.store, (state) => state.values); - useEffect(() => { - saveToStorage(tab, values); - }, [tab, values]); + const apply = useCallback( + (next: T) => { + setValues(next); + saveToStorage(tab, next); + }, + [tab], + ); const reset = useCallback(() => { - form.reset({ ...emptyValues }, { keepDefaultValues: true }); - }, [emptyValues, form]); + apply({ ...emptyValues }); + }, [apply, emptyValues]); return { - form, values, + apply, reset, activeFilterCount: countActiveFilters(values), }; diff --git a/src/client/features/backlinks/useBacklinksPageData.ts b/src/client/features/backlinks/useBacklinksPageData.ts index d06a701..23a384a 100644 --- a/src/client/features/backlinks/useBacklinksPageData.ts +++ b/src/client/features/backlinks/useBacklinksPageData.ts @@ -12,16 +12,35 @@ import { import { getBacklinksOverview, getBacklinksReferringDomains, + getBacklinksRows, getBacklinksTopPages, } from "@/serverFunctions/backlinks"; import { getBacklinksAccessSetupStatus } from "@/serverFunctions/backlinksAccess"; +import { + BACKLINKS_DEFAULT_SORT, + backlinksRowsSortFieldSchema, + referringDomainsSortFieldSchema, + topPagesSortFieldSchema, + type BacklinksSortOrder, +} from "@/types/schemas/backlinks"; +import { + toBacklinksFiltersPayload, + toReferringDomainsFiltersPayload, + toTopPagesFiltersPayload, +} from "./backlinksFilterTypes"; +import type { BacklinksFiltersState } from "./useBacklinksFilters"; import { getPersistedBacklinksSearchScope } from "./backlinksSearchScope"; type UseBacklinksPageDataArgs = { projectId: string; searchState: BacklinksSearchState; + filters: BacklinksFiltersState; }; +// Five-minute client staleness on top of the server's 6h R2 cache, so window +// refocus doesn't re-run the server functions for bytes that can't change. +const BACKLINKS_QUERY_STALE_TIME_MS = 5 * 60 * 1000; + function getBacklinksErrorMessage( error: unknown, fallback: string, @@ -34,9 +53,28 @@ function getBacklinksErrorMessage( return getStandardErrorMessage(error, fallback); } +/** + * Maps the URL's sort/order params to a request's sortField/sortOrder pair. + * The sort param is checked against the tab's allowed sort fields; anything + * unexpected falls back to the tab's default sort. + */ +function toSort( + sortParam: string | undefined, + orderParam: BacklinksSortOrder | undefined, + allowedFields: readonly T[], + fallback: { field: T; order: BacklinksSortOrder }, +): { field: T; order: BacklinksSortOrder } { + const field = sortParam + ? allowedFields.find((candidate) => candidate === sortParam) + : undefined; + if (!field) return fallback; + return { field, order: orderParam ?? "desc" }; +} + export function useBacklinksPageData({ projectId, searchState, + filters, }: UseBacklinksPageDataArgs) { const accessGate = useAccessGate({ queryKey: ["backlinksAccessStatus", projectId], @@ -45,7 +83,6 @@ export function useBacklinksPageData({ }); const backlinksEnabled = accessGate.enabled; const retryAccessGate = accessGate.onRetry; - const requestInput = buildBacklinksRequestInput(projectId, searchState); const searchCardInitialValues = useMemo( () => ({ target: searchState.target, @@ -54,33 +91,118 @@ export function useBacklinksPageData({ [searchState.scope, searchState.target], ); - const baseQueryKeyParts = [ - projectId, - searchState.scope, - searchState.target, - ] as const; + const { target, scope, tab, page, pageSize, sort, order, view } = searchState; + const rowsMode = view === "all" ? "as_is" : "one_per_domain"; + const targetReady = backlinksEnabled && Boolean(target); + const baseQueryKeyParts = [projectId, scope, target] as const; + const pageInputBase = { projectId, target, scope, page, pageSize }; + const overviewQuery = useQuery({ queryKey: ["backlinksOverview", ...baseQueryKeyParts], - enabled: backlinksEnabled && Boolean(searchState.target), - queryFn: () => getBacklinksOverview({ data: requestInput }), + enabled: targetReady, + staleTime: BACKLINKS_QUERY_STALE_TIME_MS, + queryFn: () => getBacklinksOverview({ data: { projectId, target, scope } }), }); + const rowsSort = toSort( + sort, + order, + backlinksRowsSortFieldSchema.options, + BACKLINKS_DEFAULT_SORT.backlinks, + ); + const rowsFilters = useMemo( + () => toBacklinksFiltersPayload(filters.backlinks.values), + [filters.backlinks.values], + ); + const rowsQuery = useQuery({ + queryKey: [ + "backlinksRows", + ...baseQueryKeyParts, + page, + pageSize, + rowsSort.field, + rowsSort.order, + rowsFilters, + rowsMode, + ], + enabled: targetReady && tab === "backlinks", + staleTime: BACKLINKS_QUERY_STALE_TIME_MS, + queryFn: () => + getBacklinksRows({ + data: { + ...pageInputBase, + sortField: rowsSort.field, + sortOrder: rowsSort.order, + filters: rowsFilters, + mode: rowsMode, + }, + }), + }); + + const domainsSort = toSort( + sort, + order, + referringDomainsSortFieldSchema.options, + BACKLINKS_DEFAULT_SORT.domains, + ); + const domainsFilters = useMemo( + () => toReferringDomainsFiltersPayload(filters.domains.values), + [filters.domains.values], + ); const referringDomainsQuery = useQuery({ - queryKey: ["backlinksReferringDomains", ...baseQueryKeyParts], - enabled: - backlinksEnabled && - Boolean(searchState.target) && - searchState.tab === "domains", - queryFn: () => getBacklinksReferringDomains({ data: requestInput }), + queryKey: [ + "backlinksReferringDomains", + ...baseQueryKeyParts, + page, + pageSize, + domainsSort.field, + domainsSort.order, + domainsFilters, + ], + enabled: targetReady && tab === "domains", + staleTime: BACKLINKS_QUERY_STALE_TIME_MS, + queryFn: () => + getBacklinksReferringDomains({ + data: { + ...pageInputBase, + sortField: domainsSort.field, + sortOrder: domainsSort.order, + filters: domainsFilters, + }, + }), }); + const pagesSort = toSort( + sort, + order, + topPagesSortFieldSchema.options, + BACKLINKS_DEFAULT_SORT.pages, + ); + const pagesFilters = useMemo( + () => toTopPagesFiltersPayload(filters.pages.values), + [filters.pages.values], + ); const topPagesQuery = useQuery({ - queryKey: ["backlinksTopPages", ...baseQueryKeyParts], - enabled: - backlinksEnabled && - Boolean(searchState.target) && - searchState.tab === "pages", - queryFn: () => getBacklinksTopPages({ data: requestInput }), + queryKey: [ + "backlinksTopPages", + ...baseQueryKeyParts, + page, + pageSize, + pagesSort.field, + pagesSort.order, + pagesFilters, + ], + enabled: targetReady && tab === "pages", + staleTime: BACKLINKS_QUERY_STALE_TIME_MS, + queryFn: () => + getBacklinksTopPages({ + data: { + ...pageInputBase, + sortField: pagesSort.field, + sortOrder: pagesSort.order, + filters: pagesFilters, + }, + }), }); const overviewErrorMessage = getBacklinksErrorMessage( @@ -89,17 +211,18 @@ export function useBacklinksPageData({ ); const backlinksDisabledByError = getErrorCode(overviewQuery.error) === "BACKLINKS_NOT_ENABLED"; - const activeTabError = getActiveTabError( - searchState, - referringDomainsQuery.error, - topPagesQuery.error, - ); + const activeTabQuery = + tab === "backlinks" + ? rowsQuery + : tab === "domains" + ? referringDomainsQuery + : topPagesQuery; const activeTabErrorMessage = getBacklinksErrorMessage( - activeTabError, + activeTabQuery.error, "Could not load this tab.", ); const backlinksDisabledByTabError = - getErrorCode(activeTabError) === "BACKLINKS_NOT_ENABLED"; + getErrorCode(activeTabQuery.error) === "BACKLINKS_NOT_ENABLED"; useEffect(() => { if ( @@ -118,10 +241,12 @@ export function useBacklinksPageData({ return { accessGate, activeTabErrorMessage, + activeTabQuery, backlinksDisabledByError, overviewErrorMessage, overviewQuery, referringDomainsQuery, + rowsQuery, searchCardInitialValues, topPagesQuery, }; @@ -137,39 +262,10 @@ export function navigateToBacklinksSearch( target: values.target, scope: getPersistedBacklinksSearchScope(values.target, values.scope), tab: undefined, + page: undefined, + sort: undefined, + order: undefined, }), replace: true, }); } - -function buildBacklinksRequestInput( - projectId: string, - searchState: BacklinksSearchState, -) { - return { - projectId, - target: searchState.target, - scope: searchState.scope, - // Server-side spam filtering (hideSpam/spamThreshold) is available but - // intentionally disabled. All filtering — including spam score — is applied - // client-side so users get immediate feedback without re-fetching. This - // trades slightly larger API responses for simpler code and flexibility. - hideSpam: false, - }; -} - -function getActiveTabError( - searchState: BacklinksSearchState, - referringDomainsError: unknown, - topPagesError: unknown, -) { - if (searchState.tab === "domains") { - return referringDomainsError; - } - - if (searchState.tab === "pages") { - return topPagesError; - } - - return null; -} diff --git a/src/client/features/domain/components/DomainFilterPanel.tsx b/src/client/features/domain/components/DomainFilterPanel.tsx index c733e22..5f705fb 100644 --- a/src/client/features/domain/components/DomainFilterPanel.tsx +++ b/src/client/features/domain/components/DomainFilterPanel.tsx @@ -1,4 +1,10 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { + useCallback, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; import { AlertTriangle, RotateCcw } from "lucide-react"; import { FilterNumberInput, @@ -36,6 +42,11 @@ type Props = { countConditions: (values: TValues) => number; onApply: (values: TValues) => void; onClear: () => void; + /** Extra feature-specific controls (toggles etc.) bound to the draft. */ + renderExtra?: ( + draft: TValues, + setValue: (key: keyof TValues, value: string) => void, + ) => ReactNode; }; export function DomainFilterPanel({ @@ -48,6 +59,7 @@ export function DomainFilterPanel({ countConditions, onApply, onClear, + renderExtra, }: Props) { const appliedKey = useMemo( () => fields.map((key) => appliedFilters[key]).join("|"), @@ -97,10 +109,19 @@ export function DomainFilterPanel({ }, [appliedFilters, debugName]); const resetFilters = useCallback(() => { debugDomain(`${debugName}:clear`); + // Also clear unapplied draft edits — when the applied filters are already + // empty, the applied-sync effect won't fire (appliedKey is unchanged). + setDraftFilters((current) => { + const next = { ...current }; + for (const key of fields) Object.assign(next, { [key]: "" }); + return next; + }); onClear(); - }, [debugName, onClear]); + }, [debugName, fields, onClear]); const handleKeyDown = (event: React.KeyboardEvent) => { if (event.key !== "Enter") return; + // Let buttons (Cancel, toggles) handle their own Enter activation. + if (event.target instanceof HTMLButtonElement) return; if (meta.overLimit) return; event.preventDefault(); applyFilters(); @@ -177,6 +198,8 @@ export function DomainFilterPanel({ ))}
+ {renderExtra ? renderExtra(draftFilters, handleValueChange) : null} + {meta.overLimit ? (
diff --git a/src/routes/_project/p/$projectId/backlinks.tsx b/src/routes/_project/p/$projectId/backlinks.tsx index 3bceb25..7581577 100644 --- a/src/routes/_project/p/$projectId/backlinks.tsx +++ b/src/routes/_project/p/$projectId/backlinks.tsx @@ -1,7 +1,10 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { BacklinksPage } from "@/client/features/backlinks/BacklinksPage"; import { inferBacklinksSearchScopeFromTarget } from "@/client/features/backlinks/backlinksSearchScope"; -import { backlinksSearchSchema } from "@/types/schemas/backlinks"; +import { + DEFAULT_BACKLINKS_PAGE_SIZE, + backlinksSearchSchema, +} from "@/types/schemas/backlinks"; export const Route = createFileRoute("/_project/p/$projectId/backlinks")({ validateSearch: backlinksSearchSchema, @@ -11,7 +14,16 @@ export const Route = createFileRoute("/_project/p/$projectId/backlinks")({ function BacklinksRoute() { const { projectId } = Route.useParams(); const navigate = useNavigate({ from: Route.fullPath }); - const { target = "", scope: rawScope, tab = "backlinks" } = Route.useSearch(); + const { + target = "", + scope: rawScope, + tab = "backlinks", + page = 1, + size = DEFAULT_BACKLINKS_PAGE_SIZE, + sort, + order, + view, + } = Route.useSearch(); const scope = rawScope ?? inferBacklinksSearchScopeFromTarget(target); return ( @@ -22,6 +34,11 @@ function BacklinksRoute() { target, scope, tab, + page, + pageSize: size, + sort, + order, + view, }} /> ); diff --git a/src/server/features/backlinks/services/BacklinksService.billing.test.ts b/src/server/features/backlinks/services/BacklinksService.billing.test.ts index 0d87779..3bf8518 100644 --- a/src/server/features/backlinks/services/BacklinksService.billing.test.ts +++ b/src/server/features/backlinks/services/BacklinksService.billing.test.ts @@ -37,6 +37,15 @@ const billingCustomer = { userEmail: "team@example.com", }; +const pageInputDefaults = { + projectId: "project_123", + page: 1, + pageSize: 100, + sortOrder: "desc", + filters: {}, + mode: "as_is", +} as const; + const cache = new Map(); const service = createBacklinksService({ async get(key) { @@ -53,7 +62,7 @@ beforeEach(() => { vi.clearAllMocks(); }); -it("profiles only the initial overview calls and reuses cache on repeat", async () => { +it("profiles only the summary and history for the overview and reuses cache on repeat", async () => { vi.mocked(normalizeBacklinksTarget).mockReturnValue({ apiTarget: "example.com", displayTarget: "example.com", @@ -73,27 +82,6 @@ it("profiles only the initial overview calls and reuses cache on repeat", async new_referring_domains: 8, lost_referring_domains: 2, }); - backlinksRowsMock.mockResolvedValue([ - { - domain_from: "source.example", - url_from: "https://source.example/post", - url_to: "https://example.com/", - anchor: "Example", - item_type: "content", - dofollow: true, - rank: 77, - domain_from_rank: 65, - page_from_rank: 54, - backlink_spam_score: 3, - first_seen: "2026-01-01", - last_visited: "2026-03-01", - lost_date: null, - is_lost: false, - is_broken: false, - links_count: 1, - rel_attributes: ["noopener"], - }, - ]); backlinksHistoryMock.mockResolvedValue([ { date: "2026-02-01", @@ -116,8 +104,9 @@ it("profiles only the initial overview calls and reuses cache on repeat", async billingCustomer, ); - expect(first.overview.referringDomains).toEqual([]); - expect(first.overview.topPages).toEqual([]); + expect(first.overview.summary.backlinks).toBe(1200); + expect(first.overview.trends).toHaveLength(1); + expect(backlinksRowsMock).not.toHaveBeenCalled(); expect(referringDomainsMock).not.toHaveBeenCalled(); expect(domainPagesMock).not.toHaveBeenCalled(); expect(backlinksSummaryMock).toHaveBeenCalledOnce(); @@ -125,46 +114,157 @@ it("profiles only the initial overview calls and reuses cache on repeat", async expect(second).toEqual(first); }); -it("profiles referring domains and top pages separately", async () => { +it("profiles backlink rows per page with offset and total count", async () => { + vi.mocked(normalizeBacklinksTarget).mockReturnValue({ + apiTarget: "example.com", + displayTarget: "example.com", + scope: "domain", + }); + backlinksRowsMock.mockResolvedValue({ + items: [ + { + domain_from: "source.example", + url_from: "https://source.example/post", + url_to: "https://example.com/", + anchor: "Example", + item_type: "content", + dofollow: true, + rank: 77, + domain_from_rank: 65, + page_from_rank: 54, + backlink_spam_score: 3, + first_seen: "2026-01-01", + last_visited: "2026-03-01", + lost_date: null, + is_lost: false, + is_broken: false, + links_count: 1, + rel_attributes: ["noopener"], + }, + ], + totalCount: 450, + }); + + const result = await service.profileBacklinksPage( + { + ...pageInputDefaults, + target: "example.com", + page: 2, + sortField: "rank", + }, + billingCustomer, + { hideSpam: false }, + ); + + expect(backlinksRowsMock).toHaveBeenCalledWith( + expect.objectContaining({ + target: "example.com", + limit: 100, + offset: 100, + orderBy: ["rank,desc"], + hideSpam: false, + }), + ); + expect(result.rows).toHaveLength(1); + expect(result.totalCount).toBe(450); + expect(result.hasMore).toBe(true); + expect(result.page).toBe(2); +}); + +it("translates filters into DataForSEO conditions for backlink rows", async () => { + vi.mocked(normalizeBacklinksTarget).mockReturnValue({ + apiTarget: "example.com", + displayTarget: "example.com", + scope: "domain", + }); + backlinksRowsMock.mockResolvedValue({ items: [], totalCount: 0 }); + + await service.profileBacklinksPage( + { + ...pageInputDefaults, + target: "example.com", + sortField: "rank", + filters: { + include: "blog", + minDomainRank: 30, + linkType: "dofollow", + hideLost: true, + }, + }, + billingCustomer, + { hideSpam: false }, + ); + + expect(backlinksRowsMock).toHaveBeenCalledWith( + expect.objectContaining({ + filters: [ + ["url_from", "ilike", "%blog%"], + "and", + ["domain_from_rank", ">=", 30], + "and", + ["dofollow", "=", true], + "and", + ["is_lost", "=", false], + ], + }), + ); +}); + +it("profiles referring domains and top pages pages separately", async () => { vi.mocked(normalizeBacklinksTarget).mockReturnValue({ apiTarget: "https://example.com/foo", displayTarget: "https://example.com/foo", scope: "page", }); - referringDomainsMock.mockResolvedValue([ - { - domain: "source.example", - backlinks: 4, - referring_pages: 2, - rank: 65, - first_seen: "2026-01-01", - broken_backlinks: 0, - broken_pages: 0, - backlinks_spam_score: 2, - target_spam_score: 4, - }, - ]); - domainPagesMock.mockResolvedValue([ - { - page: "https://example.com/foo", - backlinks: 100, - referring_domains: 20, - rank: 50, - broken_backlinks: 0, - }, - ]); + referringDomainsMock.mockResolvedValue({ + items: [ + { + domain: "source.example", + backlinks: 4, + referring_pages: 2, + rank: 65, + first_seen: "2026-01-01", + broken_backlinks: 0, + broken_pages: 0, + backlinks_spam_score: 2, + target_spam_score: 4, + }, + ], + totalCount: 1, + }); + domainPagesMock.mockResolvedValue({ + items: [ + { + page: "https://example.com/foo", + backlinks: 100, + referring_domains: 20, + rank: 50, + broken_backlinks: 0, + }, + ], + totalCount: 1, + }); - const domains = await service.profileReferringDomains( - { target: "https://example.com/foo" }, + const domains = await service.profileReferringDomainsPage( + { + ...pageInputDefaults, + target: "https://example.com/foo", + sortField: "backlinks", + }, billingCustomer, ); - const pages = await service.profileTopPages( - { target: "https://example.com/foo" }, + const pages = await service.profileTopPagesPage( + { + ...pageInputDefaults, + target: "https://example.com/foo", + sortField: "backlinks", + }, billingCustomer, ); expect(domains.rows).toHaveLength(1); expect(domains.rows[0]?.spamScore).toBe(2); + expect(domains.hasMore).toBe(false); expect(pages.rows).toHaveLength(1); }); @@ -174,22 +274,29 @@ it("does not fall back to target spam score for referring domains", async () => displayTarget: "example.com", scope: "domain", }); - referringDomainsMock.mockResolvedValue([ - { - domain: "source.example", - backlinks: 4, - referring_pages: 2, - rank: 65, - first_seen: "2026-01-01", - broken_backlinks: 0, - broken_pages: 0, - backlinks_spam_score: null, - target_spam_score: 4, - }, - ]); + referringDomainsMock.mockResolvedValue({ + items: [ + { + domain: "source.example", + backlinks: 4, + referring_pages: 2, + rank: 65, + first_seen: "2026-01-01", + broken_backlinks: 0, + broken_pages: 0, + backlinks_spam_score: null, + target_spam_score: 4, + }, + ], + totalCount: 1, + }); - const domains = await service.profileReferringDomains( - { target: "example.com" }, + const domains = await service.profileReferringDomainsPage( + { + ...pageInputDefaults, + target: "example.com", + sortField: "backlinks", + }, billingCustomer, ); @@ -197,39 +304,33 @@ it("does not fall back to target spam score for referring domains", async () => expect(domains.rows[0]?.spamScore).toBeNull(); }); -it("keeps cache entries isolated per organization", async () => { +it("keeps page cache entries isolated per page and per organization", async () => { vi.mocked(normalizeBacklinksTarget).mockReturnValue({ apiTarget: "example.com", displayTarget: "example.com", scope: "domain", }); - backlinksSummaryMock.mockResolvedValue({ - rank: 42, - backlinks: 1200, - referring_pages: 900, - referring_domains: 320, - broken_backlinks: 12, - broken_pages: 3, - backlinks_spam_score: 5, - info: { target_spam_score: 4 }, - new_backlinks: 25, - lost_backlinks: 10, - new_referring_domains: 8, - lost_referring_domains: 2, - }); - backlinksRowsMock.mockResolvedValue([]); - backlinksHistoryMock.mockResolvedValue([]); + backlinksRowsMock.mockResolvedValue({ items: [], totalCount: 0 }); - const input = { target: "example.com" }; + const input = { + ...pageInputDefaults, + target: "example.com", + sortField: "rank", + } as const; - await service.profileOverview(input, billingCustomer); - await service.profileOverview(input, { + await service.profileBacklinksPage(input, billingCustomer); + await service.profileBacklinksPage(input, billingCustomer); + expect(backlinksRowsMock).toHaveBeenCalledTimes(1); + + await service.profileBacklinksPage({ ...input, page: 2 }, billingCustomer); + expect(backlinksRowsMock).toHaveBeenCalledTimes(2); + + await service.profileBacklinksPage(input, { organizationId: "org_456", userId: "user_456", userEmail: "other@example.com", }); - - expect(backlinksSummaryMock).toHaveBeenCalledTimes(2); + expect(backlinksRowsMock).toHaveBeenCalledTimes(3); }); function parseCachedValue(raw: string): unknown { diff --git a/src/server/features/backlinks/services/BacklinksService.ts b/src/server/features/backlinks/services/BacklinksService.ts index 37d40c9..5bdb604 100644 --- a/src/server/features/backlinks/services/BacklinksService.ts +++ b/src/server/features/backlinks/services/BacklinksService.ts @@ -2,37 +2,63 @@ import { buildCacheKey, getCached, setCached } from "@/server/lib/r2-cache"; import { normalizeBacklinksTarget } from "@/server/lib/dataforseo"; import { normalizeBacklinksSpamFilterOptions, + type BacklinksLookupInput, type BacklinksSpamFilterOptions, } from "@/types/schemas/backlinks"; import { profileBacklinksOverview, - profileReferringDomainsRows, - profileTopPagesRows, + profileBacklinksRowsPage, + profileReferringDomainsPage, + profileTopPagesPage, type BacklinksCache, + type BacklinksRowsPageServiceInput, + type ReferringDomainsPageServiceInput, + type TopPagesPageServiceInput, } from "@/server/features/backlinks/services/backlinksServiceData"; import type { BillingCustomerContext } from "@/server/billing/subscription"; -import type { BacklinksLookupInput } from "@/types/schemas/backlinks"; const defaultCache: BacklinksCache = { get: getCached, set: setCached, }; +type BacklinksPageCacheInput = { + target: string; + scope?: "domain" | "page"; + page: number; + pageSize: number; + sortField: string; + sortOrder: string; + filters: Record; + /** Backlinks rows only: DataForSEO result grouping. */ + mode?: string; +}; + function createBacklinksService(cache: BacklinksCache = defaultCache) { return { async profileOverview( input: BacklinksLookupInput, billingCustomer: BillingCustomerContext, + ) { + const cacheKey = await buildCacheKey("backlinks:overview", { + ...buildTargetCacheInput(input, billingCustomer), + }); + + return profileBacklinksOverview(cache, cacheKey, input, billingCustomer); + }, + async profileBacklinksPage( + input: BacklinksRowsPageServiceInput, + billingCustomer: BillingCustomerContext, options?: BacklinksSpamFilterOptions, ) { - const cacheKey = await buildBacklinksCacheKey( - "backlinks:overview", + const cacheKey = await buildPageCacheKey( + "backlinks:rows-page", input, billingCustomer, options, ); - return profileBacklinksOverview( + return profileBacklinksRowsPage( cache, cacheKey, input, @@ -40,19 +66,19 @@ function createBacklinksService(cache: BacklinksCache = defaultCache) { options, ); }, - async profileReferringDomains( - input: BacklinksLookupInput, + async profileReferringDomainsPage( + input: ReferringDomainsPageServiceInput, billingCustomer: BillingCustomerContext, options?: BacklinksSpamFilterOptions, ) { - const cacheKey = await buildBacklinksCacheKey( - "backlinks:referring-domains", + const cacheKey = await buildPageCacheKey( + "backlinks:referring-domains-page", input, billingCustomer, options, ); - return profileReferringDomainsRows( + return profileReferringDomainsPage( cache, cacheKey, input, @@ -60,44 +86,52 @@ function createBacklinksService(cache: BacklinksCache = defaultCache) { options, ); }, - async profileTopPages( - input: BacklinksLookupInput, + async profileTopPagesPage( + input: TopPagesPageServiceInput, billingCustomer: BillingCustomerContext, ) { - const cacheKey = await buildBacklinksCacheKey( - "backlinks:top-pages", + const cacheKey = await buildPageCacheKey( + "backlinks:top-pages-page", input, billingCustomer, ); - return profileTopPagesRows(cache, cacheKey, input, billingCustomer); + return profileTopPagesPage(cache, cacheKey, input, billingCustomer); }, } as const; } -async function buildBacklinksCacheKey( - prefix: string, +function buildTargetCacheInput( input: BacklinksLookupInput, billingCustomer: BillingCustomerContext, - options?: BacklinksSpamFilterOptions, -): Promise { +) { const normalizedTarget = normalizeBacklinksTarget(input.target, { scope: input.scope, }); - const cacheKeyInput = { + + return { organizationId: billingCustomer.organizationId, target: normalizedTarget.apiTarget, scope: normalizedTarget.scope, }; +} - if (!options) { - return buildCacheKey(prefix, cacheKeyInput); - } - +async function buildPageCacheKey( + prefix: string, + input: BacklinksPageCacheInput, + billingCustomer: BillingCustomerContext, + options?: BacklinksSpamFilterOptions, +): Promise { const spamFilterOptions = normalizeBacklinksSpamFilterOptions(options); return buildCacheKey(prefix, { - ...cacheKeyInput, + ...buildTargetCacheInput(input, billingCustomer), + page: input.page, + pageSize: input.pageSize, + sortField: input.sortField, + sortOrder: input.sortOrder, + filters: input.filters, + ...(input.mode ? { mode: input.mode } : {}), hideSpam: String(spamFilterOptions.hideSpam), ...(spamFilterOptions.hideSpam ? { spamThreshold: String(spamFilterOptions.spamThreshold) } diff --git a/src/server/features/backlinks/services/backlinksApiFilters.test.ts b/src/server/features/backlinks/services/backlinksApiFilters.test.ts new file mode 100644 index 0000000..60c819d --- /dev/null +++ b/src/server/features/backlinks/services/backlinksApiFilters.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it } from "vitest"; +import { + countFilterConditions, + toBacklinksFiltersPayload, + type BacklinksTabFilterValues, + EMPTY_BACKLINKS_FILTERS, +} from "@/client/features/backlinks/backlinksFilterTypes"; +import { + buildBacklinksRowsApiFilters, + buildReferringDomainsApiFilters, + buildTopPagesApiFilters, +} from "./backlinksApiFilters"; + +describe("buildBacklinksRowsApiFilters", () => { + it("ORs include terms in one group and ANDs everything else", () => { + expect( + buildBacklinksRowsApiFilters({ + include: "blog, news", + exclude: "spam", + minDomainRank: 30, + linkType: "dofollow", + hideLost: true, + }), + ).toEqual([ + [["url_from", "ilike", "%blog%"], "or", ["url_from", "ilike", "%news%"]], + "and", + ["url_from", "not_ilike", "%spam%"], + "and", + ["domain_from_rank", ">=", 30], + "and", + ["dofollow", "=", true], + "and", + ["is_lost", "=", false], + ]); + }); + + it("emits a single include term as a plain condition", () => { + expect(buildBacklinksRowsApiFilters({ include: "blog" })).toEqual([ + ["url_from", "ilike", "%blog%"], + ]); + }); + + it("escapes LIKE wildcards in terms", () => { + expect(buildBacklinksRowsApiFilters({ include: "wp_content" })).toEqual([ + ["url_from", "ilike", "%wp\\_content%"], + ]); + }); + + it("returns no expressions for empty filters", () => { + expect(buildBacklinksRowsApiFilters({})).toEqual([]); + }); + + it("throws when the condition budget is exceeded", () => { + expect(() => + buildBacklinksRowsApiFilters({ + include: "a, b, c, d, e", + exclude: "f, g, h, i", + }), + ).toThrowError(/Too many filter conditions/); + }); +}); + +describe("buildReferringDomainsApiFilters", () => { + it("filters on referring-domain fields", () => { + expect( + buildReferringDomainsApiFilters({ + include: "edu", + minBacklinks: 5, + maxSpamScore: 30, + }), + ).toEqual([ + ["domain", "ilike", "%edu%"], + "and", + ["backlinks", ">=", 5], + "and", + ["backlinks_spam_score", "<=", 30], + ]); + }); +}); + +describe("buildTopPagesApiFilters", () => { + it("filters on the url field", () => { + expect( + buildTopPagesApiFilters({ include: "/blog", minReferringDomains: 2 }), + ).toEqual([ + ["url", "ilike", "%/blog%"], + "and", + ["referring_domains", ">=", 2], + ]); + }); +}); + +describe("client condition count vs server condition budget", () => { + // The client gates Apply with countFilterConditions; the server enforces the + // DataForSEO budget while building. They must agree on how many conditions a + // set of filter values produces, or users get hard errors the UI accepted. + function serverConditionCount(values: BacklinksTabFilterValues): number { + const expressions = buildBacklinksRowsApiFilters( + toBacklinksFiltersPayload(values), + ); + let count = 0; + for (const expression of expressions) { + if (expression === "and") continue; + // An include OR-group contains nested clauses and "or" connectors. + count += + Array.isArray(expression) && Array.isArray(expression[0]) + ? Math.ceil(expression.length / 2) + : 1; + } + return count; + } + + const cases: Array<[string, BacklinksTabFilterValues]> = [ + ["empty", { ...EMPTY_BACKLINKS_FILTERS }], + [ + "terms and ranges", + { + ...EMPTY_BACKLINKS_FILTERS, + include: "blog, news", + exclude: "spam", + minDomainRank: "30", + maxSpamScore: "50", + }, + ], + [ + "toggles", + { + ...EMPTY_BACKLINKS_FILTERS, + linkType: "nofollow", + hideLost: "true", + hideBroken: "true", + }, + ], + [ + "everything", + { + ...EMPTY_BACKLINKS_FILTERS, + include: "a", + exclude: "b", + minDomainRank: "1", + maxDomainRank: "90", + minLinkAuthority: "2", + linkType: "dofollow", + hideLost: "true", + }, + ], + ]; + + it.each(cases)("matches for %s", (_name, values) => { + expect(serverConditionCount(values)).toBe(countFilterConditions(values)); + }); +}); diff --git a/src/server/features/backlinks/services/backlinksApiFilters.ts b/src/server/features/backlinks/services/backlinksApiFilters.ts new file mode 100644 index 0000000..6a19b54 --- /dev/null +++ b/src/server/features/backlinks/services/backlinksApiFilters.ts @@ -0,0 +1,179 @@ +import { + assertFilterConditionBudget, + buildIncludeOrGroup, + collectNumericRange, + escapeLikeTerm, + joinClauses, + parseFilterTerms, + type FilterClause, +} from "@/server/lib/dataforseo/filters"; +import type { + BacklinksRowsFilters, + BacklinksRowsSortField, + BacklinksSortOrder, + ReferringDomainsFilters, + ReferringDomainsSortField, + TopPagesFilters, + TopPagesSortField, +} from "@/types/schemas/backlinks"; + +const BACKLINKS_ROWS_SORT_FIELDS: Record = { + rank: "rank", + domainRank: "domain_from_rank", + spamScore: "backlink_spam_score", + firstSeen: "first_seen", +}; + +const REFERRING_DOMAINS_SORT_FIELDS: Record = + { + domain: "domain", + backlinks: "backlinks", + referringPages: "referring_pages", + rank: "rank", + spamScore: "backlinks_spam_score", + firstSeen: "first_seen", + brokenBacklinks: "broken_backlinks", + }; + +const TOP_PAGES_SORT_FIELDS: Record = { + backlinks: "backlinks", + referringDomains: "referring_domains", + rank: "rank", + brokenBacklinks: "broken_backlinks", +}; + +export function buildBacklinksRowsOrderBy( + field: BacklinksRowsSortField, + order: BacklinksSortOrder, +): string[] { + return [`${BACKLINKS_ROWS_SORT_FIELDS[field]},${order}`]; +} + +export function buildReferringDomainsOrderBy( + field: ReferringDomainsSortField, + order: BacklinksSortOrder, +): string[] { + return [`${REFERRING_DOMAINS_SORT_FIELDS[field]},${order}`]; +} + +export function buildTopPagesOrderBy( + field: TopPagesSortField, + order: BacklinksSortOrder, +): string[] { + return [`${TOP_PAGES_SORT_FIELDS[field]},${order}`]; +} + +/** + * Translates the Backlinks tab filters into DataForSEO filter expressions. + * Include/exclude terms match the source URL (which contains the linking + * domain): include terms are OR'd (match any), exclude terms AND'd (drop all). + */ +export function buildBacklinksRowsApiFilters( + filters: BacklinksRowsFilters, +): unknown[] { + const conditions: FilterClause[] = []; + + collectExcludeConditions(conditions, "url_from", filters.exclude); + collectNumericRange( + conditions, + "domain_from_rank", + filters.minDomainRank, + filters.maxDomainRank, + ); + collectNumericRange( + conditions, + "rank", + filters.minLinkAuthority, + filters.maxLinkAuthority, + ); + collectNumericRange( + conditions, + "backlink_spam_score", + filters.minSpamScore, + filters.maxSpamScore, + ); + if (filters.linkType) { + conditions.push(["dofollow", "=", filters.linkType === "dofollow"]); + } + if (filters.hideLost) { + conditions.push(["is_lost", "=", false]); + } + if (filters.hideBroken) { + conditions.push(["is_broken", "=", false]); + } + if (filters.domainFrom) { + conditions.push(["domain_from", "=", filters.domainFrom]); + } + + return finishFilters("url_from", filters.include, conditions); +} + +export function buildReferringDomainsApiFilters( + filters: ReferringDomainsFilters, +): unknown[] { + const conditions: FilterClause[] = []; + + collectExcludeConditions(conditions, "domain", filters.exclude); + collectNumericRange( + conditions, + "backlinks", + filters.minBacklinks, + filters.maxBacklinks, + ); + collectNumericRange(conditions, "rank", filters.minRank, filters.maxRank); + collectNumericRange( + conditions, + "backlinks_spam_score", + filters.minSpamScore, + filters.maxSpamScore, + ); + + return finishFilters("domain", filters.include, conditions); +} + +export function buildTopPagesApiFilters(filters: TopPagesFilters): unknown[] { + const conditions: FilterClause[] = []; + + collectExcludeConditions(conditions, "url", filters.exclude); + collectNumericRange( + conditions, + "backlinks", + filters.minBacklinks, + filters.maxBacklinks, + ); + collectNumericRange( + conditions, + "referring_domains", + filters.minReferringDomains, + filters.maxReferringDomains, + ); + collectNumericRange(conditions, "rank", filters.minRank, filters.maxRank); + + return finishFilters("url", filters.include, conditions); +} + +function collectExcludeConditions( + out: FilterClause[], + field: string, + exclude: string | undefined, +) { + for (const term of parseFilterTerms(exclude)) { + out.push([field, "not_ilike", `%${escapeLikeTerm(term)}%`]); + } +} + +/** Prepends the OR'd include group, enforces the condition budget, joins with "and". */ +function finishFilters( + includeField: string, + include: string | undefined, + conditions: FilterClause[], +): unknown[] { + const includeGroup = buildIncludeOrGroup(includeField, include); + assertFilterConditionBudget( + conditions.length + (includeGroup?.conditionCount ?? 0), + ); + return joinClauses( + includeGroup ? [includeGroup.clause, ...conditions] : conditions, + "and", + ); +} diff --git a/src/server/features/backlinks/services/backlinksOverviewSchema.ts b/src/server/features/backlinks/services/backlinksOverviewSchema.ts index c656860..071445a 100644 --- a/src/server/features/backlinks/services/backlinksOverviewSchema.ts +++ b/src/server/features/backlinks/services/backlinksOverviewSchema.ts @@ -19,7 +19,7 @@ const backlinksRowSchema = z.object({ linksCount: z.number().nullable(), }); -export const referringDomainRowSchema = z.object({ +const referringDomainRowSchema = z.object({ domain: z.string().nullable(), backlinks: z.number().nullable(), referringPages: z.number().nullable(), @@ -30,7 +30,7 @@ export const referringDomainRowSchema = z.object({ brokenPages: z.number().nullable(), }); -export const topPageRowSchema = z.object({ +const topPageRowSchema = z.object({ page: z.string().nullable(), backlinks: z.number().nullable(), referringDomains: z.number().nullable(), @@ -71,12 +71,35 @@ export const backlinksOverviewSchema = z.object({ newReferringDomains: z.number().nullable(), lostReferringDomains: z.number().nullable(), }), - backlinks: z.array(backlinksRowSchema), - referringDomains: z.array(referringDomainRowSchema), - topPages: z.array(topPageRowSchema), trends: z.array(backlinksTrendRowSchema), newLostTrends: z.array(backlinksNewLostTrendRowSchema), fetchedAt: z.string(), }); export type BacklinksOverviewResult = z.infer; + +function buildPageResultSchema(rowSchema: T) { + return z.object({ + rows: z.array(rowSchema), + totalCount: z.number().nullable(), + hasMore: z.boolean(), + page: z.number(), + pageSize: z.number(), + fetchedAt: z.string(), + }); +} + +export const backlinksRowsPageResultSchema = + buildPageResultSchema(backlinksRowSchema); +export const referringDomainsPageResultSchema = buildPageResultSchema( + referringDomainRowSchema, +); +export const topPagesPageResultSchema = buildPageResultSchema(topPageRowSchema); + +export type BacklinksRowsPageResult = z.infer< + typeof backlinksRowsPageResultSchema +>; +export type ReferringDomainsPageResult = z.infer< + typeof referringDomainsPageResultSchema +>; +export type TopPagesPageResult = z.infer; diff --git a/src/server/features/backlinks/services/backlinksServiceData.ts b/src/server/features/backlinks/services/backlinksServiceData.ts index 6157c84..0927a9b 100644 --- a/src/server/features/backlinks/services/backlinksServiceData.ts +++ b/src/server/features/backlinks/services/backlinksServiceData.ts @@ -9,21 +9,52 @@ import { type DomainPageSummaryItem, type ReferringDomainItem, } from "@/server/lib/dataforseo"; -import { - normalizeBacklinksSpamFilterOptions, - type BacklinksSpamFilterOptions, +import type { + BacklinksLookupInput, + BacklinksRowsPageInput, + BacklinksSpamFilterOptions, + ReferringDomainsPageInput, + TopPagesPageInput, } from "@/types/schemas/backlinks"; + import { backlinksOverviewSchema, - referringDomainRowSchema, - topPageRowSchema, + backlinksRowsPageResultSchema, + referringDomainsPageResultSchema, + topPagesPageResultSchema, type BacklinksOverviewResult, + type BacklinksRowsPageResult, + type ReferringDomainsPageResult, + type TopPagesPageResult, } from "@/server/features/backlinks/services/backlinksOverviewSchema"; -import type { BacklinksLookupInput } from "@/types/schemas/backlinks"; +import { + buildBacklinksRowsApiFilters, + buildBacklinksRowsOrderBy, + buildReferringDomainsApiFilters, + buildReferringDomainsOrderBy, + buildTopPagesApiFilters, + buildTopPagesOrderBy, +} from "@/server/features/backlinks/services/backlinksApiFilters"; + +// The page-request schemas carry projectId for the web middleware; the +// service layer is organization-scoped and never reads it. +export type BacklinksRowsPageServiceInput = Omit< + BacklinksRowsPageInput, + "projectId" +>; +export type ReferringDomainsPageServiceInput = Omit< + ReferringDomainsPageInput, + "projectId" +>; +export type TopPagesPageServiceInput = Omit; const BACKLINKS_OVERVIEW_TTL_SECONDS = 6 * 60 * 60; const BACKLINKS_TAB_TTL_SECONDS = 6 * 60 * 60; +const backlinksOverviewCacheSchema = z.object({ + overview: backlinksOverviewSchema, +}); + export type BacklinksCache = { get(key: string): Promise; set(key: string, data: unknown, ttlSeconds: number): Promise; @@ -33,24 +64,6 @@ type BacklinksOverviewProfile = { overview: BacklinksOverviewResult; }; -type ReferringDomainsProfile = { - rows: BacklinksOverviewResult["referringDomains"]; -}; - -type TopPagesProfile = { - rows: BacklinksOverviewResult["topPages"]; -}; - -const backlinksOverviewCacheSchema = z.object({ - overview: backlinksOverviewSchema, -}); - -const referringDomainsCacheSchema = z.object({ - rows: z.array(referringDomainRowSchema), -}); - -const topPagesCacheSchema = z.object({ rows: z.array(topPageRowSchema) }); - type BacklinksDateRange = { dateFrom: string; dateTo: string; @@ -61,10 +74,10 @@ export async function profileBacklinksOverview( cacheKey: string, input: BacklinksLookupInput, billingCustomer: BillingCustomerContext, - options?: BacklinksSpamFilterOptions, ): Promise { - const cachedRaw = await cache.get(cacheKey); - const cached = backlinksOverviewCacheSchema.safeParse(cachedRaw); + const cached = backlinksOverviewCacheSchema.safeParse( + await cache.get(cacheKey), + ); if (cached.success) { return { overview: cached.data.overview, @@ -77,16 +90,10 @@ export async function profileBacklinksOverview( const normalizedTarget = normalizeBacklinksTarget(input.target, { scope: input.scope, }); - const request = buildBacklinksListRequest( - normalizedTarget.apiTarget, - 100, - options, - ); const dateRange = buildBacklinksDateRange(now); - const [summary, backlinks, history] = await Promise.all([ - dataforseo.backlinks.summary({ target: request.target }), - dataforseo.backlinks.rows(request), + const [summary, history] = await Promise.all([ + dataforseo.backlinks.summary({ target: normalizedTarget.apiTarget }), normalizedTarget.scope === "domain" ? dataforseo.backlinks.history({ target: normalizedTarget.apiTarget, @@ -99,7 +106,6 @@ export async function profileBacklinksOverview( normalizedTarget, now, summary, - backlinks, history, }); await cacheValue( @@ -112,76 +118,131 @@ export async function profileBacklinksOverview( return { overview }; } -export async function profileReferringDomainsRows( +export async function profileBacklinksRowsPage( cache: BacklinksCache, cacheKey: string, - input: BacklinksLookupInput, + input: BacklinksRowsPageServiceInput, billingCustomer: BillingCustomerContext, - options?: BacklinksSpamFilterOptions, -): Promise { - const cachedRaw = await cache.get(cacheKey); - const cached = referringDomainsCacheSchema.safeParse(cachedRaw); - if (cached.success) { - return { - rows: cached.data.rows, - }; - } - - const dataforseo = createDataforseoClient(billingCustomer); - - const request = buildBacklinksListRequest( - normalizeBacklinksTarget(input.target, { scope: input.scope }).apiTarget, - 100, - options, + spamOptions?: BacklinksSpamFilterOptions, +): Promise { + const cached = backlinksRowsPageResultSchema.safeParse( + await cache.get(cacheKey), ); - const response = await dataforseo.backlinks.referringDomains(request); - const rows = mapReferringDomainsRows(response); - - await cacheValue(cache, cacheKey, { rows }, BACKLINKS_TAB_TTL_SECONDS); - - return { rows }; -} - -export async function profileTopPagesRows( - cache: BacklinksCache, - cacheKey: string, - input: BacklinksLookupInput, - billingCustomer: BillingCustomerContext, -): Promise { - const cachedRaw = await cache.get(cacheKey); - const cached = topPagesCacheSchema.safeParse(cachedRaw); if (cached.success) { - return { - rows: cached.data.rows, - }; + return cached.data; } const dataforseo = createDataforseoClient(billingCustomer); + const offset = (input.page - 1) * input.pageSize; + const filters = buildBacklinksRowsApiFilters(input.filters); - const request = { + const response = await dataforseo.backlinks.rows({ target: normalizeBacklinksTarget(input.target, { scope: input.scope }) .apiTarget, - }; - const response = await dataforseo.backlinks.domainPages({ - ...request, - limit: 100, + limit: input.pageSize, + offset, + orderBy: buildBacklinksRowsOrderBy(input.sortField, input.sortOrder), + filters: filters.length > 0 ? filters : undefined, + mode: input.mode, + ...spamOptions, }); - const rows = mapTopPagesRows(response); - await cacheValue(cache, cacheKey, { rows }, BACKLINKS_TAB_TTL_SECONDS); + const result = buildPageResult(input, offset, { + rows: mapBacklinksRows(response.items), + totalCount: response.totalCount, + }); + await cacheValue(cache, cacheKey, result, BACKLINKS_TAB_TTL_SECONDS); - return { rows }; + return result; } -function buildBacklinksListRequest( - target: string, - limit: number, - options?: BacklinksSpamFilterOptions, +export async function profileReferringDomainsPage( + cache: BacklinksCache, + cacheKey: string, + input: ReferringDomainsPageServiceInput, + billingCustomer: BillingCustomerContext, + spamOptions?: BacklinksSpamFilterOptions, +): Promise { + const cached = referringDomainsPageResultSchema.safeParse( + await cache.get(cacheKey), + ); + if (cached.success) { + return cached.data; + } + + const dataforseo = createDataforseoClient(billingCustomer); + const offset = (input.page - 1) * input.pageSize; + const filters = buildReferringDomainsApiFilters(input.filters); + + const response = await dataforseo.backlinks.referringDomains({ + target: normalizeBacklinksTarget(input.target, { scope: input.scope }) + .apiTarget, + limit: input.pageSize, + offset, + orderBy: buildReferringDomainsOrderBy(input.sortField, input.sortOrder), + filters: filters.length > 0 ? filters : undefined, + ...spamOptions, + }); + + const result = buildPageResult(input, offset, { + rows: mapReferringDomainsRows(response.items), + totalCount: response.totalCount, + }); + await cacheValue(cache, cacheKey, result, BACKLINKS_TAB_TTL_SECONDS); + + return result; +} + +export async function profileTopPagesPage( + cache: BacklinksCache, + cacheKey: string, + input: TopPagesPageServiceInput, + billingCustomer: BillingCustomerContext, +): Promise { + const cached = topPagesPageResultSchema.safeParse(await cache.get(cacheKey)); + if (cached.success) { + return cached.data; + } + + const dataforseo = createDataforseoClient(billingCustomer); + const offset = (input.page - 1) * input.pageSize; + const filters = buildTopPagesApiFilters(input.filters); + + const response = await dataforseo.backlinks.domainPages({ + target: normalizeBacklinksTarget(input.target, { scope: input.scope }) + .apiTarget, + limit: input.pageSize, + offset, + orderBy: buildTopPagesOrderBy(input.sortField, input.sortOrder), + filters: filters.length > 0 ? filters : undefined, + }); + + const result = buildPageResult(input, offset, { + rows: mapTopPagesRows(response.items), + totalCount: response.totalCount, + }); + await cacheValue(cache, cacheKey, result, BACKLINKS_TAB_TTL_SECONDS); + + return result; +} + +function buildPageResult( + input: { page: number; pageSize: number }, + offset: number, + data: { rows: TRow[]; totalCount: number | null }, ) { + const hasMore = + data.totalCount != null + ? offset + data.rows.length < data.totalCount + : data.rows.length === input.pageSize; + return { - target, - limit, - ...normalizeBacklinksSpamFilterOptions(options), + rows: data.rows, + totalCount: data.totalCount, + hasMore, + page: input.page, + pageSize: input.pageSize, + fetchedAt: new Date().toISOString(), }; } @@ -205,7 +266,6 @@ function buildOverviewResult(args: { normalizedTarget: ReturnType; now: Date; summary: BacklinksSummaryItem; - backlinks: BacklinksItem[]; history: BacklinksHistoryItem[]; }): BacklinksOverviewResult { const historyRows = args.history @@ -253,9 +313,6 @@ function buildOverviewResult(args: { args.summary.lost_reffering_domains ?? null, }, - backlinks: mapBacklinksRows(args.backlinks), - referringDomains: [], - topPages: [], trends: historyRows.map((item) => ({ date: item.date, backlinks: item.backlinks, diff --git a/src/server/features/domain/services/domainKeywordFilters.ts b/src/server/features/domain/services/domainKeywordFilters.ts index 778f061..e54bb70 100644 --- a/src/server/features/domain/services/domainKeywordFilters.ts +++ b/src/server/features/domain/services/domainKeywordFilters.ts @@ -1,8 +1,12 @@ import { - MAX_DATAFORSEO_FILTER_CONDITIONS, - type DomainKeywordsFilters, -} from "@/types/schemas/domain"; -import { AppError } from "@/server/lib/errors"; + assertFilterConditionBudget, + collectNumericRange, + escapeLikeTerm, + joinClauses, + parseFilterTerms, + type FilterClause, +} from "@/server/lib/dataforseo/filters"; +import type { DomainKeywordsFilters } from "@/types/schemas/domain"; export type DomainKeywordsSortMode = | "rank" @@ -27,62 +31,26 @@ export function buildOrderBy( return [`${SORT_FIELD_BY_MODE[sortMode]},${sortOrder}`]; } -function escapeLikeTerm(term: string): string { - return term.replace(/[\\%_]/g, (match) => `\\${match}`); -} - -function pushAnd(filters: unknown[], expression: Clause) { - if (filters.length > 0) filters.push("and"); - filters.push(expression); -} - -function parseTerms(value: string | undefined): string[] { - if (!value) return []; - return value - .toLowerCase() - .split(/[,+]/) - .map((term) => term.trim()) - .filter(Boolean); -} - -function collectNumericRange( - out: Clause[], - field: string, - min: number | undefined, - max: number | undefined, -) { - if (typeof min === "number" && Number.isFinite(min)) { - out.push([field, ">=", min]); - } - if (typeof max === "number" && Number.isFinite(max)) { - out.push([field, "<=", max]); - } -} - /** - * DataForSEO accepts up to 8 filter conditions per request joined by - * "and"/"or" operators. Each include/exclude term is one ilike clause; - * numeric ranges add one per bound; the free-text search term adds one OR- - * group of two (keyword OR url). The client surfaces the same condition - * count and disables Apply when over budget, so reaching the cap here - * indicates a misbehaving client — we throw rather than silently truncate. + * Each include/exclude term is one ilike clause; numeric ranges add one per + * bound; the free-text search term adds one OR-group of two (keyword OR url). + * The client surfaces the same condition count and disables Apply when over + * the DataForSEO budget. */ -type Clause = unknown[]; - export function buildKeywordFilters( filters: DomainKeywordsFilters, searchTerm?: string, ): unknown[] { - const conditions: Clause[] = []; + const conditions: FilterClause[] = []; - for (const term of parseTerms(filters.include)) { + for (const term of parseFilterTerms(filters.include)) { conditions.push([ "keyword_data.keyword", "ilike", `%${escapeLikeTerm(term)}%`, ]); } - for (const term of parseTerms(filters.exclude)) { + for (const term of parseFilterTerms(filters.exclude)) { conditions.push([ "keyword_data.keyword", "not_ilike", @@ -125,21 +93,15 @@ export function buildKeywordFilters( const searchGroup = trimmedSearch ? buildSearchGroup(trimmedSearch) : null; // The search OR-group costs 2 slots; everything else is 1. - const totalConditions = conditions.length + (searchGroup ? 2 : 0); - if (totalConditions > MAX_DATAFORSEO_FILTER_CONDITIONS) { - throw new AppError( - "VALIDATION_ERROR", - `Too many filter conditions (${totalConditions} of ${MAX_DATAFORSEO_FILTER_CONDITIONS} max).`, - ); - } + assertFilterConditionBudget(conditions.length + (searchGroup ? 2 : 0)); - const expressions: unknown[] = []; - for (const clause of conditions) pushAnd(expressions, clause); - if (searchGroup) pushAnd(expressions, searchGroup); - return expressions; + return joinClauses( + searchGroup ? [...conditions, searchGroup] : conditions, + "and", + ); } -function buildSearchGroup(term: string): Clause { +function buildSearchGroup(term: string): FilterClause { const escaped = escapeLikeTerm(term); return [ ["keyword_data.keyword", "ilike", `%${escaped}%`], diff --git a/src/server/lib/dataforseo/backlinks.test.ts b/src/server/lib/dataforseo/backlinks.test.ts index 9b976af..585ffbd 100644 --- a/src/server/lib/dataforseo/backlinks.test.ts +++ b/src/server/lib/dataforseo/backlinks.test.ts @@ -209,7 +209,7 @@ describe("fetchBacklinksSummary", () => { await expect( fetchBacklinksRows({ target: "example.com" }), - ).resolves.toMatchObject({ data: [] }); + ).resolves.toMatchObject({ data: { items: [], totalCount: null } }); await expect( fetchBacklinksHistory({ target: "example.com", diff --git a/src/server/lib/dataforseo/backlinks.ts b/src/server/lib/dataforseo/backlinks.ts index cd8d402..ea4491b 100644 --- a/src/server/lib/dataforseo/backlinks.ts +++ b/src/server/lib/dataforseo/backlinks.ts @@ -17,6 +17,7 @@ import { assertOk, buildTaskBilling, parseTaskItems, + parseTaskTotalCount, type DataforseoApiResponse, } from "@/server/lib/dataforseo/envelope"; @@ -24,7 +25,16 @@ export { normalizeBacklinksTarget } from "@/server/lib/dataforseoBacklinksTarget type BacklinksRequest = { target: string }; type BacklinksListRequest = BacklinksRequest & - BacklinksSpamFilterOptions & { limit?: number }; + BacklinksSpamFilterOptions & { + limit?: number; + offset?: number; + /** DataForSEO order_by entries, e.g. ["rank,desc"]. */ + orderBy?: string[]; + /** Pre-built DataForSEO filter expressions, already joined with and/or. */ + filters?: unknown[]; + /** Result grouping (backlinks list only): "one_per_domain" | "as_is". */ + mode?: string; + }; type BacklinksTimeseriesRequest = { target: string; dateFrom: string; @@ -146,6 +156,24 @@ function buildCommonPayload(input: BacklinksRequest) { const assertOptions = (path: string) => ({ classify: classifyBacklinksError, classifyPath: path }) as const; +/** + * Joins caller-provided filter expressions with the spam-score condition. + * `userFilters` arrives already and/or-joined, so the spam condition is + * appended with a single top-level "and". + */ +function combineFilters( + userFilters: unknown[] | undefined, + spamCondition: unknown[] | undefined, +): unknown[] | undefined { + const merged: unknown[] = []; + if (userFilters && userFilters.length > 0) merged.push(...userFilters); + if (spamCondition) { + if (merged.length > 0) merged.push("and"); + merged.push(spamCondition); + } + return merged.length > 0 ? merged : undefined; +} + export async function fetchBacklinksSummary(input: BacklinksRequest) { const response = await backlinksApi(classifyBacklinksError).summaryLive([ new BacklinksSummaryLiveRequestInfo(buildCommonPayload(input)), @@ -179,14 +207,19 @@ export async function fetchBacklinksSummary(input: BacklinksRequest) { export async function fetchBacklinksRows(input: BacklinksListRequest) { const spamFilterOptions = normalizeBacklinksSpamFilterOptions(input); - const filters = spamFilterOptions.hideSpam - ? [["backlink_spam_score", "<=", spamFilterOptions.spamThreshold]] - : undefined; + const filters = combineFilters( + input.filters, + spamFilterOptions.hideSpam + ? ["backlink_spam_score", "<=", spamFilterOptions.spamThreshold] + : undefined, + ); const response = await backlinksApi(classifyBacklinksError).backlinksLive([ new BacklinksBacklinksLiveRequestInfo({ ...buildCommonPayload(input), limit: input.limit ?? 100, - order_by: ["rank,desc"], + offset: input.offset, + order_by: input.orderBy ?? ["rank,desc"], + mode: input.mode, ...(filters ? { filters } : {}), }), ]); @@ -195,23 +228,30 @@ export async function fetchBacklinksRows(input: BacklinksListRequest) { assertOptions("/v3/backlinks/backlinks/live"), ); return { - data: parseTaskItems("backlinks-live", task, backlinksItemSchema), + data: { + items: parseTaskItems("backlinks-live", task, backlinksItemSchema), + totalCount: parseTaskTotalCount(task), + }, billing: buildTaskBilling(task), }; } export async function fetchReferringDomains(input: BacklinksListRequest) { const spamFilterOptions = normalizeBacklinksSpamFilterOptions(input); - const filters = spamFilterOptions.hideSpam - ? [["backlinks_spam_score", "<=", spamFilterOptions.spamThreshold]] - : undefined; + const filters = combineFilters( + input.filters, + spamFilterOptions.hideSpam + ? ["backlinks_spam_score", "<=", spamFilterOptions.spamThreshold] + : undefined, + ); const response = await backlinksApi( classifyBacklinksError, ).referringDomainsLive([ new BacklinksReferringDomainsLiveRequestInfo({ ...buildCommonPayload(input), limit: input.limit ?? 100, - order_by: ["backlinks,desc"], + offset: input.offset, + order_by: input.orderBy ?? ["backlinks,desc"], ...(filters ? { filters } : {}), }), ]); @@ -220,23 +260,30 @@ export async function fetchReferringDomains(input: BacklinksListRequest) { assertOptions("/v3/backlinks/referring_domains/live"), ); return { - data: parseTaskItems( - "referring-domains-live", - task, - referringDomainItemSchema, - ), + data: { + items: parseTaskItems( + "referring-domains-live", + task, + referringDomainItemSchema, + ), + totalCount: parseTaskTotalCount(task), + }, billing: buildTaskBilling(task), }; } export async function fetchDomainPagesSummary(input: BacklinksListRequest) { + const filters = + input.filters && input.filters.length > 0 ? input.filters : undefined; const response = await backlinksApi( classifyBacklinksError, ).domainPagesSummaryLive([ new BacklinksDomainPagesSummaryLiveRequestInfo({ ...buildCommonPayload(input), limit: input.limit ?? 100, - order_by: ["backlinks,desc"], + offset: input.offset, + order_by: input.orderBy ?? ["backlinks,desc"], + ...(filters ? { filters } : {}), }), ]); const task = assertOk( @@ -244,11 +291,14 @@ export async function fetchDomainPagesSummary(input: BacklinksListRequest) { assertOptions("/v3/backlinks/domain_pages_summary/live"), ); return { - data: parseTaskItems( - "domain-pages-summary-live", - task, - domainPageSummaryItemSchema, - ), + data: { + items: parseTaskItems( + "domain-pages-summary-live", + task, + domainPageSummaryItemSchema, + ), + totalCount: parseTaskTotalCount(task), + }, billing: buildTaskBilling(task), }; } diff --git a/src/server/lib/dataforseo/envelope.ts b/src/server/lib/dataforseo/envelope.ts index bcb90c8..e572736 100644 --- a/src/server/lib/dataforseo/envelope.ts +++ b/src/server/lib/dataforseo/envelope.ts @@ -148,6 +148,13 @@ export function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } +/** Reads `task.result[0].total_count` for paginated list endpoints. */ +export function parseTaskTotalCount(task: DataforseoTaskLike): number | null { + const first = task.result?.[0]; + if (!isRecord(first)) return null; + return typeof first.total_count === "number" ? first.total_count : null; +} + /** Reads `task.result[0].items`, validating against a Zod schema for loosely-typed endpoints. */ export function parseTaskItems( endpoint: string, diff --git a/src/server/lib/dataforseo/filters.ts b/src/server/lib/dataforseo/filters.ts new file mode 100644 index 0000000..035707c --- /dev/null +++ b/src/server/lib/dataforseo/filters.ts @@ -0,0 +1,89 @@ +import { AppError } from "@/server/lib/errors"; +import { MAX_DATAFORSEO_FILTER_CONDITIONS } from "@/types/schemas/domain"; + +/** + * Building blocks for DataForSEO `filters` expressions, shared by the + * feature-specific builders (domain keywords, backlinks). A "clause" is one + * condition tuple like ["field", "ilike", "%term%"] or a nested group. + */ +export type FilterClause = unknown[]; + +export function escapeLikeTerm(term: string): string { + return term.replace(/[\\%_]/g, (match) => `\\${match}`); +} + +/** Splits a comma/plus separated terms string into trimmed lowercase terms. */ +export function parseFilterTerms(value: string | undefined): string[] { + if (!value) return []; + return value + .toLowerCase() + .split(/[,+]/) + .map((term) => term.trim()) + .filter(Boolean); +} + +export function collectNumericRange( + out: FilterClause[], + field: string, + min: number | undefined, + max: number | undefined, +) { + if (typeof min === "number" && Number.isFinite(min)) { + out.push([field, ">=", min]); + } + if (typeof max === "number" && Number.isFinite(max)) { + out.push([field, "<=", max]); + } +} + +/** + * One ilike condition per include term, joined with "or" into a single nested + * group (match-any semantics). Returns the group clause plus how many of the + * DataForSEO condition budget it consumes. + */ +export function buildIncludeOrGroup( + field: string, + include: string | undefined, +): { clause: FilterClause; conditionCount: number } | null { + const conditions = parseFilterTerms(include).map((term) => [ + field, + "ilike", + `%${escapeLikeTerm(term)}%`, + ]); + if (conditions.length === 0) return null; + const first = conditions[0]; + if (conditions.length === 1 && first) { + return { clause: first, conditionCount: 1 }; + } + return { + clause: joinClauses(conditions, "or"), + conditionCount: conditions.length, + }; +} + +/** + * DataForSEO accepts up to 8 filter conditions per request. The clients + * surface the same condition count and disable Apply when over budget, so + * reaching the cap here indicates a misbehaving client — we throw rather + * than silently truncate. + */ +export function assertFilterConditionBudget(conditionCount: number): void { + if (conditionCount > MAX_DATAFORSEO_FILTER_CONDITIONS) { + throw new AppError( + "VALIDATION_ERROR", + `Too many filter conditions (${conditionCount} of ${MAX_DATAFORSEO_FILTER_CONDITIONS} max).`, + ); + } +} + +export function joinClauses( + clauses: FilterClause[], + operator: "and" | "or", +): unknown[] { + const expressions: unknown[] = []; + for (const clause of clauses) { + if (expressions.length > 0) expressions.push(operator); + expressions.push(clause); + } + return expressions; +} diff --git a/src/server/mcp/tools/get-backlinks-overview.ts b/src/server/mcp/tools/get-backlinks-overview.ts index 35ab54b..02df2f5 100644 --- a/src/server/mcp/tools/get-backlinks-overview.ts +++ b/src/server/mcp/tools/get-backlinks-overview.ts @@ -57,24 +57,28 @@ export const getBacklinksOverviewTool = { const lookup = { target: args.target, scope: args.scope }; const spamOptions = { hideSpam: args.hideSpam ?? true }; const [overview, refDomains] = await Promise.all([ - BacklinksService.profileOverview(lookup, context.billing, spamOptions), - BacklinksService.profileReferringDomains( - lookup, + BacklinksService.profileOverview(lookup, context.billing), + BacklinksService.profileReferringDomainsPage( + { + ...lookup, + page: 1, + pageSize: 100, + sortField: "backlinks", + sortOrder: "desc", + filters: {}, + }, context.billing, spamOptions, ), ]); const topDomains = refDomains.rows ?? []; - const overviewRecord = - overview && typeof overview === "object" - ? (overview as Record) - : {}; + const summary = overview.overview.summary; const text = [ `Backlinks profile for ${args.target} (${args.scope ?? "domain"}):`, - `- backlinks: ${formatMetric(overviewRecord.backlinks)}`, - `- referring domains: ${formatMetric(overviewRecord.referring_domains)}`, - `- referring pages: ${formatMetric(overviewRecord.referring_pages)}`, - `- rank: ${formatMetric(overviewRecord.rank)}`, + `- backlinks: ${formatMetric(summary.backlinks)}`, + `- referring domains: ${formatMetric(summary.referringDomains)}`, + `- referring pages: ${formatMetric(summary.referringPages)}`, + `- rank: ${formatMetric(summary.rank)}`, "", `Top referring domains (${Math.min(topDomains.length, 10)} shown):`, ...topDomains diff --git a/src/serverFunctions/backlinks.ts b/src/serverFunctions/backlinks.ts index 5b03535..d732e3c 100644 --- a/src/serverFunctions/backlinks.ts +++ b/src/serverFunctions/backlinks.ts @@ -1,7 +1,16 @@ import { createServerFn } from "@tanstack/react-start"; import { BacklinksService } from "@/server/features/backlinks/services/BacklinksService"; import { requireProjectContext } from "@/serverFunctions/middleware"; -import { backlinksOverviewInputSchema } from "@/types/schemas/backlinks"; +import { + backlinksOverviewInputSchema, + backlinksRowsPageRequestSchema, + referringDomainsPageRequestSchema, + topPagesPageRequestSchema, +} from "@/types/schemas/backlinks"; + +// The web UI exposes spam score as a regular user filter, so the implicit +// DataForSEO spam-score cutoff stays off for all web requests. +const WEB_SPAM_OPTIONS = { hideSpam: false }; export const getBacklinksOverview = createServerFn({ method: "POST", @@ -9,49 +18,45 @@ export const getBacklinksOverview = createServerFn({ .middleware(requireProjectContext) .inputValidator((data: unknown) => backlinksOverviewInputSchema.parse(data)) .handler(async ({ data, context }) => { - const input = { - target: data.target, - scope: data.scope, - }; - const spamOptions = { - hideSpam: data.hideSpam, - spamThreshold: data.spamThreshold, - }; const profile = await BacklinksService.profileOverview( - input, + { + target: data.target, + scope: data.scope, + }, context, - spamOptions, ); return profile.overview; }); +export const getBacklinksRows = createServerFn({ + method: "POST", +}) + .middleware(requireProjectContext) + .inputValidator((data: unknown) => backlinksRowsPageRequestSchema.parse(data)) + .handler(({ data, context }) => + BacklinksService.profileBacklinksPage(data, context, WEB_SPAM_OPTIONS), + ); + export const getBacklinksReferringDomains = createServerFn({ method: "POST", }) .middleware(requireProjectContext) - .inputValidator((data: unknown) => backlinksOverviewInputSchema.parse(data)) - .handler(async ({ data, context }) => { - const input = { - target: data.target, - scope: data.scope, - }; - const profile = await BacklinksService.profileReferringDomains( - input, + .inputValidator((data: unknown) => + referringDomainsPageRequestSchema.parse(data), + ) + .handler(({ data, context }) => + BacklinksService.profileReferringDomainsPage( + data, context, - ); - return profile.rows; - }); + WEB_SPAM_OPTIONS, + ), + ); export const getBacklinksTopPages = createServerFn({ method: "POST", }) .middleware(requireProjectContext) - .inputValidator((data: unknown) => backlinksOverviewInputSchema.parse(data)) - .handler(async ({ data, context }) => { - const input = { - target: data.target, - scope: data.scope, - }; - const profile = await BacklinksService.profileTopPages(input, context); - return profile.rows; - }); + .inputValidator((data: unknown) => topPagesPageRequestSchema.parse(data)) + .handler(({ data, context }) => + BacklinksService.profileTopPagesPage(data, context), + ); diff --git a/src/types/schemas/backlinks.ts b/src/types/schemas/backlinks.ts index daaa059..08be167 100644 --- a/src/types/schemas/backlinks.ts +++ b/src/types/schemas/backlinks.ts @@ -40,23 +40,192 @@ export const backlinksProjectSchema = z.object({ projectId: z.string().min(1), }); -const backlinksSpamFilterSchema = z.object({ - hideSpam: z.boolean().optional(), - spamThreshold: z.number().int().min(0).max(100).optional(), +export const backlinksOverviewInputSchema = backlinksLookupSchema.extend({ + projectId: z.string().min(1), }); -export const backlinksOverviewInputSchema = backlinksLookupSchema - .extend({ - projectId: z.string().min(1), - }) - .merge(backlinksSpamFilterSchema); +/* ------------------------------------------------------------------ */ +/* Paginated tab requests */ +/* ------------------------------------------------------------------ */ + +export const BACKLINKS_PAGE_SIZES = [50, 100, 200] as const; +export const DEFAULT_BACKLINKS_PAGE_SIZE = 100; + +const optionalNumber = z + .union([ + z.number(), + z.string().transform((value, ctx) => { + const trimmed = value.trim(); + if (trimmed === "") return undefined; + const parsed = Number(trimmed); + if (!Number.isFinite(parsed)) { + ctx.addIssue({ code: "custom", message: "Invalid number" }); + return z.NEVER; + } + return parsed; + }), + ]) + .optional(); + +export const backlinksRowsFiltersSchema = z.object({ + include: z.string().optional(), + exclude: z.string().optional(), + minDomainRank: optionalNumber, + maxDomainRank: optionalNumber, + minLinkAuthority: optionalNumber, + maxLinkAuthority: optionalNumber, + minSpamScore: optionalNumber, + maxSpamScore: optionalNumber, + linkType: z.enum(["dofollow", "nofollow"]).optional(), + hideLost: z.boolean().optional(), + hideBroken: z.boolean().optional(), + /** Exact-match on the linking domain; used to expand one domain's links. */ + domainFrom: z.string().max(255).optional(), +}); + +/** + * DataForSEO result grouping for the backlinks list: `one_per_domain` returns + * each referring domain's strongest link (the default, denoised view); + * `as_is` returns every individual backlink. + */ +const backlinksRowsModeSchema = z.enum(["one_per_domain", "as_is"]); + +export const referringDomainsFiltersSchema = z.object({ + include: z.string().optional(), + exclude: z.string().optional(), + minBacklinks: optionalNumber, + maxBacklinks: optionalNumber, + minRank: optionalNumber, + maxRank: optionalNumber, + minSpamScore: optionalNumber, + maxSpamScore: optionalNumber, +}); + +export const topPagesFiltersSchema = z.object({ + include: z.string().optional(), + exclude: z.string().optional(), + minBacklinks: optionalNumber, + maxBacklinks: optionalNumber, + minReferringDomains: optionalNumber, + maxReferringDomains: optionalNumber, + minRank: optionalNumber, + maxRank: optionalNumber, +}); + +export const backlinksSortOrderSchema = z.enum(["asc", "desc"]); +// Sort field names double as table column ids on the client; the server maps +// them to DataForSEO field names. +export const backlinksRowsSortFieldSchema = z.enum([ + "rank", + "domainRank", + "spamScore", + "firstSeen", +]); +export const referringDomainsSortFieldSchema = z.enum([ + "domain", + "backlinks", + "referringPages", + "rank", + "spamScore", + "firstSeen", + "brokenBacklinks", +]); +export const topPagesSortFieldSchema = z.enum([ + "backlinks", + "referringDomains", + "rank", + "brokenBacklinks", +]); + +/** Single source for each tab's default sort, shared by the request-schema + * defaults and the client's header indicators / query fallbacks. */ +export const BACKLINKS_DEFAULT_SORT = { + backlinks: { field: "rank", order: "desc" }, + domains: { field: "backlinks", order: "desc" }, + pages: { field: "backlinks", order: "desc" }, +} as const satisfies Record< + z.infer, + { field: string; order: z.infer } +>; + +const backlinksPageRequestBase = backlinksLookupSchema.extend({ + projectId: z.string().min(1), + page: z.number().int().positive().default(1), + pageSize: z + .number() + .int() + .refine((value) => + (BACKLINKS_PAGE_SIZES as readonly number[]).includes(value), + ) + .default(DEFAULT_BACKLINKS_PAGE_SIZE), + sortOrder: backlinksSortOrderSchema.default("desc"), +}); + +export const backlinksRowsPageRequestSchema = backlinksPageRequestBase.extend({ + sortField: backlinksRowsSortFieldSchema.default( + BACKLINKS_DEFAULT_SORT.backlinks.field, + ), + filters: backlinksRowsFiltersSchema.default({}), + mode: backlinksRowsModeSchema.default("one_per_domain"), +}); + +export const referringDomainsPageRequestSchema = + backlinksPageRequestBase.extend({ + sortField: referringDomainsSortFieldSchema.default( + BACKLINKS_DEFAULT_SORT.domains.field, + ), + filters: referringDomainsFiltersSchema.default({}), + }); + +export const topPagesPageRequestSchema = backlinksPageRequestBase.extend({ + sortField: topPagesSortFieldSchema.default( + BACKLINKS_DEFAULT_SORT.pages.field, + ), + filters: topPagesFiltersSchema.default({}), +}); export const backlinksSearchSchema = z.object({ target: z.string().optional(), scope: backlinksTargetScopeSchema.optional(), tab: backlinksTabSchema.optional(), + page: z.coerce.number().int().positive().optional().catch(undefined), + size: z.coerce + .number() + .int() + .refine((value) => + (BACKLINKS_PAGE_SIZES as readonly number[]).includes(value), + ) + .optional() + .catch(undefined), + // Sort column id for the active tab; validated against the tab's sort-field + // enum when building the request, so a mismatched value falls back to the + // tab's default sort. + sort: z.string().optional().catch(undefined), + order: backlinksSortOrderSchema.optional().catch(undefined), + // Backlinks tab only: "all" shows every link; default is one per domain. + view: z.literal("all").optional().catch(undefined), }); export type BacklinksLookupInput = z.infer; export type BacklinksTab = z.infer; export type BacklinksTargetScope = z.infer; +export type BacklinksSortOrder = z.infer; +export type BacklinksRowsSortField = z.infer< + typeof backlinksRowsSortFieldSchema +>; +export type ReferringDomainsSortField = z.infer< + typeof referringDomainsSortFieldSchema +>; +export type TopPagesSortField = z.infer; +export type BacklinksRowsFilters = z.infer; +export type ReferringDomainsFilters = z.infer< + typeof referringDomainsFiltersSchema +>; +export type TopPagesFilters = z.infer; +export type BacklinksRowsPageInput = z.infer< + typeof backlinksRowsPageRequestSchema +>; +export type ReferringDomainsPageInput = z.infer< + typeof referringDomainsPageRequestSchema +>; +export type TopPagesPageInput = z.infer;