Backlinks: server-side pagination, sorting, filters, and a one-per-domain view (#258)
This commit is contained in:
parent
977c69c3de
commit
16b92ccea3
@ -42,22 +42,45 @@ async function main() {
|
|||||||
const includeTabs = parseBoolean(args.includeTabs, true);
|
const includeTabs = parseBoolean(args.includeTabs, true);
|
||||||
const runs = [];
|
const runs = [];
|
||||||
|
|
||||||
|
const pageInput = {
|
||||||
|
...input,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 100,
|
||||||
|
sortOrder: "desc",
|
||||||
|
} as const;
|
||||||
|
|
||||||
for (let index = 0; index < repeat; index += 1) {
|
for (let index = 0; index < repeat; index += 1) {
|
||||||
const overview = await service.profileOverview(input, billingCustomer);
|
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
|
const domains = includeTabs
|
||||||
? await service.profileReferringDomains(input, billingCustomer)
|
? await service.profileReferringDomainsPage(
|
||||||
|
{ ...pageInput, sortField: "backlinks", filters: {} },
|
||||||
|
billingCustomer,
|
||||||
|
)
|
||||||
: null;
|
: null;
|
||||||
const pages = includeTabs
|
const pages = includeTabs
|
||||||
? await service.profileTopPages(input, billingCustomer)
|
? await service.profileTopPagesPage(
|
||||||
|
{ ...pageInput, sortField: "backlinks", filters: {} },
|
||||||
|
billingCustomer,
|
||||||
|
)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
runs.push({
|
runs.push({
|
||||||
run: index + 1,
|
run: index + 1,
|
||||||
overview: {
|
overview: {
|
||||||
backlinksRows: overview.overview.backlinks.length,
|
|
||||||
trendRows: overview.overview.trends.length,
|
trendRows: overview.overview.trends.length,
|
||||||
newLostRows: overview.overview.newLostTrends.length,
|
newLostRows: overview.overview.newLostTrends.length,
|
||||||
},
|
},
|
||||||
|
backlinksTab: rows
|
||||||
|
? {
|
||||||
|
rows: rows.rows.length,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
domainsTab: domains
|
domainsTab: domains
|
||||||
? {
|
? {
|
||||||
rows: domains.rows.length,
|
rows: domains.rows.length,
|
||||||
|
|||||||
97
src/client/components/table/TablePagination.tsx
Normal file
97
src/client/components/table/TablePagination.tsx
Normal file
@ -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 (
|
||||||
|
<div className="flex flex-col gap-3 border-t border-base-300 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-base-content/70 tabular-nums">
|
||||||
|
<span>{formatRange(page, pageSize, totalCount)}</span>
|
||||||
|
{isLoading ? (
|
||||||
|
<span className="loading loading-spinner loading-xs" />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<label className="flex items-center gap-2 text-sm text-base-content/70">
|
||||||
|
<span className="whitespace-nowrap">Rows per page</span>
|
||||||
|
<select
|
||||||
|
className="select select-bordered select-sm w-20"
|
||||||
|
value={pageSize}
|
||||||
|
onChange={(event) => onPageSizeChange(Number(event.target.value))}
|
||||||
|
>
|
||||||
|
{pageSizes.map((size) => (
|
||||||
|
<option key={size} value={size}>
|
||||||
|
{size}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="whitespace-nowrap text-sm tabular-nums text-base-content/70">
|
||||||
|
Page {page.toLocaleString()}
|
||||||
|
{totalPages != null ? ` of ${totalPages.toLocaleString()}` : ""}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Previous page"
|
||||||
|
className="btn btn-ghost btn-sm btn-square"
|
||||||
|
disabled={!canGoPrev || isLoading}
|
||||||
|
onClick={() => onPageChange(page - 1)}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="size-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Next page"
|
||||||
|
className="btn btn-ghost btn-sm btn-square"
|
||||||
|
disabled={!canGoNext || isLoading}
|
||||||
|
onClick={() => onPageChange(page + 1)}
|
||||||
|
>
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -7,20 +7,16 @@ import type { Row } from "@tanstack/react-table";
|
|||||||
* direction from the cell context and return a value that survives the flip.
|
* direction from the cell context and return a value that survives the flip.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export function isDescending<TData>(
|
function isDescending<TData>(row: Row<TData>, columnId: string): boolean {
|
||||||
row: Row<TData>,
|
|
||||||
columnId: string,
|
|
||||||
): boolean {
|
|
||||||
const cell = row.getAllCells().find((c) => c.column.id === columnId);
|
const cell = row.getAllCells().find((c) => c.column.id === columnId);
|
||||||
return cell?.column.getIsSorted() === "desc";
|
return cell?.column.getIsSorted() === "desc";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compare two nullable numeric values with nulls always at the bottom,
|
* Compare two nullable numeric values with nulls always at the bottom,
|
||||||
* regardless of the column's current sort direction. Use this directly for
|
* regardless of the column's current sort direction.
|
||||||
* tiebreakers or when the value isn't the column's accessor.
|
|
||||||
*/
|
*/
|
||||||
export function compareNumericNullsLast(
|
function compareNumericNullsLast(
|
||||||
a: number | null | undefined,
|
a: number | null | undefined,
|
||||||
b: number | null | undefined,
|
b: number | null | undefined,
|
||||||
descending: boolean,
|
descending: boolean,
|
||||||
@ -44,33 +40,3 @@ export function numericNullsLast<TData>(
|
|||||||
isDescending(rowA, columnId),
|
isDescending(rowA, columnId),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stringNullsLast<TData>(
|
|
||||||
rowA: Row<TData>,
|
|
||||||
rowB: Row<TData>,
|
|
||||||
columnId: string,
|
|
||||||
): number {
|
|
||||||
const a = rowA.getValue<string | null | undefined>(columnId);
|
|
||||||
const b = rowB.getValue<string | null | undefined>(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<TData>(
|
|
||||||
rowA: Row<TData>,
|
|
||||||
rowB: Row<TData>,
|
|
||||||
columnId: string,
|
|
||||||
): number {
|
|
||||||
const a = rowA.getValue<string | null | undefined>(columnId);
|
|
||||||
const b = rowB.getValue<string | null | undefined>(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);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,177 +1,194 @@
|
|||||||
import { RotateCcw } from "lucide-react";
|
import { DomainFilterPanel } from "@/client/features/domain/components/DomainFilterPanel";
|
||||||
import type { BacklinksTab } from "@/types/schemas/backlinks";
|
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";
|
import type { BacklinksFiltersState } from "./useBacklinksFilters";
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
/**
|
||||||
type AnyForm = { Field: React.ComponentType<any> };
|
* Filters are applied explicitly (not per keystroke) because every change
|
||||||
|
* triggers a billed DataForSEO request. Each include/exclude term and each
|
||||||
function FilterTextInput({
|
* set field costs one DataForSEO filter condition, capped per request —
|
||||||
form,
|
* DomainFilterPanel surfaces the count and gates Apply.
|
||||||
name,
|
*/
|
||||||
label,
|
export function BacklinksFilterPanel({
|
||||||
placeholder,
|
activeTab,
|
||||||
|
filters,
|
||||||
|
onApplied,
|
||||||
}: {
|
}: {
|
||||||
form: AnyForm;
|
activeTab: BacklinksTab;
|
||||||
name: string;
|
filters: BacklinksFiltersState;
|
||||||
label: string;
|
onApplied: () => void;
|
||||||
placeholder: string;
|
|
||||||
}) {
|
}) {
|
||||||
|
if (activeTab === "backlinks") {
|
||||||
|
const state = filters.backlinks;
|
||||||
return (
|
return (
|
||||||
<label className="form-control gap-1.5">
|
<DomainFilterPanel
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-base-content/60">
|
key="backlinks"
|
||||||
{label}
|
debugName="BacklinksFilterPanel"
|
||||||
</span>
|
appliedFilters={state.values}
|
||||||
<form.Field name={name}>
|
fields={BACKLINKS_FILTER_FIELDS}
|
||||||
{(field: {
|
activeFilterCount={state.activeFilterCount}
|
||||||
state: { value: string };
|
countConditions={countFilterConditions}
|
||||||
handleChange: (v: string) => void;
|
textFields={[
|
||||||
}) => (
|
{
|
||||||
<input
|
key: "include",
|
||||||
className="input input-bordered input-sm w-full bg-base-100"
|
label: "Source URL Contains",
|
||||||
placeholder={placeholder}
|
placeholder: "example.com, blog",
|
||||||
value={field.state.value}
|
},
|
||||||
onChange={(event) => field.handleChange(event.target.value)}
|
{
|
||||||
/>
|
key: "exclude",
|
||||||
|
label: "Source URL Excludes",
|
||||||
|
placeholder: "spam, forum",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
rangeFields={[
|
||||||
|
{
|
||||||
|
title: "Domain Authority",
|
||||||
|
minKey: "minDomainRank",
|
||||||
|
maxKey: "maxDomainRank",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Link Authority",
|
||||||
|
minKey: "minLinkAuthority",
|
||||||
|
maxKey: "maxLinkAuthority",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Spam Score",
|
||||||
|
minKey: "minSpamScore",
|
||||||
|
maxKey: "maxSpamScore",
|
||||||
|
step: "0.1",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onApply={(values) => {
|
||||||
|
state.apply(values);
|
||||||
|
onApplied();
|
||||||
|
}}
|
||||||
|
onClear={() => {
|
||||||
|
state.reset();
|
||||||
|
onApplied();
|
||||||
|
}}
|
||||||
|
renderExtra={(draft, setValue) => (
|
||||||
|
<BacklinksToggleControls draft={draft} setValue={setValue} />
|
||||||
)}
|
)}
|
||||||
</form.Field>
|
/>
|
||||||
</label>
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeTab === "domains") {
|
||||||
|
const state = filters.domains;
|
||||||
|
return (
|
||||||
|
<DomainFilterPanel
|
||||||
|
key="domains"
|
||||||
|
debugName="ReferringDomainsFilterPanel"
|
||||||
|
appliedFilters={state.values}
|
||||||
|
fields={REFERRING_DOMAINS_FILTER_FIELDS}
|
||||||
|
activeFilterCount={state.activeFilterCount}
|
||||||
|
countConditions={countFilterConditions}
|
||||||
|
textFields={[
|
||||||
|
{
|
||||||
|
key: "include",
|
||||||
|
label: "Domain Contains",
|
||||||
|
placeholder: "example.com, blog",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "exclude",
|
||||||
|
label: "Domain Excludes",
|
||||||
|
placeholder: "spam, forum",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
rangeFields={[
|
||||||
|
{
|
||||||
|
title: "Backlinks",
|
||||||
|
minKey: "minBacklinks",
|
||||||
|
maxKey: "maxBacklinks",
|
||||||
|
},
|
||||||
|
{ title: "Rank", minKey: "minRank", maxKey: "maxRank" },
|
||||||
|
{
|
||||||
|
title: "Spam Score",
|
||||||
|
minKey: "minSpamScore",
|
||||||
|
maxKey: "maxSpamScore",
|
||||||
|
step: "0.1",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onApply={(values) => {
|
||||||
|
state.apply(values);
|
||||||
|
onApplied();
|
||||||
|
}}
|
||||||
|
onClear={() => {
|
||||||
|
state.reset();
|
||||||
|
onApplied();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = filters.pages;
|
||||||
|
return (
|
||||||
|
<DomainFilterPanel
|
||||||
|
key="pages"
|
||||||
|
debugName="TopPagesFilterPanel"
|
||||||
|
appliedFilters={state.values}
|
||||||
|
fields={TOP_PAGES_FILTER_FIELDS}
|
||||||
|
activeFilterCount={state.activeFilterCount}
|
||||||
|
countConditions={countFilterConditions}
|
||||||
|
textFields={[
|
||||||
|
{
|
||||||
|
key: "include",
|
||||||
|
label: "Page URL Contains",
|
||||||
|
placeholder: "/blog, /products",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "exclude",
|
||||||
|
label: "Page URL Excludes",
|
||||||
|
placeholder: "/tag, /author",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
rangeFields={[
|
||||||
|
{ title: "Backlinks", minKey: "minBacklinks", maxKey: "maxBacklinks" },
|
||||||
|
{
|
||||||
|
title: "Referring Domains",
|
||||||
|
minKey: "minReferringDomains",
|
||||||
|
maxKey: "maxReferringDomains",
|
||||||
|
},
|
||||||
|
{ title: "Rank", minKey: "minRank", maxKey: "maxRank" },
|
||||||
|
]}
|
||||||
|
onApply={(values) => {
|
||||||
|
state.apply(values);
|
||||||
|
onApplied();
|
||||||
|
}}
|
||||||
|
onClear={() => {
|
||||||
|
state.reset();
|
||||||
|
onApplied();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FilterRangeInputs({
|
function BacklinksToggleControls({
|
||||||
form,
|
draft,
|
||||||
title,
|
setValue,
|
||||||
minName,
|
|
||||||
maxName,
|
|
||||||
step,
|
|
||||||
}: {
|
}: {
|
||||||
form: AnyForm;
|
draft: BacklinksTabFilterValues;
|
||||||
title: string;
|
setValue: (key: keyof BacklinksTabFilterValues, value: string) => void;
|
||||||
minName: string;
|
|
||||||
maxName: string;
|
|
||||||
step?: string;
|
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-base-300 bg-base-100 p-2.5 space-y-2">
|
|
||||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-base-content/60">
|
|
||||||
{title}
|
|
||||||
</p>
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
<CompactRangeInput
|
|
||||||
form={form}
|
|
||||||
name={minName}
|
|
||||||
placeholder="Min"
|
|
||||||
step={step}
|
|
||||||
/>
|
|
||||||
<CompactRangeInput
|
|
||||||
form={form}
|
|
||||||
name={maxName}
|
|
||||||
placeholder="Max"
|
|
||||||
step={step}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CompactRangeInput({
|
|
||||||
form,
|
|
||||||
name,
|
|
||||||
placeholder,
|
|
||||||
step,
|
|
||||||
}: {
|
|
||||||
form: AnyForm;
|
|
||||||
name: string;
|
|
||||||
placeholder: string;
|
|
||||||
step?: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<form.Field name={name}>
|
|
||||||
{(field: {
|
|
||||||
state: { value: string };
|
|
||||||
handleChange: (v: string) => void;
|
|
||||||
}) => (
|
|
||||||
<input
|
|
||||||
className="input input-bordered input-xs bg-base-100"
|
|
||||||
placeholder={placeholder}
|
|
||||||
type="number"
|
|
||||||
step={step}
|
|
||||||
value={field.state.value}
|
|
||||||
onChange={(event) => field.handleChange(event.target.value)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</form.Field>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function BacklinksTabFilters({
|
|
||||||
form,
|
|
||||||
showAhrefsDrFilter,
|
|
||||||
}: {
|
|
||||||
form: BacklinksFiltersState["backlinks"]["form"];
|
|
||||||
showAhrefsDrFilter: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
|
||||||
<FilterTextInput
|
|
||||||
form={form}
|
|
||||||
name="include"
|
|
||||||
label="Include Terms"
|
|
||||||
placeholder="example.com, blog"
|
|
||||||
/>
|
|
||||||
<FilterTextInput
|
|
||||||
form={form}
|
|
||||||
name="exclude"
|
|
||||||
label="Exclude Terms"
|
|
||||||
placeholder="spam, forum"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-2 lg:grid-cols-3">
|
|
||||||
<FilterRangeInputs
|
|
||||||
form={form}
|
|
||||||
title="Domain Authority"
|
|
||||||
minName="minDomainRank"
|
|
||||||
maxName="maxDomainRank"
|
|
||||||
/>
|
|
||||||
{showAhrefsDrFilter ? (
|
|
||||||
<FilterRangeInputs
|
|
||||||
form={form}
|
|
||||||
title="Ahrefs DR"
|
|
||||||
minName="minAhrefsDr"
|
|
||||||
maxName="maxAhrefsDr"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<FilterRangeInputs
|
|
||||||
form={form}
|
|
||||||
title="Link Authority"
|
|
||||||
minName="minLinkAuthority"
|
|
||||||
maxName="maxLinkAuthority"
|
|
||||||
/>
|
|
||||||
<FilterRangeInputs
|
|
||||||
form={form}
|
|
||||||
title="Spam Score"
|
|
||||||
minName="minSpamScore"
|
|
||||||
maxName="maxSpamScore"
|
|
||||||
step="0.1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-4">
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-base-content/60">
|
<p className="text-[11px] font-semibold uppercase tracking-wide text-base-content/60">
|
||||||
Link Type
|
Link Type
|
||||||
</p>
|
</p>
|
||||||
<form.Field name="linkType">
|
|
||||||
{(field) => (
|
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{(["", "dofollow", "nofollow"] as const).map((value) => (
|
{(["", "dofollow", "nofollow"] as const).map((value) => (
|
||||||
<button
|
<button
|
||||||
key={value || "all"}
|
key={value || "all"}
|
||||||
type="button"
|
type="button"
|
||||||
className={`btn btn-xs ${field.state.value === value ? "btn-soft" : "btn-ghost"}`}
|
className={`btn btn-xs ${draft.linkType === value ? "btn-soft" : "btn-ghost"}`}
|
||||||
onClick={() => field.handleChange(value)}
|
onClick={() => setValue("linkType", value)}
|
||||||
>
|
>
|
||||||
{value === ""
|
{value === ""
|
||||||
? "All"
|
? "All"
|
||||||
@ -181,8 +198,6 @@ function BacklinksTabFilters({
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</form.Field>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
@ -190,196 +205,30 @@ function BacklinksTabFilters({
|
|||||||
Visibility
|
Visibility
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<form.Field name="hideLost">
|
|
||||||
{(field) => (
|
|
||||||
<label className="flex items-center gap-1.5 cursor-pointer">
|
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="checkbox checkbox-xs"
|
className="checkbox checkbox-xs"
|
||||||
checked={field.state.value === "true"}
|
checked={draft.hideLost === "true"}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
field.handleChange(event.target.checked ? "true" : "")
|
setValue("hideLost", event.target.checked ? "true" : "")
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<span className="text-xs">Hide lost</span>
|
<span className="text-xs">Hide lost</span>
|
||||||
</label>
|
</label>
|
||||||
)}
|
|
||||||
</form.Field>
|
|
||||||
<form.Field name="hideBroken">
|
|
||||||
{(field) => (
|
|
||||||
<label className="flex items-center gap-1.5 cursor-pointer">
|
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="checkbox checkbox-xs"
|
className="checkbox checkbox-xs"
|
||||||
checked={field.state.value === "true"}
|
checked={draft.hideBroken === "true"}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
field.handleChange(event.target.checked ? "true" : "")
|
setValue("hideBroken", event.target.checked ? "true" : "")
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<span className="text-xs">Hide broken</span>
|
<span className="text-xs">Hide broken</span>
|
||||||
</label>
|
</label>
|
||||||
)}
|
|
||||||
</form.Field>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ReferringDomainsFilters({
|
|
||||||
form,
|
|
||||||
showAhrefsDrFilter,
|
|
||||||
}: {
|
|
||||||
form: BacklinksFiltersState["domains"]["form"];
|
|
||||||
showAhrefsDrFilter: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
|
||||||
<FilterTextInput
|
|
||||||
form={form}
|
|
||||||
name="include"
|
|
||||||
label="Include Terms"
|
|
||||||
placeholder="example.com, blog"
|
|
||||||
/>
|
|
||||||
<FilterTextInput
|
|
||||||
form={form}
|
|
||||||
name="exclude"
|
|
||||||
label="Exclude Terms"
|
|
||||||
placeholder="spam, forum"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-2 lg:grid-cols-3">
|
|
||||||
<FilterRangeInputs
|
|
||||||
form={form}
|
|
||||||
title="Backlinks"
|
|
||||||
minName="minBacklinks"
|
|
||||||
maxName="maxBacklinks"
|
|
||||||
/>
|
|
||||||
<FilterRangeInputs
|
|
||||||
form={form}
|
|
||||||
title="Rank"
|
|
||||||
minName="minRank"
|
|
||||||
maxName="maxRank"
|
|
||||||
/>
|
|
||||||
{showAhrefsDrFilter ? (
|
|
||||||
<FilterRangeInputs
|
|
||||||
form={form}
|
|
||||||
title="Ahrefs DR"
|
|
||||||
minName="minAhrefsDr"
|
|
||||||
maxName="maxAhrefsDr"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<FilterRangeInputs
|
|
||||||
form={form}
|
|
||||||
title="Spam Score"
|
|
||||||
minName="minSpamScore"
|
|
||||||
maxName="maxSpamScore"
|
|
||||||
step="0.1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TopPagesFilters({
|
|
||||||
form,
|
|
||||||
}: {
|
|
||||||
form: BacklinksFiltersState["pages"]["form"];
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
|
||||||
<FilterTextInput
|
|
||||||
form={form}
|
|
||||||
name="include"
|
|
||||||
label="Include Terms"
|
|
||||||
placeholder="/blog, /products"
|
|
||||||
/>
|
|
||||||
<FilterTextInput
|
|
||||||
form={form}
|
|
||||||
name="exclude"
|
|
||||||
label="Exclude Terms"
|
|
||||||
placeholder="/tag, /author"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-2 lg:grid-cols-3">
|
|
||||||
<FilterRangeInputs
|
|
||||||
form={form}
|
|
||||||
title="Backlinks"
|
|
||||||
minName="minBacklinks"
|
|
||||||
maxName="maxBacklinks"
|
|
||||||
/>
|
|
||||||
<FilterRangeInputs
|
|
||||||
form={form}
|
|
||||||
title="Referring Domains"
|
|
||||||
minName="minReferringDomains"
|
|
||||||
maxName="maxReferringDomains"
|
|
||||||
/>
|
|
||||||
<FilterRangeInputs
|
|
||||||
form={form}
|
|
||||||
title="Rank"
|
|
||||||
minName="minRank"
|
|
||||||
maxName="maxRank"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BacklinksFilterPanel({
|
|
||||||
activeTab,
|
|
||||||
filters,
|
|
||||||
showAhrefsDrFilter,
|
|
||||||
activeFilterCount,
|
|
||||||
}: {
|
|
||||||
activeTab: BacklinksTab;
|
|
||||||
filters: BacklinksFiltersState;
|
|
||||||
showAhrefsDrFilter: boolean;
|
|
||||||
activeFilterCount: number;
|
|
||||||
}) {
|
|
||||||
const current = filters[activeTab];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="shrink-0 border-b border-base-300 bg-gradient-to-b from-base-100 to-base-200/30 px-4 py-3 space-y-3">
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<p className="text-sm font-semibold">Refine results</p>
|
|
||||||
{activeFilterCount > 0 ? (
|
|
||||||
<span className="badge badge-xs badge-primary border-0 text-primary-content">
|
|
||||||
{activeFilterCount} active
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-xs btn-ghost gap-1"
|
|
||||||
onClick={current.reset}
|
|
||||||
disabled={activeFilterCount === 0}
|
|
||||||
>
|
|
||||||
<RotateCcw className="size-3" />
|
|
||||||
Clear all
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{activeTab === "backlinks" ? (
|
|
||||||
<BacklinksTabFilters
|
|
||||||
form={filters.backlinks.form}
|
|
||||||
showAhrefsDrFilter={showAhrefsDrFilter}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{activeTab === "domains" ? (
|
|
||||||
<ReferringDomainsFilters
|
|
||||||
form={filters.domains.form}
|
|
||||||
showAhrefsDrFilter={showAhrefsDrFilter}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{activeTab === "pages" ? (
|
|
||||||
<TopPagesFilters form={filters.pages.form} />
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -55,6 +55,9 @@ export function BacklinksHistorySection({
|
|||||||
target: item.target,
|
target: item.target,
|
||||||
scope: item.scope,
|
scope: item.scope,
|
||||||
tab: undefined,
|
tab: undefined,
|
||||||
|
page: undefined,
|
||||||
|
sort: undefined,
|
||||||
|
order: undefined,
|
||||||
})}
|
})}
|
||||||
replace
|
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"
|
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"
|
||||||
|
|||||||
152
src/client/features/backlinks/BacklinksOverviewPanels.tsx
Normal file
152
src/client/features/backlinks/BacklinksOverviewPanels.tsx
Normal file
@ -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 (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<Link
|
||||||
|
to="/p/$projectId/backlinks"
|
||||||
|
params={{ projectId }}
|
||||||
|
search={{
|
||||||
|
target: undefined,
|
||||||
|
scope: undefined,
|
||||||
|
tab: undefined,
|
||||||
|
page: undefined,
|
||||||
|
size: undefined,
|
||||||
|
sort: undefined,
|
||||||
|
order: undefined,
|
||||||
|
}}
|
||||||
|
replace
|
||||||
|
className="btn btn-ghost btn-sm gap-2 px-0 text-base-content/70 hover:bg-transparent"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="size-4" />
|
||||||
|
Recent searches
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-sm text-base-content/65">
|
||||||
|
<span className="badge badge-outline">{data.scope}</span>
|
||||||
|
<span>Target: {data.displayTarget}</span>
|
||||||
|
<span>-</span>
|
||||||
|
<span>Updated {formatRelativeTimestamp(data.fetchedAt)}</span>
|
||||||
|
</div>
|
||||||
|
<OverviewGrid data={data} summaryStats={summaryStats} />
|
||||||
|
{data.scope === "page" ? (
|
||||||
|
<div className="alert alert-info">
|
||||||
|
<span>
|
||||||
|
Showing backlinks for this exact page. Enter a bare domain for
|
||||||
|
site-wide results. Trend charts are only shown for domain-level
|
||||||
|
lookups.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OverviewGrid({
|
||||||
|
data,
|
||||||
|
summaryStats,
|
||||||
|
}: {
|
||||||
|
data: BacklinksOverviewData;
|
||||||
|
summaryStats: SummaryStat[];
|
||||||
|
}) {
|
||||||
|
const domainScope = data.scope === "domain";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`grid grid-cols-1 gap-3 ${domainScope ? "md:grid-cols-2 xl:grid-cols-3" : ""}`}
|
||||||
|
>
|
||||||
|
<SummaryStatsGrid data={data} summaryStats={summaryStats} />
|
||||||
|
{domainScope ? <TrendPanels data={data} /> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className={cardClassName}>
|
||||||
|
<div className="card-body p-4 xl:h-full">
|
||||||
|
<div className="grid grid-cols-2 gap-x-6 gap-y-5 xl:gap-y-6">
|
||||||
|
{summaryStats.map((item) => (
|
||||||
|
<div key={item.label}>
|
||||||
|
<div className="text-xs uppercase tracking-wide text-base-content/55">
|
||||||
|
<HeaderHelpLabel
|
||||||
|
label={item.label}
|
||||||
|
helpText={item.description}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-semibold">{item.value}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TrendPanels({ data }: { data: BacklinksOverviewData }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TrendCard
|
||||||
|
title="Backlink growth"
|
||||||
|
description="Backlinks and referring domains over the last year"
|
||||||
|
>
|
||||||
|
<BacklinksTrendChart data={data.trends} />
|
||||||
|
</TrendCard>
|
||||||
|
<TrendCard
|
||||||
|
title="New vs lost"
|
||||||
|
description="Backlink acquisition and attrition"
|
||||||
|
>
|
||||||
|
<BacklinksNewLostChart data={data.newLostTrends} />
|
||||||
|
</TrendCard>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TrendCard({
|
||||||
|
children,
|
||||||
|
description,
|
||||||
|
title,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
description: string;
|
||||||
|
title: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="card bg-base-100 border border-base-300">
|
||||||
|
<div className="card-body gap-2 p-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-medium">{title}</h2>
|
||||||
|
<p className="text-xs text-base-content/55">{description}</p>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
|
import type { SortingState, Updater } from "@tanstack/react-table";
|
||||||
import { BacklinksSearchCard } from "./BacklinksSearchCard";
|
import { BacklinksSearchCard } from "./BacklinksSearchCard";
|
||||||
import { BacklinksBody } from "./BacklinksPageContent";
|
import { BacklinksBody } from "./BacklinksPageContent";
|
||||||
import type { BacklinksPageProps } from "./backlinksPageTypes";
|
import type { BacklinksPageProps } from "./backlinksPageTypes";
|
||||||
@ -7,6 +8,7 @@ import {
|
|||||||
navigateToBacklinksSearch,
|
navigateToBacklinksSearch,
|
||||||
useBacklinksPageData,
|
useBacklinksPageData,
|
||||||
} from "./useBacklinksPageData";
|
} from "./useBacklinksPageData";
|
||||||
|
import { useBacklinksDomainExpansion } from "./useBacklinksDomainExpansion";
|
||||||
import { useBacklinksFilters } from "./useBacklinksFilters";
|
import { useBacklinksFilters } from "./useBacklinksFilters";
|
||||||
import { useBacklinksSearchHistory } from "@/client/hooks/useBacklinksSearchHistory";
|
import { useBacklinksSearchHistory } from "@/client/hooks/useBacklinksSearchHistory";
|
||||||
import type {
|
import type {
|
||||||
@ -14,6 +16,10 @@ import type {
|
|||||||
SearchTabInput,
|
SearchTabInput,
|
||||||
} from "@/client/features/search-tabs/types";
|
} from "@/client/features/search-tabs/types";
|
||||||
import { useSearchTabNavigation } from "@/client/features/search-tabs/useSearchTabNavigation";
|
import { useSearchTabNavigation } from "@/client/features/search-tabs/useSearchTabNavigation";
|
||||||
|
import {
|
||||||
|
BACKLINKS_DEFAULT_SORT,
|
||||||
|
DEFAULT_BACKLINKS_PAGE_SIZE,
|
||||||
|
} from "@/types/schemas/backlinks";
|
||||||
|
|
||||||
export function BacklinksPage({
|
export function BacklinksPage({
|
||||||
projectId,
|
projectId,
|
||||||
@ -21,18 +27,94 @@ export function BacklinksPage({
|
|||||||
navigate,
|
navigate,
|
||||||
}: BacklinksPageProps) {
|
}: BacklinksPageProps) {
|
||||||
const filters = useBacklinksFilters();
|
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<SortingState>(() => {
|
||||||
|
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<SortingState>) => {
|
||||||
|
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 {
|
const {
|
||||||
accessGate,
|
accessGate,
|
||||||
activeTabErrorMessage,
|
activeTabErrorMessage,
|
||||||
|
activeTabQuery,
|
||||||
backlinksDisabledByError,
|
backlinksDisabledByError,
|
||||||
overviewErrorMessage,
|
overviewErrorMessage,
|
||||||
overviewQuery,
|
overviewQuery,
|
||||||
referringDomainsQuery,
|
referringDomainsQuery,
|
||||||
|
rowsQuery,
|
||||||
searchCardInitialValues,
|
searchCardInitialValues,
|
||||||
topPagesQuery,
|
topPagesQuery,
|
||||||
} = useBacklinksPageData({
|
} = useBacklinksPageData({
|
||||||
projectId,
|
projectId,
|
||||||
searchState,
|
searchState,
|
||||||
|
filters,
|
||||||
});
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@ -71,6 +153,9 @@ export function BacklinksPage({
|
|||||||
search: (prev) => ({
|
search: (prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
tab: tab === "backlinks" ? undefined : tab,
|
tab: tab === "backlinks" ? undefined : tab,
|
||||||
|
page: undefined,
|
||||||
|
sort: undefined,
|
||||||
|
order: undefined,
|
||||||
}),
|
}),
|
||||||
replace: true,
|
replace: true,
|
||||||
});
|
});
|
||||||
@ -134,19 +219,23 @@ export function BacklinksPage({
|
|||||||
overviewData={overviewQuery.data}
|
overviewData={overviewQuery.data}
|
||||||
overviewError={overviewErrorMessage}
|
overviewError={overviewErrorMessage}
|
||||||
overviewLoading={overviewQuery.isLoading}
|
overviewLoading={overviewQuery.isLoading}
|
||||||
referringDomains={referringDomainsQuery.data}
|
backlinksRowsPage={rowsQuery.data}
|
||||||
|
referringDomainsPage={referringDomainsQuery.data}
|
||||||
|
topPagesPage={topPagesQuery.data}
|
||||||
searchState={searchState}
|
searchState={searchState}
|
||||||
filters={filters}
|
filters={filters}
|
||||||
|
sorting={sorting}
|
||||||
|
domainExpansion={domainExpansion}
|
||||||
tabErrorMessage={activeTabErrorMessage}
|
tabErrorMessage={activeTabErrorMessage}
|
||||||
tabLoading={
|
tabLoading={activeTabQuery.isLoading}
|
||||||
(searchState.tab === "domains" &&
|
tabFetching={activeTabQuery.isFetching}
|
||||||
referringDomainsQuery.isLoading) ||
|
onPageChange={handlePageChange}
|
||||||
(searchState.tab === "pages" && topPagesQuery.isLoading)
|
onPageSizeChange={handlePageSizeChange}
|
||||||
}
|
|
||||||
topPages={topPagesQuery.data}
|
|
||||||
onRemoveHistoryItem={removeHistoryItem}
|
onRemoveHistoryItem={removeHistoryItem}
|
||||||
onRetryOverview={() => void overviewQuery.refetch()}
|
onRetryOverview={() => void overviewQuery.refetch()}
|
||||||
|
onSortingChange={handleSortingChange}
|
||||||
onTabChange={handleResultTabChange}
|
onTabChange={handleResultTabChange}
|
||||||
|
onViewChange={handleViewChange}
|
||||||
searchTabs={
|
searchTabs={
|
||||||
searchState.target
|
searchState.target
|
||||||
? {
|
? {
|
||||||
|
|||||||
@ -1,8 +1,7 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import {
|
import type { OnChangeFn, SortingState } from "@tanstack/react-table";
|
||||||
BacklinksOverviewPanels,
|
import { BacklinksOverviewPanels } from "./BacklinksOverviewPanels";
|
||||||
BacklinksResultsCard,
|
import { BacklinksResultsCard } from "./BacklinksPageSections";
|
||||||
} from "./BacklinksPageSections";
|
|
||||||
import {
|
import {
|
||||||
BacklinksErrorState,
|
BacklinksErrorState,
|
||||||
BacklinksLoadingState,
|
BacklinksLoadingState,
|
||||||
@ -13,17 +12,15 @@ import type { BacklinksSearchHistoryItem } from "@/client/hooks/useBacklinksSear
|
|||||||
import type {
|
import type {
|
||||||
BacklinksOverviewData,
|
BacklinksOverviewData,
|
||||||
BacklinksReferringDomainsData,
|
BacklinksReferringDomainsData,
|
||||||
|
BacklinksRowsPageData,
|
||||||
BacklinksSearchState,
|
BacklinksSearchState,
|
||||||
|
BacklinksTabRows,
|
||||||
BacklinksTopPagesData,
|
BacklinksTopPagesData,
|
||||||
} from "./backlinksPageTypes";
|
} from "./backlinksPageTypes";
|
||||||
import type { UseAccessGateResult } from "@/client/features/access-gate/useAccessGate";
|
import type { UseAccessGateResult } from "@/client/features/access-gate/useAccessGate";
|
||||||
import { AccessGateLoadingState } from "@/client/features/access-gate/AccessGate";
|
import { AccessGateLoadingState } from "@/client/features/access-gate/AccessGate";
|
||||||
import { buildSummaryStats } from "./backlinksPageUtils";
|
import { buildSummaryStats } from "./backlinksPageUtils";
|
||||||
import {
|
import type { BacklinksDomainExpansion } from "./useBacklinksDomainExpansion";
|
||||||
filterBacklinkRows,
|
|
||||||
filterReferringDomainRows,
|
|
||||||
filterTopPageRows,
|
|
||||||
} from "./backlinksFiltering";
|
|
||||||
import type { BacklinksFiltersState } from "./useBacklinksFilters";
|
import type { BacklinksFiltersState } from "./useBacklinksFilters";
|
||||||
import {
|
import {
|
||||||
SearchTabStrip,
|
SearchTabStrip,
|
||||||
@ -39,15 +36,23 @@ type BacklinksBodyProps = {
|
|||||||
overviewData: BacklinksOverviewData | undefined;
|
overviewData: BacklinksOverviewData | undefined;
|
||||||
overviewError: string | null;
|
overviewError: string | null;
|
||||||
overviewLoading: boolean;
|
overviewLoading: boolean;
|
||||||
referringDomains: BacklinksReferringDomainsData | undefined;
|
backlinksRowsPage: BacklinksRowsPageData | undefined;
|
||||||
|
referringDomainsPage: BacklinksReferringDomainsData | undefined;
|
||||||
|
topPagesPage: BacklinksTopPagesData | undefined;
|
||||||
searchState: BacklinksSearchState;
|
searchState: BacklinksSearchState;
|
||||||
filters: BacklinksFiltersState;
|
filters: BacklinksFiltersState;
|
||||||
|
sorting: SortingState;
|
||||||
|
domainExpansion: BacklinksDomainExpansion;
|
||||||
tabErrorMessage: string | null;
|
tabErrorMessage: string | null;
|
||||||
tabLoading: boolean;
|
tabLoading: boolean;
|
||||||
topPages: BacklinksTopPagesData | undefined;
|
tabFetching: boolean;
|
||||||
|
onPageChange: (nextPage: number) => void;
|
||||||
|
onPageSizeChange: (nextPageSize: number) => void;
|
||||||
onRemoveHistoryItem: (timestamp: number) => void;
|
onRemoveHistoryItem: (timestamp: number) => void;
|
||||||
onRetryOverview: () => void;
|
onRetryOverview: () => void;
|
||||||
|
onSortingChange: OnChangeFn<SortingState>;
|
||||||
onTabChange: (tab: BacklinksSearchState["tab"]) => void;
|
onTabChange: (tab: BacklinksSearchState["tab"]) => void;
|
||||||
|
onViewChange: (view: "all" | undefined) => void;
|
||||||
searchTabs: {
|
searchTabs: {
|
||||||
activeTabId: string | null;
|
activeTabId: string | null;
|
||||||
tabs: SearchTab[];
|
tabs: SearchTab[];
|
||||||
@ -66,45 +71,42 @@ export function BacklinksBody({
|
|||||||
overviewData,
|
overviewData,
|
||||||
overviewError,
|
overviewError,
|
||||||
overviewLoading,
|
overviewLoading,
|
||||||
referringDomains,
|
backlinksRowsPage,
|
||||||
|
referringDomainsPage,
|
||||||
|
topPagesPage,
|
||||||
searchState,
|
searchState,
|
||||||
filters,
|
filters,
|
||||||
|
sorting,
|
||||||
|
domainExpansion,
|
||||||
tabErrorMessage,
|
tabErrorMessage,
|
||||||
tabLoading,
|
tabLoading,
|
||||||
topPages,
|
tabFetching,
|
||||||
|
onPageChange,
|
||||||
|
onPageSizeChange,
|
||||||
onRemoveHistoryItem,
|
onRemoveHistoryItem,
|
||||||
onRetryOverview,
|
onRetryOverview,
|
||||||
|
onSortingChange,
|
||||||
onTabChange,
|
onTabChange,
|
||||||
|
onViewChange,
|
||||||
searchTabs,
|
searchTabs,
|
||||||
}: BacklinksBodyProps) {
|
}: BacklinksBodyProps) {
|
||||||
const mergedData = useMemo(
|
const tabRows = useMemo<BacklinksTabRows>(
|
||||||
() => mergeTabData(overviewData, referringDomains, topPages),
|
() => ({
|
||||||
[overviewData, referringDomains, topPages],
|
backlinks: backlinksRowsPage?.rows ?? [],
|
||||||
|
referringDomains: referringDomainsPage?.rows ?? [],
|
||||||
|
topPages: topPagesPage?.rows ?? [],
|
||||||
|
}),
|
||||||
|
[backlinksRowsPage, referringDomainsPage, topPagesPage],
|
||||||
);
|
);
|
||||||
const filteredData = useMemo(() => {
|
const activeTabPage =
|
||||||
if (!mergedData) {
|
searchState.tab === "backlinks"
|
||||||
return { backlinks: [], referringDomains: [], topPages: [] };
|
? backlinksRowsPage
|
||||||
}
|
: searchState.tab === "domains"
|
||||||
return {
|
? referringDomainsPage
|
||||||
backlinks: filterBacklinkRows(
|
: topPagesPage;
|
||||||
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 summaryStats = useMemo(
|
const summaryStats = useMemo(
|
||||||
() => buildSummaryStats(mergedData),
|
() => buildSummaryStats(overviewData),
|
||||||
[mergedData],
|
[overviewData],
|
||||||
);
|
);
|
||||||
const tabStrip = searchTabs ? (
|
const tabStrip = searchTabs ? (
|
||||||
<SearchTabStrip
|
<SearchTabStrip
|
||||||
@ -160,7 +162,7 @@ export function BacklinksBody({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!mergedData) {
|
if (!overviewData) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{tabStrip}
|
{tabStrip}
|
||||||
@ -177,37 +179,33 @@ export function BacklinksBody({
|
|||||||
{tabStrip}
|
{tabStrip}
|
||||||
<BacklinksOverviewPanels
|
<BacklinksOverviewPanels
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
data={mergedData}
|
data={overviewData}
|
||||||
summaryStats={summaryStats}
|
summaryStats={summaryStats}
|
||||||
/>
|
/>
|
||||||
<BacklinksResultsCard
|
<BacklinksResultsCard
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
activeTab={searchState.tab}
|
activeTab={searchState.tab}
|
||||||
filteredData={filteredData}
|
tabRows={tabRows}
|
||||||
filters={filters}
|
filters={filters}
|
||||||
isTabLoading={searchState.tab !== "backlinks" && tabLoading}
|
sorting={sorting}
|
||||||
tabErrorMessage={
|
view={searchState.view}
|
||||||
searchState.tab !== "backlinks" ? tabErrorMessage : null
|
domainExpansion={domainExpansion}
|
||||||
}
|
isTabLoading={tabLoading}
|
||||||
exportTarget={mergedData.displayTarget || searchState.target}
|
tabErrorMessage={tabErrorMessage}
|
||||||
|
exportTarget={overviewData.displayTarget || searchState.target}
|
||||||
|
pagination={{
|
||||||
|
page: searchState.page,
|
||||||
|
pageSize: searchState.pageSize,
|
||||||
|
totalCount: activeTabPage?.totalCount ?? null,
|
||||||
|
hasNextPage: activeTabPage?.hasMore ?? false,
|
||||||
|
isFetching: tabFetching,
|
||||||
|
}}
|
||||||
|
onPageChange={onPageChange}
|
||||||
|
onPageSizeChange={onPageSizeChange}
|
||||||
|
onSortingChange={onSortingChange}
|
||||||
onTabChange={onTabChange}
|
onTabChange={onTabChange}
|
||||||
|
onViewChange={onViewChange}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,34 +1,28 @@
|
|||||||
import { useEffect, useMemo } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
import { Link } from "@tanstack/react-router";
|
import { SlidersHorizontal } from "lucide-react";
|
||||||
import { HeaderHelpLabel } from "@/client/features/keywords/components";
|
import type { OnChangeFn, SortingState } from "@tanstack/react-table";
|
||||||
import { ArrowLeft, SlidersHorizontal } from "lucide-react";
|
|
||||||
import {
|
|
||||||
BacklinksNewLostChart,
|
|
||||||
BacklinksTrendChart,
|
|
||||||
} from "./BacklinksPageCharts";
|
|
||||||
import { BacklinksFilterPanel } from "./BacklinksFilterPanel";
|
import { BacklinksFilterPanel } from "./BacklinksFilterPanel";
|
||||||
import { BacklinksTable } from "./BacklinksTable";
|
import { BacklinksTable } from "./BacklinksTable";
|
||||||
import { ReferringDomainsTable } from "./ReferringDomainsTable";
|
import { ReferringDomainsTable } from "./ReferringDomainsTable";
|
||||||
import { TopPagesTable } from "./TopPagesTable";
|
import { TopPagesTable } from "./TopPagesTable";
|
||||||
import type {
|
import type {
|
||||||
BacklinksOverviewData,
|
|
||||||
BacklinksSearchState,
|
BacklinksSearchState,
|
||||||
|
BacklinksTabRows,
|
||||||
} from "./backlinksPageTypes";
|
} from "./backlinksPageTypes";
|
||||||
import {
|
import { TAB_DESCRIPTIONS } from "./backlinksPageUtils";
|
||||||
TAB_DESCRIPTIONS,
|
|
||||||
formatRelativeTimestamp,
|
|
||||||
} from "./backlinksPageUtils";
|
|
||||||
import {
|
import {
|
||||||
BacklinksActionsMenu,
|
BacklinksActionsMenu,
|
||||||
BacklinksExportMenu,
|
BacklinksExportMenu,
|
||||||
} from "./BacklinksToolbarMenus";
|
} from "./BacklinksToolbarMenus";
|
||||||
import { buildBacklinksTabExport } from "./export";
|
import { buildBacklinksTabExport } from "./export";
|
||||||
import {
|
import type { BacklinksDomainExpansion } from "./useBacklinksDomainExpansion";
|
||||||
filterBacklinkRows,
|
|
||||||
filterReferringDomainRows,
|
|
||||||
} from "./backlinksFiltering";
|
|
||||||
import type { BacklinksFiltersState } from "./useBacklinksFilters";
|
import type { BacklinksFiltersState } from "./useBacklinksFilters";
|
||||||
import { useAhrefsDomainRatings } from "./useAhrefsDomainRatings";
|
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<{
|
const BACKLINKS_RESULTS_TABS: Array<{
|
||||||
tab: BacklinksSearchState["tab"];
|
tab: BacklinksSearchState["tab"];
|
||||||
@ -39,118 +33,66 @@ const BACKLINKS_RESULTS_TABS: Array<{
|
|||||||
{ tab: "pages", label: "Top Pages" },
|
{ tab: "pages", label: "Top Pages" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function BacklinksOverviewPanels({
|
|
||||||
projectId,
|
|
||||||
data,
|
|
||||||
summaryStats,
|
|
||||||
}: {
|
|
||||||
projectId: string;
|
|
||||||
data: BacklinksOverviewData;
|
|
||||||
summaryStats: Array<{ label: string; value: string; description: string }>;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div>
|
|
||||||
<Link
|
|
||||||
to="/p/$projectId/backlinks"
|
|
||||||
params={{ projectId }}
|
|
||||||
search={{ target: undefined, scope: undefined, tab: undefined }}
|
|
||||||
replace
|
|
||||||
className="btn btn-ghost btn-sm gap-2 px-0 text-base-content/70 hover:bg-transparent"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="size-4" />
|
|
||||||
Recent searches
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap items-center gap-2 text-sm text-base-content/65">
|
|
||||||
<span className="badge badge-outline">{data.scope}</span>
|
|
||||||
<span>Target: {data.displayTarget}</span>
|
|
||||||
<span>-</span>
|
|
||||||
<span>Updated {formatRelativeTimestamp(data.fetchedAt)}</span>
|
|
||||||
</div>
|
|
||||||
<OverviewGrid data={data} summaryStats={summaryStats} />
|
|
||||||
{data.scope === "page" ? (
|
|
||||||
<div className="alert alert-info">
|
|
||||||
<span>
|
|
||||||
Showing backlinks for this exact page. Enter a bare domain for
|
|
||||||
site-wide results. Trend charts are only shown for domain-level
|
|
||||||
lookups.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BacklinksResultsCard({
|
export function BacklinksResultsCard({
|
||||||
projectId,
|
projectId,
|
||||||
activeTab,
|
activeTab,
|
||||||
filteredData,
|
tabRows,
|
||||||
filters,
|
filters,
|
||||||
|
sorting,
|
||||||
|
view,
|
||||||
|
domainExpansion,
|
||||||
isTabLoading,
|
isTabLoading,
|
||||||
tabErrorMessage,
|
tabErrorMessage,
|
||||||
exportTarget,
|
exportTarget,
|
||||||
|
pagination,
|
||||||
|
onPageChange,
|
||||||
|
onPageSizeChange,
|
||||||
|
onSortingChange,
|
||||||
onTabChange,
|
onTabChange,
|
||||||
|
onViewChange,
|
||||||
}: {
|
}: {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
activeTab: BacklinksSearchState["tab"];
|
activeTab: BacklinksSearchState["tab"];
|
||||||
filteredData: {
|
tabRows: BacklinksTabRows;
|
||||||
backlinks: BacklinksOverviewData["backlinks"];
|
|
||||||
referringDomains: BacklinksOverviewData["referringDomains"];
|
|
||||||
topPages: BacklinksOverviewData["topPages"];
|
|
||||||
};
|
|
||||||
filters: BacklinksFiltersState;
|
filters: BacklinksFiltersState;
|
||||||
|
sorting: SortingState;
|
||||||
|
view: "all" | undefined;
|
||||||
|
domainExpansion: BacklinksDomainExpansion;
|
||||||
isTabLoading: boolean;
|
isTabLoading: boolean;
|
||||||
tabErrorMessage: string | null;
|
tabErrorMessage: string | null;
|
||||||
exportTarget: string;
|
exportTarget: string;
|
||||||
|
pagination: {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalCount: number | null;
|
||||||
|
hasNextPage: boolean;
|
||||||
|
isFetching: boolean;
|
||||||
|
};
|
||||||
|
onPageChange: (nextPage: number) => void;
|
||||||
|
onPageSizeChange: (nextPageSize: number) => void;
|
||||||
|
onSortingChange: OnChangeFn<SortingState>;
|
||||||
onTabChange: (tab: BacklinksSearchState["tab"]) => void;
|
onTabChange: (tab: BacklinksSearchState["tab"]) => void;
|
||||||
|
onViewChange: (view: "all" | undefined) => void;
|
||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
ratings: domainRatings,
|
ratings: domainRatings,
|
||||||
isLoading: isLoadingRatings,
|
isLoading: isLoadingRatings,
|
||||||
loadRatings,
|
loadRatings,
|
||||||
} = useAhrefsDomainRatings(projectId);
|
} = useAhrefsDomainRatings(projectId);
|
||||||
const showAhrefsDrFilter = domainRatings !== null && activeTab !== "pages";
|
const activeFilterCount = filters[activeTab].activeFilterCount;
|
||||||
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 exportTable = useMemo(
|
const exportTable = useMemo(
|
||||||
() =>
|
() =>
|
||||||
buildBacklinksTabExport({ tab: activeTab, rows: visibleFilteredData }),
|
buildBacklinksTabExport({ tab: activeTab, rows: tabRows, domainRatings }),
|
||||||
[activeTab, visibleFilteredData],
|
[activeTab, domainRatings, tabRows],
|
||||||
);
|
);
|
||||||
// Domains keyed by both tables that the DR column can enrich. The Referring
|
// Domains keyed by both tables that the DR column can enrich. Each table
|
||||||
// Domains list loads lazily, so this grows once that tab is opened.
|
// holds the currently loaded page, so this changes as the user paginates.
|
||||||
const ratableDomains = useMemo(
|
const ratableDomains = useMemo(
|
||||||
() => collectRatableDomains(visibleFilteredData),
|
() => collectRatableDomains(tabRows),
|
||||||
[visibleFilteredData],
|
[tabRows],
|
||||||
);
|
);
|
||||||
// Once the user has opted in, keep newly loaded domains enriched without a
|
// 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.
|
// KV-cached, so re-requesting already-known domains is nearly free.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!domainRatings) return;
|
if (!domainRatings) return;
|
||||||
@ -184,7 +126,6 @@ export function BacklinksResultsCard({
|
|||||||
<BacklinksExportMenu
|
<BacklinksExportMenu
|
||||||
activeTab={activeTab}
|
activeTab={activeTab}
|
||||||
exportTarget={exportTarget}
|
exportTarget={exportTarget}
|
||||||
filteredData={visibleFilteredData}
|
|
||||||
headers={exportTable.headers}
|
headers={exportTable.headers}
|
||||||
rows={exportTable.rows}
|
rows={exportTable.rows}
|
||||||
/>
|
/>
|
||||||
@ -198,7 +139,7 @@ export function BacklinksResultsCard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-base-300">
|
<div className="flex flex-wrap items-center gap-2 px-4 py-2 border-b border-base-300">
|
||||||
<button
|
<button
|
||||||
className={`btn btn-ghost btn-sm gap-1.5 ${filters.showFilters ? "btn-active" : ""}`}
|
className={`btn btn-ghost btn-sm gap-1.5 ${filters.showFilters ? "btn-active" : ""}`}
|
||||||
onClick={() => filters.setShowFilters((current) => !current)}
|
onClick={() => filters.setShowFilters((current) => !current)}
|
||||||
@ -206,20 +147,47 @@ export function BacklinksResultsCard({
|
|||||||
>
|
>
|
||||||
<SlidersHorizontal className="size-3.5" />
|
<SlidersHorizontal className="size-3.5" />
|
||||||
Filters
|
Filters
|
||||||
{currentFilterCount > 0 ? (
|
{activeFilterCount > 0 ? (
|
||||||
<span className="badge badge-xs badge-primary border-0 text-primary-content">
|
<span className="badge badge-xs badge-primary border-0 text-primary-content">
|
||||||
{currentFilterCount}
|
{activeFilterCount}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</button>
|
</button>
|
||||||
|
{activeTab === "backlinks" ? (
|
||||||
|
<div
|
||||||
|
role="tablist"
|
||||||
|
aria-label="Backlinks view"
|
||||||
|
className="ml-auto tabs tabs-box tabs-xs w-fit"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={view !== "all"}
|
||||||
|
className={`tab ${view !== "all" ? "tab-active" : ""}`}
|
||||||
|
title="Show each referring domain's strongest link; expand a row for the rest"
|
||||||
|
onClick={() => onViewChange(undefined)}
|
||||||
|
>
|
||||||
|
One per domain
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={view === "all"}
|
||||||
|
className={`tab ${view === "all" ? "tab-active" : ""}`}
|
||||||
|
title="List every individual backlink"
|
||||||
|
onClick={() => onViewChange("all")}
|
||||||
|
>
|
||||||
|
All links
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{filters.showFilters ? (
|
{filters.showFilters ? (
|
||||||
<BacklinksFilterPanel
|
<BacklinksFilterPanel
|
||||||
activeTab={activeTab}
|
activeTab={activeTab}
|
||||||
filters={filters}
|
filters={filters}
|
||||||
showAhrefsDrFilter={showAhrefsDrFilter}
|
onApplied={() => onPageChange(1)}
|
||||||
activeFilterCount={currentFilterCount}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@ -229,154 +197,72 @@ export function BacklinksResultsCard({
|
|||||||
<span>{tabErrorMessage}</span>
|
<span>{tabErrorMessage}</span>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
{isTabLoading && !tabErrorMessage ? (
|
||||||
|
<TabLoadingState label={TAB_LOADING_LABELS[activeTab]} />
|
||||||
|
) : null}
|
||||||
|
{!isTabLoading && !tabErrorMessage ? (
|
||||||
|
<>
|
||||||
{activeTab === "backlinks" ? (
|
{activeTab === "backlinks" ? (
|
||||||
<BacklinksTable
|
<BacklinksTable
|
||||||
rows={visibleFilteredData.backlinks}
|
rows={tabRows.backlinks}
|
||||||
domainRatings={domainRatings}
|
domainRatings={domainRatings}
|
||||||
|
sorting={sorting}
|
||||||
|
onSortingChange={onSortingChange}
|
||||||
|
expansion={view === "all" ? null : domainExpansion}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{activeTab === "domains" && isTabLoading && !tabErrorMessage ? (
|
{activeTab === "domains" ? (
|
||||||
<TabLoadingState label="Loading referring domains" />
|
|
||||||
) : null}
|
|
||||||
{activeTab === "domains" && !isTabLoading && !tabErrorMessage ? (
|
|
||||||
<ReferringDomainsTable
|
<ReferringDomainsTable
|
||||||
rows={visibleFilteredData.referringDomains}
|
rows={tabRows.referringDomains}
|
||||||
domainRatings={domainRatings}
|
domainRatings={domainRatings}
|
||||||
|
sorting={sorting}
|
||||||
|
onSortingChange={onSortingChange}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{activeTab === "pages" && isTabLoading && !tabErrorMessage ? (
|
{activeTab === "pages" ? (
|
||||||
<TabLoadingState label="Loading top pages" />
|
<TopPagesTable
|
||||||
|
rows={tabRows.topPages}
|
||||||
|
sorting={sorting}
|
||||||
|
onSortingChange={onSortingChange}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{activeTab === "pages" && !isTabLoading && !tabErrorMessage ? (
|
</>
|
||||||
<TopPagesTable rows={filteredData.topPages} />
|
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Kept visible on tab errors so a failing page still offers a way back. */}
|
||||||
|
<TablePagination
|
||||||
|
page={pagination.page}
|
||||||
|
pageSize={pagination.pageSize}
|
||||||
|
pageSizes={BACKLINKS_PAGE_SIZES}
|
||||||
|
totalCount={pagination.totalCount}
|
||||||
|
hasNextPage={pagination.hasNextPage}
|
||||||
|
isLoading={pagination.isFetching}
|
||||||
|
onPageChange={onPageChange}
|
||||||
|
onPageSizeChange={onPageSizeChange}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TAB_LOADING_LABELS: Record<BacklinksTab, string> = {
|
||||||
|
backlinks: "Loading backlinks",
|
||||||
|
domains: "Loading referring domains",
|
||||||
|
pages: "Loading top pages",
|
||||||
|
};
|
||||||
|
|
||||||
/** Unique domains the DR column keys on, from both the backlinks and referring
|
/** Unique domains the DR column keys on, from both the backlinks and referring
|
||||||
* domains tables, normalized to match how each table renders its domain. */
|
* domains tables, normalized to match how each table renders its domain. */
|
||||||
function collectRatableDomains(filteredData: {
|
function collectRatableDomains(tabRows: BacklinksTabRows): string[] {
|
||||||
backlinks: BacklinksOverviewData["backlinks"];
|
|
||||||
referringDomains: BacklinksOverviewData["referringDomains"];
|
|
||||||
}): string[] {
|
|
||||||
const domains = [
|
const domains = [
|
||||||
...filteredData.backlinks.map((row) =>
|
...tabRows.backlinks.map((row) => row.domainFrom?.replace(/^www\./, "")),
|
||||||
row.domainFrom?.replace(/^www\./, ""),
|
...tabRows.referringDomains.map((row) => row.domain),
|
||||||
),
|
|
||||||
...filteredData.referringDomains.map((row) => row.domain),
|
|
||||||
];
|
];
|
||||||
return [
|
return [
|
||||||
...new Set(domains.filter((domain): domain is string => Boolean(domain))),
|
...new Set(domains.filter((domain): domain is string => Boolean(domain))),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function countVisibleFilters(
|
|
||||||
values: Record<string, string>,
|
|
||||||
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 (
|
|
||||||
<div
|
|
||||||
className={`grid grid-cols-1 gap-3 ${domainScope ? "md:grid-cols-2 xl:grid-cols-3" : ""}`}
|
|
||||||
>
|
|
||||||
<SummaryStatsGrid data={data} summaryStats={summaryStats} />
|
|
||||||
{domainScope ? <TrendPanels data={data} /> : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<div className={cardClassName}>
|
|
||||||
<div className="card-body p-4 xl:h-full">
|
|
||||||
<div className="grid grid-cols-2 gap-x-6 gap-y-5 xl:gap-y-6">
|
|
||||||
{summaryStats.map((item) => (
|
|
||||||
<div key={item.label}>
|
|
||||||
<div className="text-xs uppercase tracking-wide text-base-content/55">
|
|
||||||
<HeaderHelpLabel
|
|
||||||
label={item.label}
|
|
||||||
helpText={item.description}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p className="text-2xl font-semibold">{item.value}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TrendPanels({ data }: { data: BacklinksOverviewData }) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<TrendCard
|
|
||||||
title="Backlink growth"
|
|
||||||
description="Backlinks and referring domains over the last year"
|
|
||||||
>
|
|
||||||
<BacklinksTrendChart data={data.trends} />
|
|
||||||
</TrendCard>
|
|
||||||
<TrendCard
|
|
||||||
title="New vs lost"
|
|
||||||
description="Backlink acquisition and attrition"
|
|
||||||
>
|
|
||||||
<BacklinksNewLostChart data={data.newLostTrends} />
|
|
||||||
</TrendCard>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TrendCard({
|
|
||||||
children,
|
|
||||||
description,
|
|
||||||
title,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
description: string;
|
|
||||||
title: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="card bg-base-100 border border-base-300">
|
|
||||||
<div className="card-body gap-2 p-4">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-sm font-medium">{title}</h2>
|
|
||||||
<p className="text-xs text-base-content/55">{description}</p>
|
|
||||||
</div>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TabLink({
|
function TabLink({
|
||||||
activeTab,
|
activeTab,
|
||||||
label,
|
label,
|
||||||
|
|||||||
@ -1,37 +1,108 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
import type { OnChangeFn, SortingState } from "@tanstack/react-table";
|
||||||
import {
|
import {
|
||||||
AppDataTable,
|
AppDataTable,
|
||||||
useAppTable,
|
useAppTable,
|
||||||
} from "@/client/components/table/AppDataTable";
|
} from "@/client/components/table/AppDataTable";
|
||||||
import { EmptyTableState } from "./BacklinksPageEmptyTableState";
|
import { EmptyTableState } from "./BacklinksPageEmptyTableState";
|
||||||
import { buildBacklinksColumns } from "./BacklinksTableColumns";
|
import {
|
||||||
import type { BacklinksOverviewData } from "./backlinksPageTypes";
|
buildBacklinksColumns,
|
||||||
import { groupBacklinksByDomain } from "./backlinksPageUtils";
|
type BacklinksDisplayRow,
|
||||||
|
} from "./BacklinksTableColumns";
|
||||||
|
import type { BacklinksRow } from "./backlinksPageTypes";
|
||||||
|
import type { BacklinksDomainExpansion } from "./useBacklinksDomainExpansion";
|
||||||
import type { DomainRatings } from "./useAhrefsDomainRatings";
|
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({
|
export function BacklinksTable({
|
||||||
rows,
|
rows,
|
||||||
domainRatings,
|
domainRatings,
|
||||||
|
sorting,
|
||||||
|
onSortingChange,
|
||||||
|
expansion,
|
||||||
}: {
|
}: {
|
||||||
rows: BacklinksOverviewData["backlinks"];
|
rows: BacklinksRow[];
|
||||||
domainRatings: DomainRatings | null;
|
domainRatings: DomainRatings | null;
|
||||||
|
sorting: SortingState;
|
||||||
|
onSortingChange: OnChangeFn<SortingState>;
|
||||||
|
/** Present in the one-per-domain view; null when listing all links. */
|
||||||
|
expansion: BacklinksDomainExpansion | null;
|
||||||
}) {
|
}) {
|
||||||
const groupedData = useMemo(() => groupBacklinksByDomain(rows), [rows]);
|
|
||||||
const columns = useMemo(
|
const columns = useMemo(
|
||||||
() => buildBacklinksColumns(domainRatings),
|
() => buildBacklinksColumns(domainRatings, expansion?.toggleDomain),
|
||||||
[domainRatings],
|
[domainRatings, expansion?.toggleDomain],
|
||||||
|
);
|
||||||
|
const displayRows = useMemo(
|
||||||
|
() => buildDisplayRows(rows, expansion),
|
||||||
|
[rows, expansion],
|
||||||
);
|
);
|
||||||
|
|
||||||
const table = useAppTable({
|
const table = useAppTable({
|
||||||
data: groupedData,
|
data: displayRows,
|
||||||
columns,
|
columns,
|
||||||
initialState: {
|
state: { sorting },
|
||||||
sorting: [{ id: "firstSeen", desc: true }],
|
onSortingChange,
|
||||||
},
|
manualSorting: true,
|
||||||
getSubRows: (row) => row.subRows,
|
|
||||||
withSorting: true,
|
|
||||||
withExpanded: true,
|
|
||||||
getRowCanExpand: (row) => row.depth === 0,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
@ -42,13 +113,11 @@ export function BacklinksTable({
|
|||||||
<AppDataTable
|
<AppDataTable
|
||||||
table={table}
|
table={table}
|
||||||
fixedLayout
|
fixedLayout
|
||||||
getRowProps={(row) => ({
|
getRowClassName={(row) =>
|
||||||
className:
|
row.original.kind !== "link" || row.original.depth > 0
|
||||||
row.depth === 0
|
? "bg-base-200/30"
|
||||||
? "cursor-pointer bg-base-200/50 transition-colors hover:bg-base-200/80"
|
: undefined
|
||||||
: "bg-base-100",
|
}
|
||||||
onClick: row.depth === 0 ? row.getToggleExpandedHandler() : undefined,
|
|
||||||
})}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,14 +1,10 @@
|
|||||||
import type { ColumnDef } from "@tanstack/react-table";
|
import type { ColumnDef } from "@tanstack/react-table";
|
||||||
import { ChevronRight } from "lucide-react";
|
import { ChevronRight } from "lucide-react";
|
||||||
import { SortableHeader } from "@/client/components/table/SortableHeader";
|
import { SortableHeader } from "@/client/components/table/SortableHeader";
|
||||||
import {
|
|
||||||
dateNullsLast,
|
|
||||||
numericNullsLast,
|
|
||||||
stringNullsLast,
|
|
||||||
} from "@/client/components/table/nullSafeSort";
|
|
||||||
import { HeaderHelpLabel } from "@/client/features/keywords/components";
|
import { HeaderHelpLabel } from "@/client/features/keywords/components";
|
||||||
import { BacklinksSourceLink } from "./BacklinksPageLinks";
|
import { BacklinksSourceLink } from "./BacklinksPageLinks";
|
||||||
import type { BacklinksRow, GroupedBacklinkDomain } from "./backlinksPageTypes";
|
import type { BacklinksRow } from "./backlinksPageTypes";
|
||||||
|
import type { BacklinksRowsSortField } from "@/types/schemas/backlinks";
|
||||||
import {
|
import {
|
||||||
formatCompactDate,
|
formatCompactDate,
|
||||||
formatDecimal,
|
formatDecimal,
|
||||||
@ -16,6 +12,21 @@ import {
|
|||||||
} from "./backlinksPageUtils";
|
} from "./backlinksPageUtils";
|
||||||
import type { DomainRatings } from "./useAhrefsDomainRatings";
|
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 }) {
|
function BacklinkFlags({ row }: { row: BacklinksRow }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
@ -39,61 +50,41 @@ function BacklinkFlags({ row }: { row: BacklinksRow }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DomainFlagBadges({ group }: { group: GroupedBacklinkDomain }) {
|
function StatusCell({ status }: { status: "loading" | "error" | "empty" }) {
|
||||||
const badges: Array<{ label: string; className: string }> = [];
|
if (status === "loading") {
|
||||||
if (group.lostCount > 0) {
|
|
||||||
badges.push({
|
|
||||||
label: `${group.lostCount} Lost`,
|
|
||||||
className:
|
|
||||||
"badge badge-sm badge-error badge-outline min-w-fit whitespace-nowrap",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
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 (
|
return (
|
||||||
<div className="flex flex-wrap gap-1">
|
<span className="flex items-center gap-2 pl-6 text-sm text-base-content/60">
|
||||||
{badges.map((badge) => (
|
<span className="loading loading-spinner loading-xs" />
|
||||||
<span key={badge.label} className={badge.className}>
|
Loading links…
|
||||||
{badge.label}
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="pl-6 text-sm text-base-content/60">
|
||||||
|
{status === "error"
|
||||||
|
? "Couldn't load this domain's links."
|
||||||
|
: "No other links from this domain."}
|
||||||
</span>
|
</span>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseBacklinksColumns: ColumnDef<GroupedBacklinkDomain>[] = [
|
function SourceCell({
|
||||||
{
|
displayRow,
|
||||||
id: "source",
|
onToggleDomain,
|
||||||
accessorKey: "domain",
|
}: {
|
||||||
header: ({ column }) => (
|
displayRow: BacklinksDisplayRow;
|
||||||
<SortableHeader
|
onToggleDomain?: (domain: string) => void;
|
||||||
column={column}
|
}) {
|
||||||
label="Source"
|
if (displayRow.kind === "status") {
|
||||||
helpText="Page or domain linking to you"
|
return <StatusCell status={displayRow.status} />;
|
||||||
/>
|
}
|
||||||
),
|
|
||||||
size: 250,
|
const { row, depth, expandable, expanded } = displayRow;
|
||||||
minSize: 180,
|
if (depth > 0) {
|
||||||
cell: ({ row }) => {
|
|
||||||
if (row.depth > 0) {
|
|
||||||
const child = row.original._backlink;
|
|
||||||
return (
|
return (
|
||||||
<div className="pl-6 break-all">
|
<div className="break-all pl-6">
|
||||||
{child?.urlFrom ? (
|
{row.urlFrom ? (
|
||||||
<BacklinksSourceLink url={child.urlFrom} maxLength={48} muted />
|
<BacklinksSourceLink url={row.urlFrom} maxLength={48} muted />
|
||||||
) : (
|
) : (
|
||||||
<span className="text-base-content/55">-</span>
|
<span className="text-base-content/55">-</span>
|
||||||
)}
|
)}
|
||||||
@ -101,78 +92,97 @@ const baseBacklinksColumns: ColumnDef<GroupedBacklinkDomain>[] = [
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const group = row.original;
|
const domainLabel = row.domainFrom?.replace(/^www\./, "") ?? "-";
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-start gap-1.5 break-all">
|
||||||
|
{expandable && row.domainFrom && onToggleDomain ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-xs btn-square shrink-0 -ml-1"
|
||||||
|
aria-label={`${expanded ? "Hide" : "Show"} all links from ${domainLabel}`}
|
||||||
|
aria-expanded={expanded}
|
||||||
|
onClick={() => onToggleDomain(row.domainFrom ?? "")}
|
||||||
|
>
|
||||||
<ChevronRight
|
<ChevronRight
|
||||||
className={`size-4 shrink-0 transition-transform ${row.getIsExpanded() ? "rotate-90" : ""}`}
|
className={`size-4 transition-transform ${expanded ? "rotate-90" : ""}`}
|
||||||
/>
|
/>
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
<div>
|
<div>
|
||||||
<div className="font-semibold">{group.domain}</div>
|
<div className="font-semibold">{domainLabel}</div>
|
||||||
<div className="text-xs text-base-content/55">
|
{row.urlFrom ? (
|
||||||
{group.backlinkCount}{" "}
|
<BacklinksSourceLink url={row.urlFrom} maxLength={48} muted />
|
||||||
{group.backlinkCount === 1 ? "backlink" : "backlinks"} ·{" "}
|
) : null}
|
||||||
{group.targetCount} {group.targetCount === 1 ? "page" : "pages"}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
}
|
||||||
sortingFn: stringNullsLast,
|
|
||||||
|
/** 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBaseColumns(
|
||||||
|
onToggleDomain?: (domain: string) => void,
|
||||||
|
): ColumnDef<BacklinksDisplayRow>[] {
|
||||||
|
// 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: () => (
|
||||||
|
<HeaderHelpLabel label="Source" helpText="Page linking to you" />
|
||||||
|
),
|
||||||
|
size: 250,
|
||||||
|
minSize: 180,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<SourceCell displayRow={row.original} onToggleDomain={onToggleDomain} />
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "target",
|
id: "target",
|
||||||
|
enableSorting: false,
|
||||||
header: () => (
|
header: () => (
|
||||||
<HeaderHelpLabel label="Target" helpText="Destination on your site" />
|
<HeaderHelpLabel label="Target" helpText="Destination on your site" />
|
||||||
),
|
),
|
||||||
size: 220,
|
size: 220,
|
||||||
minSize: 150,
|
minSize: 150,
|
||||||
enableSorting: false,
|
cell: linkCell((row) => (
|
||||||
cell: ({ row }) => {
|
|
||||||
if (row.depth > 0) {
|
|
||||||
const child = row.original._backlink;
|
|
||||||
return (
|
|
||||||
<div className="break-all">
|
<div className="break-all">
|
||||||
{child?.urlTo ? (
|
{row.urlTo ? (
|
||||||
<BacklinksSourceLink url={child.urlTo} maxLength={40} />
|
<BacklinksSourceLink url={row.urlTo} maxLength={40} />
|
||||||
) : (
|
) : (
|
||||||
"-"
|
"-"
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
)),
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "anchor",
|
id: "anchor",
|
||||||
|
enableSorting: false,
|
||||||
header: () => (
|
header: () => (
|
||||||
<HeaderHelpLabel label="Anchor" helpText="Text or format of the link" />
|
<HeaderHelpLabel label="Anchor" helpText="Text or format of the link" />
|
||||||
),
|
),
|
||||||
size: 150,
|
size: 150,
|
||||||
minSize: 100,
|
minSize: 100,
|
||||||
enableSorting: false,
|
cell: linkCell((row) => (
|
||||||
cell: ({ row }) => {
|
|
||||||
if (row.depth > 0) {
|
|
||||||
const child = row.original._backlink;
|
|
||||||
return (
|
|
||||||
<div className="space-y-0.5 break-words">
|
<div className="space-y-0.5 break-words">
|
||||||
<span className="text-sm">{child?.anchor || "No anchor text"}</span>
|
<span className="text-sm">{row.anchor || "No anchor text"}</span>
|
||||||
{child?.itemType ? (
|
{row.itemType ? (
|
||||||
<div className="text-xs text-base-content/55">
|
<div className="text-xs text-base-content/55">{row.itemType}</div>
|
||||||
{child.itemType}
|
|
||||||
</div>
|
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
)),
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "flags",
|
id: "flags",
|
||||||
|
enableSorting: false,
|
||||||
header: () => (
|
header: () => (
|
||||||
<HeaderHelpLabel
|
<HeaderHelpLabel
|
||||||
label="Flags"
|
label="Flags"
|
||||||
@ -181,58 +191,33 @@ const baseBacklinksColumns: ColumnDef<GroupedBacklinkDomain>[] = [
|
|||||||
),
|
),
|
||||||
size: 130,
|
size: 130,
|
||||||
minSize: 80,
|
minSize: 80,
|
||||||
enableSorting: false,
|
cell: linkCell((row) => <BacklinkFlags row={row} />),
|
||||||
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 ? <BacklinkFlags row={child} /> : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <DomainFlagBadges group={row.original} />;
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "linkAuthority",
|
id: "rank" satisfies BacklinksRowsSortField,
|
||||||
header: () => (
|
accessorFn: (displayRow) =>
|
||||||
<span className="flex w-full justify-end">
|
displayRow.kind === "link" ? displayRow.row.rank : null,
|
||||||
<HeaderHelpLabel
|
header: ({ column }) => (
|
||||||
|
<SortableHeader
|
||||||
|
column={column}
|
||||||
label="Link"
|
label="Link"
|
||||||
helpText="Authority of the linking page"
|
helpText="Authority of the linking page"
|
||||||
|
align="right"
|
||||||
/>
|
/>
|
||||||
</span>
|
|
||||||
),
|
),
|
||||||
size: 70,
|
size: 70,
|
||||||
minSize: 50,
|
minSize: 50,
|
||||||
enableSorting: false,
|
sortDescFirst: true,
|
||||||
cell: ({ row }) => {
|
cell: linkCell((row) => (
|
||||||
if (row.depth > 0) {
|
|
||||||
const child = row.original._backlink;
|
|
||||||
return (
|
|
||||||
<div className="text-right tabular-nums text-sm">
|
<div className="text-right tabular-nums text-sm">
|
||||||
<span
|
{formatNumber(row.rank)}
|
||||||
title={
|
|
||||||
child?.spamScore != null
|
|
||||||
? `Spam score: ${formatDecimal(child.spamScore)}`
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{formatNumber(child?.rank)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
)),
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "domainAuthority",
|
id: "domainRank" satisfies BacklinksRowsSortField,
|
||||||
accessorKey: "domainAuthority",
|
accessorFn: (displayRow) =>
|
||||||
|
displayRow.kind === "link" ? displayRow.row.domainFromRank : null,
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<SortableHeader
|
<SortableHeader
|
||||||
column={column}
|
column={column}
|
||||||
@ -243,49 +228,41 @@ const baseBacklinksColumns: ColumnDef<GroupedBacklinkDomain>[] = [
|
|||||||
),
|
),
|
||||||
size: 70,
|
size: 70,
|
||||||
minSize: 50,
|
minSize: 50,
|
||||||
cell: ({ row }) => {
|
|
||||||
if (row.depth > 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="text-right tabular-nums text-sm">
|
|
||||||
{formatNumber(row.original.domainAuthority)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
sortingFn: numericNullsLast,
|
|
||||||
sortDescFirst: true,
|
sortDescFirst: true,
|
||||||
|
cell: linkCell((row) => (
|
||||||
|
<div className="text-right tabular-nums text-sm">
|
||||||
|
{formatNumber(row.domainFromRank)}
|
||||||
|
</div>
|
||||||
|
)),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "spamScore",
|
id: "spamScore" satisfies BacklinksRowsSortField,
|
||||||
accessorKey: "spamScore",
|
accessorFn: (displayRow) =>
|
||||||
|
displayRow.kind === "link" ? displayRow.row.spamScore : null,
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<SortableHeader
|
<SortableHeader
|
||||||
column={column}
|
column={column}
|
||||||
label="Spam"
|
label="Spam"
|
||||||
helpText="Estimated spam risk for the linking domain or backlink. Higher scores are more likely to be manipulative or low quality."
|
helpText="Estimated spam risk for this backlink. Higher scores are more likely to be manipulative or low quality."
|
||||||
align="right"
|
align="right"
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
size: 70,
|
size: 70,
|
||||||
minSize: 50,
|
minSize: 50,
|
||||||
cell: ({ row }) => {
|
sortDescFirst: true,
|
||||||
const value =
|
cell: linkCell((row) => {
|
||||||
row.depth > 0
|
const value = row.spamScore;
|
||||||
? row.original._backlink?.spamScore
|
|
||||||
: row.original.spamScore;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="text-right tabular-nums text-sm">
|
<div className="text-right tabular-nums text-sm">
|
||||||
{value != null && value > 0 ? Math.round(value) : null}
|
{value != null && value > 0 ? Math.round(value) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
}),
|
||||||
sortingFn: numericNullsLast,
|
|
||||||
sortDescFirst: true,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "firstSeen",
|
id: "firstSeen" satisfies BacklinksRowsSortField,
|
||||||
accessorKey: "firstSeen",
|
accessorFn: (displayRow) =>
|
||||||
|
displayRow.kind === "link" ? displayRow.row.firstSeen : null,
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<SortableHeader
|
<SortableHeader
|
||||||
column={column}
|
column={column}
|
||||||
@ -295,76 +272,64 @@ const baseBacklinksColumns: ColumnDef<GroupedBacklinkDomain>[] = [
|
|||||||
),
|
),
|
||||||
size: 110,
|
size: 110,
|
||||||
minSize: 80,
|
minSize: 80,
|
||||||
cell: ({ row }) => {
|
sortDescFirst: true,
|
||||||
if (row.depth > 0) {
|
cell: linkCell((row) => (
|
||||||
const child = row.original._backlink;
|
|
||||||
return (
|
|
||||||
<div className="whitespace-nowrap text-sm">
|
<div className="whitespace-nowrap text-sm">
|
||||||
<div>{formatCompactDate(child?.firstSeen)}</div>
|
<div>{formatCompactDate(row.firstSeen)}</div>
|
||||||
{child?.lastSeen ? (
|
{row.lastSeen ? (
|
||||||
<div className="text-xs text-base-content/55">
|
<div className="text-xs text-base-content/55">
|
||||||
Last {formatCompactDate(child.lastSeen)}
|
Last {formatCompactDate(row.lastSeen)}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
)),
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="whitespace-nowrap text-sm">
|
|
||||||
{formatCompactDate(row.original.firstSeen)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
sortingFn: dateNullsLast,
|
];
|
||||||
sortDescFirst: true,
|
}
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Columns for the grouped backlinks table. When `domainRatings` is provided
|
* Columns for the backlinks table. When `domainRatings` is provided (the user
|
||||||
* (the user clicked "Ahrefs DR"), an Ahrefs DR column is inserted after DA;
|
* clicked "Ahrefs DR"), an Ahrefs DR column is inserted after DA; otherwise it
|
||||||
* otherwise it stays hidden.
|
* stays hidden. DR is loaded client-side from Ahrefs, so it can't participate
|
||||||
|
* in server-side sorting.
|
||||||
*/
|
*/
|
||||||
export function buildBacklinksColumns(
|
export function buildBacklinksColumns(
|
||||||
domainRatings: DomainRatings | null,
|
domainRatings: DomainRatings | null,
|
||||||
): ColumnDef<GroupedBacklinkDomain>[] {
|
onToggleDomain?: (domain: string) => void,
|
||||||
if (!domainRatings) return baseBacklinksColumns;
|
): ColumnDef<BacklinksDisplayRow>[] {
|
||||||
|
const baseColumns = buildBaseColumns(onToggleDomain);
|
||||||
|
if (!domainRatings) return baseColumns;
|
||||||
|
|
||||||
const ratings = domainRatings;
|
const ratings = domainRatings;
|
||||||
const drColumn: ColumnDef<GroupedBacklinkDomain> = {
|
const drColumn: ColumnDef<BacklinksDisplayRow> = {
|
||||||
id: "ahrefsDr",
|
id: "ahrefsDr",
|
||||||
accessorFn: (row) => ratings[row.domain] ?? null,
|
enableSorting: false,
|
||||||
header: ({ column }) => (
|
header: () => (
|
||||||
<SortableHeader
|
<span className="flex w-full justify-end">
|
||||||
column={column}
|
<HeaderHelpLabel
|
||||||
label="Ahrefs DR"
|
label="Ahrefs DR"
|
||||||
helpText="Ahrefs Domain Rating (0-100) for the linking domain."
|
helpText="Ahrefs Domain Rating (0-100) for the linking domain."
|
||||||
align="right"
|
|
||||||
/>
|
/>
|
||||||
|
</span>
|
||||||
),
|
),
|
||||||
size: 90,
|
size: 90,
|
||||||
minSize: 70,
|
minSize: 70,
|
||||||
cell: ({ row }) => {
|
cell: linkCell((row) => {
|
||||||
if (row.depth > 0) return null;
|
const domain = row.domainFrom?.replace(/^www\./, "");
|
||||||
const dr = ratings[row.original.domain] ?? null;
|
const dr = domain ? (ratings[domain] ?? null) : null;
|
||||||
return (
|
return (
|
||||||
<div className="text-right tabular-nums text-sm">
|
<div className="text-right tabular-nums text-sm">
|
||||||
{dr == null ? "—" : formatDecimal(dr)}
|
{dr == null ? "—" : formatDecimal(dr)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
}),
|
||||||
sortingFn: numericNullsLast,
|
|
||||||
sortDescFirst: true,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const insertAt =
|
const insertAt =
|
||||||
baseBacklinksColumns.findIndex(
|
baseColumns.findIndex((column) => column.id === "domainRank") + 1;
|
||||||
(column) => column.id === "domainAuthority",
|
|
||||||
) + 1;
|
|
||||||
return [
|
return [
|
||||||
...baseBacklinksColumns.slice(0, insertAt),
|
...baseColumns.slice(0, insertAt),
|
||||||
drColumn,
|
drColumn,
|
||||||
...baseBacklinksColumns.slice(insertAt),
|
...baseColumns.slice(insertAt),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,26 +8,17 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { CsvValue } from "@/client/lib/csv";
|
import type { CsvValue } from "@/client/lib/csv";
|
||||||
import { exportTableToSheets } from "@/client/lib/exportToSheets";
|
import { exportTableToSheets } from "@/client/lib/exportToSheets";
|
||||||
import type {
|
import type { BacklinksSearchState } from "./backlinksPageTypes";
|
||||||
BacklinksOverviewData,
|
|
||||||
BacklinksSearchState,
|
|
||||||
} from "./backlinksPageTypes";
|
|
||||||
import { exportBacklinksTabCsv } from "./export";
|
import { exportBacklinksTabCsv } from "./export";
|
||||||
|
|
||||||
export function BacklinksExportMenu({
|
export function BacklinksExportMenu({
|
||||||
activeTab,
|
activeTab,
|
||||||
exportTarget,
|
exportTarget,
|
||||||
filteredData,
|
|
||||||
headers,
|
headers,
|
||||||
rows,
|
rows,
|
||||||
}: {
|
}: {
|
||||||
activeTab: BacklinksSearchState["tab"];
|
activeTab: BacklinksSearchState["tab"];
|
||||||
exportTarget: string;
|
exportTarget: string;
|
||||||
filteredData: {
|
|
||||||
backlinks: BacklinksOverviewData["backlinks"];
|
|
||||||
referringDomains: BacklinksOverviewData["referringDomains"];
|
|
||||||
topPages: BacklinksOverviewData["topPages"];
|
|
||||||
};
|
|
||||||
headers: string[];
|
headers: string[];
|
||||||
rows: CsvValue[][];
|
rows: CsvValue[][];
|
||||||
}) {
|
}) {
|
||||||
@ -86,7 +77,8 @@ export function BacklinksExportMenu({
|
|||||||
exportBacklinksTabCsv({
|
exportBacklinksTabCsv({
|
||||||
tab: activeTab,
|
tab: activeTab,
|
||||||
target: exportTarget,
|
target: exportTarget,
|
||||||
rows: filteredData,
|
headers,
|
||||||
|
rows,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
disabled={rows.length === 0}
|
disabled={rows.length === 0}
|
||||||
|
|||||||
@ -1,24 +1,16 @@
|
|||||||
import {
|
import { createColumnHelper } from "@tanstack/react-table";
|
||||||
createColumnHelper,
|
import type { OnChangeFn, SortingState } from "@tanstack/react-table";
|
||||||
type SortingFn,
|
import { useMemo } from "react";
|
||||||
type SortingState,
|
|
||||||
} from "@tanstack/react-table";
|
|
||||||
import { useMemo, useState } from "react";
|
|
||||||
import { SafeExternalLink } from "@/client/components/SafeExternalLink";
|
import { SafeExternalLink } from "@/client/components/SafeExternalLink";
|
||||||
import {
|
import {
|
||||||
AppDataTable,
|
AppDataTable,
|
||||||
useAppTable,
|
useAppTable,
|
||||||
} from "@/client/components/table/AppDataTable";
|
} from "@/client/components/table/AppDataTable";
|
||||||
import { SortableHeader } from "@/client/components/table/SortableHeader";
|
import { SortableHeader } from "@/client/components/table/SortableHeader";
|
||||||
import {
|
import { HeaderHelpLabel } from "@/client/features/keywords/components";
|
||||||
compareNumericNullsLast,
|
|
||||||
dateNullsLast,
|
|
||||||
isDescending,
|
|
||||||
numericNullsLast,
|
|
||||||
stringNullsLast,
|
|
||||||
} from "@/client/components/table/nullSafeSort";
|
|
||||||
import { EmptyTableState } from "./BacklinksPageEmptyTableState";
|
import { EmptyTableState } from "./BacklinksPageEmptyTableState";
|
||||||
import type { BacklinksOverviewData } from "./backlinksPageTypes";
|
import type { ReferringDomainRow } from "./backlinksPageTypes";
|
||||||
|
import type { ReferringDomainsSortField } from "@/types/schemas/backlinks";
|
||||||
import {
|
import {
|
||||||
formatCompactDate,
|
formatCompactDate,
|
||||||
formatDecimal,
|
formatDecimal,
|
||||||
@ -26,30 +18,13 @@ import {
|
|||||||
} from "./backlinksPageUtils";
|
} from "./backlinksPageUtils";
|
||||||
import type { DomainRatings } from "./useAhrefsDomainRatings";
|
import type { DomainRatings } from "./useAhrefsDomainRatings";
|
||||||
|
|
||||||
type ReferringDomainRow = BacklinksOverviewData["referringDomains"][number];
|
|
||||||
|
|
||||||
const columnHelper = createColumnHelper<ReferringDomainRow>();
|
const columnHelper = createColumnHelper<ReferringDomainRow>();
|
||||||
|
|
||||||
// Nulls always to the bottom in both directions, same as the pre-TanStack
|
// Column ids map to server-side sort fields; sorting re-queries DataForSEO
|
||||||
// implementation. Secondary compare on brokenPages must also keep nulls last —
|
// across all referring domains, not just the loaded page.
|
||||||
// coercing to 0 would mix unknown values with real zeroes.
|
|
||||||
const sortByIssues: SortingFn<ReferringDomainRow> = (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,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const baseColumns = [
|
const baseColumns = [
|
||||||
columnHelper.accessor("domain", {
|
columnHelper.accessor("domain", {
|
||||||
|
id: "domain" satisfies ReferringDomainsSortField,
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<SortableHeader
|
<SortableHeader
|
||||||
column={column}
|
column={column}
|
||||||
@ -68,9 +43,9 @@ const baseColumns = [
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
sortingFn: stringNullsLast,
|
|
||||||
}),
|
}),
|
||||||
columnHelper.accessor("backlinks", {
|
columnHelper.accessor("backlinks", {
|
||||||
|
id: "backlinks" satisfies ReferringDomainsSortField,
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<SortableHeader
|
<SortableHeader
|
||||||
column={column}
|
column={column}
|
||||||
@ -79,10 +54,10 @@ const baseColumns = [
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
cell: ({ getValue }) => formatNumber(getValue()),
|
cell: ({ getValue }) => formatNumber(getValue()),
|
||||||
sortingFn: numericNullsLast,
|
|
||||||
sortDescFirst: true,
|
sortDescFirst: true,
|
||||||
}),
|
}),
|
||||||
columnHelper.accessor("referringPages", {
|
columnHelper.accessor("referringPages", {
|
||||||
|
id: "referringPages" satisfies ReferringDomainsSortField,
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<SortableHeader
|
<SortableHeader
|
||||||
column={column}
|
column={column}
|
||||||
@ -91,10 +66,10 @@ const baseColumns = [
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
cell: ({ getValue }) => formatNumber(getValue()),
|
cell: ({ getValue }) => formatNumber(getValue()),
|
||||||
sortingFn: numericNullsLast,
|
|
||||||
sortDescFirst: true,
|
sortDescFirst: true,
|
||||||
}),
|
}),
|
||||||
columnHelper.accessor("rank", {
|
columnHelper.accessor("rank", {
|
||||||
|
id: "rank" satisfies ReferringDomainsSortField,
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<SortableHeader
|
<SortableHeader
|
||||||
column={column}
|
column={column}
|
||||||
@ -103,10 +78,10 @@ const baseColumns = [
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
cell: ({ getValue }) => formatNumber(getValue()),
|
cell: ({ getValue }) => formatNumber(getValue()),
|
||||||
sortingFn: numericNullsLast,
|
|
||||||
sortDescFirst: true,
|
sortDescFirst: true,
|
||||||
}),
|
}),
|
||||||
columnHelper.accessor("spamScore", {
|
columnHelper.accessor("spamScore", {
|
||||||
|
id: "spamScore" satisfies ReferringDomainsSortField,
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<SortableHeader
|
<SortableHeader
|
||||||
column={column}
|
column={column}
|
||||||
@ -115,10 +90,10 @@ const baseColumns = [
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
cell: ({ getValue }) => formatDecimal(getValue()),
|
cell: ({ getValue }) => formatDecimal(getValue()),
|
||||||
sortingFn: numericNullsLast,
|
|
||||||
sortDescFirst: true,
|
sortDescFirst: true,
|
||||||
}),
|
}),
|
||||||
columnHelper.accessor("firstSeen", {
|
columnHelper.accessor("firstSeen", {
|
||||||
|
id: "firstSeen" satisfies ReferringDomainsSortField,
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<SortableHeader
|
<SortableHeader
|
||||||
column={column}
|
column={column}
|
||||||
@ -127,11 +102,10 @@ const baseColumns = [
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
cell: ({ getValue }) => formatCompactDate(getValue()),
|
cell: ({ getValue }) => formatCompactDate(getValue()),
|
||||||
sortingFn: dateNullsLast,
|
|
||||||
sortDescFirst: true,
|
sortDescFirst: true,
|
||||||
}),
|
}),
|
||||||
columnHelper.display({
|
columnHelper.accessor("brokenBacklinks", {
|
||||||
id: "issues",
|
id: "brokenBacklinks" satisfies ReferringDomainsSortField,
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<SortableHeader
|
<SortableHeader
|
||||||
column={column}
|
column={column}
|
||||||
@ -147,8 +121,6 @@ const baseColumns = [
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
enableSorting: true,
|
|
||||||
sortingFn: sortByIssues,
|
|
||||||
sortDescFirst: true,
|
sortDescFirst: true,
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
@ -156,36 +128,29 @@ const baseColumns = [
|
|||||||
/**
|
/**
|
||||||
* Columns for the referring domains table. When `domainRatings` is provided
|
* Columns for the referring domains table. When `domainRatings` is provided
|
||||||
* (the user clicked "Ahrefs DR"), an Ahrefs DR column is inserted after Rank;
|
* (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) {
|
function buildReferringDomainColumns(domainRatings: DomainRatings | null) {
|
||||||
if (!domainRatings) return baseColumns;
|
if (!domainRatings) return baseColumns;
|
||||||
|
|
||||||
const ratings = domainRatings;
|
const ratings = domainRatings;
|
||||||
const drColumn = columnHelper.accessor(
|
const drColumn = columnHelper.display({
|
||||||
(row) => (row.domain ? (ratings[row.domain] ?? null) : null),
|
|
||||||
{
|
|
||||||
id: "ahrefsDr",
|
id: "ahrefsDr",
|
||||||
header: ({ column }) => (
|
header: () => (
|
||||||
<SortableHeader
|
<HeaderHelpLabel
|
||||||
column={column}
|
|
||||||
label="Ahrefs DR"
|
label="Ahrefs DR"
|
||||||
helpText="Ahrefs Domain Rating (0-100) for this referring domain."
|
helpText="Ahrefs Domain Rating (0-100) for this referring domain."
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
cell: ({ getValue }) => {
|
cell: ({ row }) => {
|
||||||
const dr = getValue();
|
const domain = row.original.domain;
|
||||||
|
const dr = domain ? (ratings[domain] ?? null) : null;
|
||||||
return dr == null ? "—" : formatDecimal(dr);
|
return dr == null ? "—" : formatDecimal(dr);
|
||||||
},
|
},
|
||||||
sortingFn: numericNullsLast,
|
});
|
||||||
sortDescFirst: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const insertAt =
|
const insertAt = baseColumns.findIndex((column) => column.id === "rank") + 1;
|
||||||
baseColumns.findIndex(
|
|
||||||
(column) => "accessorKey" in column && column.accessorKey === "rank",
|
|
||||||
) + 1;
|
|
||||||
return [
|
return [
|
||||||
...baseColumns.slice(0, insertAt),
|
...baseColumns.slice(0, insertAt),
|
||||||
drColumn,
|
drColumn,
|
||||||
@ -193,8 +158,6 @@ function buildReferringDomainColumns(domainRatings: DomainRatings | null) {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_SORTING: SortingState = [{ id: "backlinks", desc: true }];
|
|
||||||
|
|
||||||
function getDomainWebsiteHref(domain: string) {
|
function getDomainWebsiteHref(domain: string) {
|
||||||
try {
|
try {
|
||||||
return new URL(domain).toString();
|
return new URL(domain).toString();
|
||||||
@ -206,11 +169,14 @@ function getDomainWebsiteHref(domain: string) {
|
|||||||
export function ReferringDomainsTable({
|
export function ReferringDomainsTable({
|
||||||
rows,
|
rows,
|
||||||
domainRatings,
|
domainRatings,
|
||||||
|
sorting,
|
||||||
|
onSortingChange,
|
||||||
}: {
|
}: {
|
||||||
rows: BacklinksOverviewData["referringDomains"];
|
rows: ReferringDomainRow[];
|
||||||
domainRatings: DomainRatings | null;
|
domainRatings: DomainRatings | null;
|
||||||
|
sorting: SortingState;
|
||||||
|
onSortingChange: OnChangeFn<SortingState>;
|
||||||
}) {
|
}) {
|
||||||
const [sorting, setSorting] = useState<SortingState>(DEFAULT_SORTING);
|
|
||||||
const columns = useMemo(
|
const columns = useMemo(
|
||||||
() => buildReferringDomainColumns(domainRatings),
|
() => buildReferringDomainColumns(domainRatings),
|
||||||
[domainRatings],
|
[domainRatings],
|
||||||
@ -220,8 +186,8 @@ export function ReferringDomainsTable({
|
|||||||
data: rows,
|
data: rows,
|
||||||
columns,
|
columns,
|
||||||
state: { sorting },
|
state: { sorting },
|
||||||
onSortingChange: setSorting,
|
onSortingChange,
|
||||||
withSorting: true,
|
manualSorting: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
|
|||||||
@ -1,28 +1,27 @@
|
|||||||
import { createColumnHelper, type SortingState } from "@tanstack/react-table";
|
import { createColumnHelper } from "@tanstack/react-table";
|
||||||
import { useState } from "react";
|
import type { OnChangeFn, SortingState } from "@tanstack/react-table";
|
||||||
import { SafeExternalLink } from "@/client/components/SafeExternalLink";
|
import { SafeExternalLink } from "@/client/components/SafeExternalLink";
|
||||||
import {
|
import {
|
||||||
AppDataTable,
|
AppDataTable,
|
||||||
useAppTable,
|
useAppTable,
|
||||||
} from "@/client/components/table/AppDataTable";
|
} from "@/client/components/table/AppDataTable";
|
||||||
import { SortableHeader } from "@/client/components/table/SortableHeader";
|
import { SortableHeader } from "@/client/components/table/SortableHeader";
|
||||||
import {
|
import { HeaderHelpLabel } from "@/client/features/keywords/components";
|
||||||
numericNullsLast,
|
|
||||||
stringNullsLast,
|
|
||||||
} from "@/client/components/table/nullSafeSort";
|
|
||||||
import { EmptyTableState } from "./BacklinksPageEmptyTableState";
|
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";
|
import { formatNumber } from "./backlinksPageUtils";
|
||||||
|
|
||||||
type TopPageRow = BacklinksOverviewData["topPages"][number];
|
|
||||||
|
|
||||||
const columnHelper = createColumnHelper<TopPageRow>();
|
const columnHelper = createColumnHelper<TopPageRow>();
|
||||||
|
|
||||||
|
// Column ids map to server-side sort fields; sorting re-queries DataForSEO
|
||||||
|
// across all pages, not just the loaded page of results.
|
||||||
const columns = [
|
const columns = [
|
||||||
columnHelper.accessor("page", {
|
columnHelper.accessor("page", {
|
||||||
header: ({ column }) => (
|
id: "page",
|
||||||
<SortableHeader
|
enableSorting: false,
|
||||||
column={column}
|
header: () => (
|
||||||
|
<HeaderHelpLabel
|
||||||
label="Page"
|
label="Page"
|
||||||
helpText="Page on the target site receiving backlinks."
|
helpText="Page on the target site receiving backlinks."
|
||||||
/>
|
/>
|
||||||
@ -39,9 +38,9 @@ const columns = [
|
|||||||
"-"
|
"-"
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
sortingFn: stringNullsLast,
|
|
||||||
}),
|
}),
|
||||||
columnHelper.accessor("backlinks", {
|
columnHelper.accessor("backlinks", {
|
||||||
|
id: "backlinks" satisfies TopPagesSortField,
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<SortableHeader
|
<SortableHeader
|
||||||
column={column}
|
column={column}
|
||||||
@ -50,10 +49,10 @@ const columns = [
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
cell: ({ getValue }) => formatNumber(getValue()),
|
cell: ({ getValue }) => formatNumber(getValue()),
|
||||||
sortingFn: numericNullsLast,
|
|
||||||
sortDescFirst: true,
|
sortDescFirst: true,
|
||||||
}),
|
}),
|
||||||
columnHelper.accessor("referringDomains", {
|
columnHelper.accessor("referringDomains", {
|
||||||
|
id: "referringDomains" satisfies TopPagesSortField,
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<SortableHeader
|
<SortableHeader
|
||||||
column={column}
|
column={column}
|
||||||
@ -62,10 +61,10 @@ const columns = [
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
cell: ({ getValue }) => formatNumber(getValue()),
|
cell: ({ getValue }) => formatNumber(getValue()),
|
||||||
sortingFn: numericNullsLast,
|
|
||||||
sortDescFirst: true,
|
sortDescFirst: true,
|
||||||
}),
|
}),
|
||||||
columnHelper.accessor("rank", {
|
columnHelper.accessor("rank", {
|
||||||
|
id: "rank" satisfies TopPagesSortField,
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<SortableHeader
|
<SortableHeader
|
||||||
column={column}
|
column={column}
|
||||||
@ -74,10 +73,10 @@ const columns = [
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
cell: ({ getValue }) => formatNumber(getValue()),
|
cell: ({ getValue }) => formatNumber(getValue()),
|
||||||
sortingFn: numericNullsLast,
|
|
||||||
sortDescFirst: true,
|
sortDescFirst: true,
|
||||||
}),
|
}),
|
||||||
columnHelper.accessor("brokenBacklinks", {
|
columnHelper.accessor("brokenBacklinks", {
|
||||||
|
id: "brokenBacklinks" satisfies TopPagesSortField,
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<SortableHeader
|
<SortableHeader
|
||||||
column={column}
|
column={column}
|
||||||
@ -86,26 +85,25 @@ const columns = [
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
cell: ({ getValue }) => formatNumber(getValue()),
|
cell: ({ getValue }) => formatNumber(getValue()),
|
||||||
sortingFn: numericNullsLast,
|
|
||||||
sortDescFirst: true,
|
sortDescFirst: true,
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
const DEFAULT_SORTING: SortingState = [{ id: "backlinks", desc: true }];
|
|
||||||
|
|
||||||
export function TopPagesTable({
|
export function TopPagesTable({
|
||||||
rows,
|
rows,
|
||||||
|
sorting,
|
||||||
|
onSortingChange,
|
||||||
}: {
|
}: {
|
||||||
rows: BacklinksOverviewData["topPages"];
|
rows: TopPageRow[];
|
||||||
|
sorting: SortingState;
|
||||||
|
onSortingChange: OnChangeFn<SortingState>;
|
||||||
}) {
|
}) {
|
||||||
const [sorting, setSorting] = useState<SortingState>(DEFAULT_SORTING);
|
|
||||||
|
|
||||||
const table = useAppTable({
|
const table = useAppTable({
|
||||||
data: rows,
|
data: rows,
|
||||||
columns,
|
columns,
|
||||||
state: { sorting },
|
state: { sorting },
|
||||||
onSortingChange: setSorting,
|
onSortingChange,
|
||||||
withSorting: true,
|
manualSorting: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
|
|||||||
@ -1,10 +1,14 @@
|
|||||||
|
import type {
|
||||||
|
BacklinksRowsFilters,
|
||||||
|
ReferringDomainsFilters,
|
||||||
|
TopPagesFilters,
|
||||||
|
} from "@/types/schemas/backlinks";
|
||||||
|
|
||||||
export type BacklinksTabFilterValues = {
|
export type BacklinksTabFilterValues = {
|
||||||
include: string;
|
include: string;
|
||||||
exclude: string;
|
exclude: string;
|
||||||
minDomainRank: string;
|
minDomainRank: string;
|
||||||
maxDomainRank: string;
|
maxDomainRank: string;
|
||||||
minAhrefsDr: string;
|
|
||||||
maxAhrefsDr: string;
|
|
||||||
minLinkAuthority: string;
|
minLinkAuthority: string;
|
||||||
maxLinkAuthority: string;
|
maxLinkAuthority: string;
|
||||||
minSpamScore: string;
|
minSpamScore: string;
|
||||||
@ -21,8 +25,6 @@ export type ReferringDomainsFilterValues = {
|
|||||||
maxBacklinks: string;
|
maxBacklinks: string;
|
||||||
minRank: string;
|
minRank: string;
|
||||||
maxRank: string;
|
maxRank: string;
|
||||||
minAhrefsDr: string;
|
|
||||||
maxAhrefsDr: string;
|
|
||||||
minSpamScore: string;
|
minSpamScore: string;
|
||||||
maxSpamScore: string;
|
maxSpamScore: string;
|
||||||
};
|
};
|
||||||
@ -43,8 +45,6 @@ export const EMPTY_BACKLINKS_FILTERS: BacklinksTabFilterValues = {
|
|||||||
exclude: "",
|
exclude: "",
|
||||||
minDomainRank: "",
|
minDomainRank: "",
|
||||||
maxDomainRank: "",
|
maxDomainRank: "",
|
||||||
minAhrefsDr: "",
|
|
||||||
maxAhrefsDr: "",
|
|
||||||
minLinkAuthority: "",
|
minLinkAuthority: "",
|
||||||
maxLinkAuthority: "",
|
maxLinkAuthority: "",
|
||||||
minSpamScore: "",
|
minSpamScore: "",
|
||||||
@ -61,8 +61,6 @@ export const EMPTY_REFERRING_DOMAINS_FILTERS: ReferringDomainsFilterValues = {
|
|||||||
maxBacklinks: "",
|
maxBacklinks: "",
|
||||||
minRank: "",
|
minRank: "",
|
||||||
maxRank: "",
|
maxRank: "",
|
||||||
minAhrefsDr: "",
|
|
||||||
maxAhrefsDr: "",
|
|
||||||
minSpamScore: "",
|
minSpamScore: "",
|
||||||
maxSpamScore: "",
|
maxSpamScore: "",
|
||||||
};
|
};
|
||||||
@ -77,3 +75,116 @@ export const EMPTY_TOP_PAGES_FILTERS: TopPagesFilterValues = {
|
|||||||
minRank: "",
|
minRank: "",
|
||||||
maxRank: "",
|
maxRank: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const BACKLINKS_FILTER_FIELDS = [
|
||||||
|
"include",
|
||||||
|
"exclude",
|
||||||
|
"minDomainRank",
|
||||||
|
"maxDomainRank",
|
||||||
|
"minLinkAuthority",
|
||||||
|
"maxLinkAuthority",
|
||||||
|
"minSpamScore",
|
||||||
|
"maxSpamScore",
|
||||||
|
"linkType",
|
||||||
|
"hideLost",
|
||||||
|
"hideBroken",
|
||||||
|
] as const satisfies ReadonlyArray<keyof BacklinksTabFilterValues>;
|
||||||
|
export const REFERRING_DOMAINS_FILTER_FIELDS = [
|
||||||
|
"include",
|
||||||
|
"exclude",
|
||||||
|
"minBacklinks",
|
||||||
|
"maxBacklinks",
|
||||||
|
"minRank",
|
||||||
|
"maxRank",
|
||||||
|
"minSpamScore",
|
||||||
|
"maxSpamScore",
|
||||||
|
] as const satisfies ReadonlyArray<keyof ReferringDomainsFilterValues>;
|
||||||
|
export const TOP_PAGES_FILTER_FIELDS = [
|
||||||
|
"include",
|
||||||
|
"exclude",
|
||||||
|
"minBacklinks",
|
||||||
|
"maxBacklinks",
|
||||||
|
"minReferringDomains",
|
||||||
|
"maxReferringDomains",
|
||||||
|
"minRank",
|
||||||
|
"maxRank",
|
||||||
|
] as const satisfies ReadonlyArray<keyof TopPagesFilterValues>;
|
||||||
|
|
||||||
|
export function countActiveFilters(values: Record<string, string>): 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<string, string>): 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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@ -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> = {}): 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> = {},
|
|
||||||
): 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]]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -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<string, string>): number {
|
|
||||||
return Object.values(values).filter((v) => v.trim() !== "").length;
|
|
||||||
}
|
|
||||||
@ -1,16 +1,21 @@
|
|||||||
import type {
|
import type {
|
||||||
|
BacklinksSortOrder,
|
||||||
BacklinksTab,
|
BacklinksTab,
|
||||||
BacklinksTargetScope,
|
BacklinksTargetScope,
|
||||||
} from "@/types/schemas/backlinks";
|
} from "@/types/schemas/backlinks";
|
||||||
import type {
|
import type {
|
||||||
getBacklinksOverview,
|
getBacklinksOverview,
|
||||||
getBacklinksReferringDomains,
|
getBacklinksReferringDomains,
|
||||||
|
getBacklinksRows,
|
||||||
getBacklinksTopPages,
|
getBacklinksTopPages,
|
||||||
} from "@/serverFunctions/backlinks";
|
} from "@/serverFunctions/backlinks";
|
||||||
|
|
||||||
export type BacklinksOverviewData = Awaited<
|
export type BacklinksOverviewData = Awaited<
|
||||||
ReturnType<typeof getBacklinksOverview>
|
ReturnType<typeof getBacklinksOverview>
|
||||||
>;
|
>;
|
||||||
|
export type BacklinksRowsPageData = Awaited<
|
||||||
|
ReturnType<typeof getBacklinksRows>
|
||||||
|
>;
|
||||||
export type BacklinksReferringDomainsData = Awaited<
|
export type BacklinksReferringDomainsData = Awaited<
|
||||||
ReturnType<typeof getBacklinksReferringDomains>
|
ReturnType<typeof getBacklinksReferringDomains>
|
||||||
>;
|
>;
|
||||||
@ -18,10 +23,21 @@ export type BacklinksTopPagesData = Awaited<
|
|||||||
ReturnType<typeof getBacklinksTopPages>
|
ReturnType<typeof getBacklinksTopPages>
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
export type BacklinksRow = BacklinksRowsPageData["rows"][number];
|
||||||
|
export type ReferringDomainRow = BacklinksReferringDomainsData["rows"][number];
|
||||||
|
export type TopPageRow = BacklinksTopPagesData["rows"][number];
|
||||||
|
|
||||||
export type BacklinksSearchState = {
|
export type BacklinksSearchState = {
|
||||||
target: string;
|
target: string;
|
||||||
scope: BacklinksTargetScope;
|
scope: BacklinksTargetScope;
|
||||||
tab: BacklinksTab;
|
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: {
|
export type BacklinksNavigate = (args: {
|
||||||
@ -35,20 +51,9 @@ export type BacklinksPageProps = {
|
|||||||
navigate: BacklinksNavigate;
|
navigate: BacklinksNavigate;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type BacklinksRow = BacklinksOverviewData["backlinks"][number];
|
/** Page rows for all three tabs; tabs that haven't loaded yet are empty. */
|
||||||
|
export type BacklinksTabRows = {
|
||||||
export type GroupedBacklinkDomain = {
|
backlinks: BacklinksRow[];
|
||||||
domain: string;
|
referringDomains: ReferringDomainRow[];
|
||||||
domainAuthority: number | null;
|
topPages: TopPageRow[];
|
||||||
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;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,53 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import type { BacklinksRow } from "./backlinksPageTypes";
|
|
||||||
import { groupBacklinksByDomain } from "./backlinksPageUtils";
|
|
||||||
|
|
||||||
function makeBacklinkRow(overrides: Partial<BacklinksRow> = {}): 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]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,9 +1,5 @@
|
|||||||
import type { BacklinksTab } from "@/types/schemas/backlinks";
|
import type { BacklinksTab } from "@/types/schemas/backlinks";
|
||||||
import type {
|
import type { BacklinksOverviewData } from "./backlinksPageTypes";
|
||||||
BacklinksOverviewData,
|
|
||||||
BacklinksRow,
|
|
||||||
GroupedBacklinkDomain,
|
|
||||||
} from "./backlinksPageTypes";
|
|
||||||
|
|
||||||
export const TAB_DESCRIPTIONS: Record<BacklinksTab, string> = {
|
export const TAB_DESCRIPTIONS: Record<BacklinksTab, string> = {
|
||||||
backlinks:
|
backlinks:
|
||||||
@ -109,70 +105,6 @@ export function formatRelativeTimestamp(value: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function groupBacklinksByDomain(
|
|
||||||
rows: BacklinksRow[],
|
|
||||||
): GroupedBacklinkDomain[] {
|
|
||||||
const groups = new Map<string, BacklinksRow[]>();
|
|
||||||
|
|
||||||
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) {
|
export function extractUrlPath(url: string) {
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(url);
|
const parsed = new URL(url);
|
||||||
|
|||||||
@ -1,14 +1,13 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
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> = {}): BacklinksRow {
|
||||||
it("builds backlinks csv with backlink-specific columns", () => {
|
return {
|
||||||
const file = buildBacklinksTabCsvFile({
|
|
||||||
tab: "backlinks",
|
|
||||||
target: "https://Example.com/path?q=1",
|
|
||||||
rows: {
|
|
||||||
backlinks: [
|
|
||||||
{
|
|
||||||
domainFrom: "example.org",
|
domainFrom: "example.org",
|
||||||
urlFrom: "https://example.org/post",
|
urlFrom: "https://example.org/post",
|
||||||
urlTo: "https://example.com/path",
|
urlTo: "https://example.com/path",
|
||||||
@ -25,27 +24,14 @@ describe("buildBacklinksTabCsvFile", () => {
|
|||||||
isLost: false,
|
isLost: false,
|
||||||
isBroken: false,
|
isBroken: false,
|
||||||
linksCount: 2,
|
linksCount: 2,
|
||||||
},
|
...overrides,
|
||||||
],
|
};
|
||||||
referringDomains: [],
|
}
|
||||||
topPages: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(file.filename).toBe("backlinks-backlinks-example.com-path-q-1.csv");
|
function makeReferringDomainRow(
|
||||||
expect(file.content).toContain('"Domain","Source URL","Target URL"');
|
overrides: Partial<ReferringDomainRow> = {},
|
||||||
expect(file.content).toContain('"example.org"');
|
): ReferringDomainRow {
|
||||||
expect(file.content).toContain('"noopener, noreferrer"');
|
return {
|
||||||
});
|
|
||||||
|
|
||||||
it("builds referring domains csv", () => {
|
|
||||||
const file = buildBacklinksTabCsvFile({
|
|
||||||
tab: "domains",
|
|
||||||
target: "Example.com",
|
|
||||||
rows: {
|
|
||||||
backlinks: [],
|
|
||||||
referringDomains: [
|
|
||||||
{
|
|
||||||
domain: "source.com",
|
domain: "source.com",
|
||||||
backlinks: 12,
|
backlinks: 12,
|
||||||
referringPages: 7,
|
referringPages: 7,
|
||||||
@ -54,21 +40,95 @@ describe("buildBacklinksTabCsvFile", () => {
|
|||||||
firstSeen: "2024-05-10",
|
firstSeen: "2024-05-10",
|
||||||
brokenBacklinks: 1,
|
brokenBacklinks: 1,
|
||||||
brokenPages: 0,
|
brokenPages: 0,
|
||||||
},
|
...overrides,
|
||||||
],
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTabCsv(
|
||||||
|
...args: Parameters<typeof buildBacklinksTabExport>
|
||||||
|
): 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 content = buildTabCsv({
|
||||||
|
tab: "backlinks",
|
||||||
|
rows: {
|
||||||
|
backlinks: [makeBacklinkRow()],
|
||||||
|
referringDomains: [],
|
||||||
topPages: [],
|
topPages: [],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(file.filename).toBe("backlinks-referring-domains-example.com.csv");
|
expect(content).toContain('"Domain","Source URL","Target URL"');
|
||||||
expect(file.content).toContain('"Domain","Backlinks","Referring Pages"');
|
expect(content).not.toContain('"Ahrefs DR"');
|
||||||
expect(file.content).toContain('"source.com"');
|
expect(content).toContain('"example.org"');
|
||||||
|
expect(content).toContain('"noopener, noreferrer"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes an Ahrefs DR column when ratings are loaded", () => {
|
||||||
|
const content = buildTabCsv({
|
||||||
|
tab: "domains",
|
||||||
|
domainRatings: { "source.com": 71.5, "other.com": null },
|
||||||
|
rows: {
|
||||||
|
backlinks: [],
|
||||||
|
referringDomains: [makeReferringDomainRow()],
|
||||||
|
topPages: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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", () => {
|
it("builds top pages csv", () => {
|
||||||
const file = buildBacklinksTabCsvFile({
|
const content = buildTabCsv({
|
||||||
tab: "pages",
|
tab: "pages",
|
||||||
target: "docs.example.com",
|
|
||||||
rows: {
|
rows: {
|
||||||
backlinks: [],
|
backlinks: [],
|
||||||
referringDomains: [],
|
referringDomains: [],
|
||||||
@ -84,45 +144,34 @@ describe("buildBacklinksTabCsvFile", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(file.filename).toBe("backlinks-top-pages-docs.example.com.csv");
|
expect(content).toContain(
|
||||||
expect(file.content).toContain(
|
|
||||||
'"Page","Backlinks","Referring Domains","Rank","Broken Backlinks"',
|
'"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", () => {
|
it("sanitizes formula-like cell values to prevent CSV injection", () => {
|
||||||
const file = buildBacklinksTabCsvFile({
|
const content = buildTabCsv({
|
||||||
tab: "backlinks",
|
tab: "backlinks",
|
||||||
target: "example.com",
|
|
||||||
rows: {
|
rows: {
|
||||||
backlinks: [
|
backlinks: [
|
||||||
{
|
makeBacklinkRow({
|
||||||
domainFrom: "=cmd|' /C calc'!A0",
|
domainFrom: "=cmd|' /C calc'!A0",
|
||||||
urlFrom: "+https://evil.example/source",
|
urlFrom: "+https://evil.example/source",
|
||||||
urlTo: "@https://evil.example/target",
|
urlTo: "@https://evil.example/target",
|
||||||
anchor: "\tformula",
|
anchor: "\tformula",
|
||||||
itemType: "organic",
|
|
||||||
isDofollow: true,
|
|
||||||
relAttributes: [],
|
relAttributes: [],
|
||||||
rank: 1,
|
|
||||||
domainFromRank: 1,
|
|
||||||
pageFromRank: 1,
|
|
||||||
spamScore: 0,
|
|
||||||
firstSeen: "2025-01-01",
|
|
||||||
lastSeen: "2025-01-01",
|
|
||||||
isLost: false,
|
|
||||||
isBroken: false,
|
|
||||||
linksCount: 1,
|
linksCount: 1,
|
||||||
},
|
}),
|
||||||
],
|
],
|
||||||
referringDomains: [],
|
referringDomains: [],
|
||||||
topPages: [],
|
topPages: [],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(file.content).toContain("\"'=cmd|' /C calc'!A0\"");
|
expect(content).toContain("\"'=cmd|' /C calc'!A0\"");
|
||||||
expect(file.content).toContain('"\'+https://evil.example/source"');
|
expect(content).toContain('"\'+https://evil.example/source"');
|
||||||
expect(file.content).toContain('"\'@https://evil.example/target"');
|
expect(content).toContain('"\'@https://evil.example/target"');
|
||||||
expect(file.content).toContain('"\'\tformula"');
|
expect(content).toContain('"\'\tformula"');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,20 +1,25 @@
|
|||||||
import { buildCsv, type CsvValue, downloadCsv } from "@/client/lib/csv";
|
import { buildCsv, type CsvValue, downloadCsv } from "@/client/lib/csv";
|
||||||
import type {
|
import type {
|
||||||
BacklinksOverviewData,
|
|
||||||
BacklinksSearchState,
|
BacklinksSearchState,
|
||||||
|
BacklinksTabRows,
|
||||||
} from "./backlinksPageTypes";
|
} from "./backlinksPageTypes";
|
||||||
|
import type { DomainRatings } from "./useAhrefsDomainRatings";
|
||||||
|
|
||||||
type BacklinksFilteredData = {
|
/**
|
||||||
backlinks: BacklinksOverviewData["backlinks"];
|
* Builds the export table for the active tab. When `domainRatings` is loaded
|
||||||
referringDomains: BacklinksOverviewData["referringDomains"];
|
* (the user clicked "Ahrefs DR"), an Ahrefs DR column is included for the
|
||||||
topPages: BacklinksOverviewData["topPages"];
|
* Backlinks and Referring Domains tabs, matching the on-screen table.
|
||||||
};
|
*/
|
||||||
|
|
||||||
export function buildBacklinksTabExport(args: {
|
export function buildBacklinksTabExport(args: {
|
||||||
tab: BacklinksSearchState["tab"];
|
tab: BacklinksSearchState["tab"];
|
||||||
rows: BacklinksFilteredData;
|
rows: BacklinksTabRows;
|
||||||
|
domainRatings?: DomainRatings | null;
|
||||||
}): { headers: string[]; rows: CsvValue[][] } {
|
}): { 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") {
|
if (tab === "backlinks") {
|
||||||
return {
|
return {
|
||||||
@ -27,6 +32,7 @@ export function buildBacklinksTabExport(args: {
|
|||||||
"Dofollow",
|
"Dofollow",
|
||||||
"Rel Attributes",
|
"Rel Attributes",
|
||||||
"Domain Rank",
|
"Domain Rank",
|
||||||
|
...(domainRatings ? ["Ahrefs DR"] : []),
|
||||||
"Source Page Rank",
|
"Source Page Rank",
|
||||||
"Target Rank",
|
"Target Rank",
|
||||||
"Spam Score",
|
"Spam Score",
|
||||||
@ -45,6 +51,7 @@ export function buildBacklinksTabExport(args: {
|
|||||||
row.isDofollow,
|
row.isDofollow,
|
||||||
row.relAttributes.join(", "),
|
row.relAttributes.join(", "),
|
||||||
row.domainFromRank,
|
row.domainFromRank,
|
||||||
|
...(domainRatings ? [ratingFor(row.domainFrom)] : []),
|
||||||
row.pageFromRank,
|
row.pageFromRank,
|
||||||
row.rank,
|
row.rank,
|
||||||
row.spamScore,
|
row.spamScore,
|
||||||
@ -64,6 +71,7 @@ export function buildBacklinksTabExport(args: {
|
|||||||
"Backlinks",
|
"Backlinks",
|
||||||
"Referring Pages",
|
"Referring Pages",
|
||||||
"Rank",
|
"Rank",
|
||||||
|
...(domainRatings ? ["Ahrefs DR"] : []),
|
||||||
"Spam Score",
|
"Spam Score",
|
||||||
"First Seen",
|
"First Seen",
|
||||||
"Broken Backlinks",
|
"Broken Backlinks",
|
||||||
@ -74,6 +82,7 @@ export function buildBacklinksTabExport(args: {
|
|||||||
row.backlinks,
|
row.backlinks,
|
||||||
row.referringPages,
|
row.referringPages,
|
||||||
row.rank,
|
row.rank,
|
||||||
|
...(domainRatings ? [ratingFor(row.domain)] : []),
|
||||||
row.spamScore,
|
row.spamScore,
|
||||||
row.firstSeen,
|
row.firstSeen,
|
||||||
row.brokenBacklinks,
|
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: {
|
export function exportBacklinksTabCsv(args: {
|
||||||
tab: BacklinksSearchState["tab"];
|
tab: BacklinksSearchState["tab"];
|
||||||
target: string;
|
target: string;
|
||||||
rows: BacklinksFilteredData;
|
headers: string[];
|
||||||
|
rows: CsvValue[][];
|
||||||
}) {
|
}) {
|
||||||
const file = buildBacklinksTabCsvFile(args);
|
downloadCsv(
|
||||||
downloadCsv(file.filename, file.content);
|
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
|
const normalizedTarget = target
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.trim()
|
.trim()
|
||||||
|
|||||||
93
src/client/features/backlinks/useBacklinksDomainExpansion.ts
Normal file
93
src/client/features/backlinks/useBacklinksDomainExpansion.ts
Normal file
@ -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<string>;
|
||||||
|
/** One entry per expanded domain; keyed by the row's raw domainFrom. */
|
||||||
|
entriesByDomain: Record<string, BacklinksDomainEntry>;
|
||||||
|
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<string[]>([]);
|
||||||
|
|
||||||
|
// 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<string, BacklinksDomainEntry> = {};
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@ -1,14 +1,15 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { useForm, useStore } from "@tanstack/react-form";
|
import { MAX_DATAFORSEO_FILTER_CONDITIONS } from "@/types/schemas/domain";
|
||||||
import {
|
import {
|
||||||
EMPTY_BACKLINKS_FILTERS,
|
EMPTY_BACKLINKS_FILTERS,
|
||||||
EMPTY_REFERRING_DOMAINS_FILTERS,
|
EMPTY_REFERRING_DOMAINS_FILTERS,
|
||||||
EMPTY_TOP_PAGES_FILTERS,
|
EMPTY_TOP_PAGES_FILTERS,
|
||||||
|
countActiveFilters,
|
||||||
|
countFilterConditions,
|
||||||
type BacklinksTabFilterValues,
|
type BacklinksTabFilterValues,
|
||||||
type ReferringDomainsFilterValues,
|
type ReferringDomainsFilterValues,
|
||||||
type TopPagesFilterValues,
|
type TopPagesFilterValues,
|
||||||
} from "./backlinksFilterTypes";
|
} from "./backlinksFilterTypes";
|
||||||
import { countActiveFilters } from "./backlinksFiltering";
|
|
||||||
|
|
||||||
const STORAGE_KEY_PREFIX = "backlinks-filters:";
|
const STORAGE_KEY_PREFIX = "backlinks-filters:";
|
||||||
|
|
||||||
@ -36,6 +37,13 @@ function loadFromStorage<T extends FilterValues>(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;
|
return result;
|
||||||
} catch {
|
} catch {
|
||||||
return fallbackClone;
|
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<T extends FilterValues>(tab: string, emptyValues: T) {
|
function useTabFilters<T extends FilterValues>(tab: string, emptyValues: T) {
|
||||||
const [defaultValues] = useState<T>(() =>
|
const [values, setValues] = useState<T>(() =>
|
||||||
loadFromStorage(tab, { ...emptyValues }),
|
loadFromStorage(tab, { ...emptyValues }),
|
||||||
);
|
);
|
||||||
const form = useForm({ defaultValues });
|
|
||||||
const values = useStore(form.store, (state) => state.values);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const apply = useCallback(
|
||||||
saveToStorage(tab, values);
|
(next: T) => {
|
||||||
}, [tab, values]);
|
setValues(next);
|
||||||
|
saveToStorage(tab, next);
|
||||||
|
},
|
||||||
|
[tab],
|
||||||
|
);
|
||||||
|
|
||||||
const reset = useCallback(() => {
|
const reset = useCallback(() => {
|
||||||
form.reset({ ...emptyValues }, { keepDefaultValues: true });
|
apply({ ...emptyValues });
|
||||||
}, [emptyValues, form]);
|
}, [apply, emptyValues]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
form,
|
|
||||||
values,
|
values,
|
||||||
|
apply,
|
||||||
reset,
|
reset,
|
||||||
activeFilterCount: countActiveFilters(values),
|
activeFilterCount: countActiveFilters(values),
|
||||||
};
|
};
|
||||||
|
|||||||
@ -12,16 +12,35 @@ import {
|
|||||||
import {
|
import {
|
||||||
getBacklinksOverview,
|
getBacklinksOverview,
|
||||||
getBacklinksReferringDomains,
|
getBacklinksReferringDomains,
|
||||||
|
getBacklinksRows,
|
||||||
getBacklinksTopPages,
|
getBacklinksTopPages,
|
||||||
} from "@/serverFunctions/backlinks";
|
} from "@/serverFunctions/backlinks";
|
||||||
import { getBacklinksAccessSetupStatus } from "@/serverFunctions/backlinksAccess";
|
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";
|
import { getPersistedBacklinksSearchScope } from "./backlinksSearchScope";
|
||||||
|
|
||||||
type UseBacklinksPageDataArgs = {
|
type UseBacklinksPageDataArgs = {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
searchState: BacklinksSearchState;
|
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(
|
function getBacklinksErrorMessage(
|
||||||
error: unknown,
|
error: unknown,
|
||||||
fallback: string,
|
fallback: string,
|
||||||
@ -34,9 +53,28 @@ function getBacklinksErrorMessage(
|
|||||||
return getStandardErrorMessage(error, fallback);
|
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<T extends string>(
|
||||||
|
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({
|
export function useBacklinksPageData({
|
||||||
projectId,
|
projectId,
|
||||||
searchState,
|
searchState,
|
||||||
|
filters,
|
||||||
}: UseBacklinksPageDataArgs) {
|
}: UseBacklinksPageDataArgs) {
|
||||||
const accessGate = useAccessGate({
|
const accessGate = useAccessGate({
|
||||||
queryKey: ["backlinksAccessStatus", projectId],
|
queryKey: ["backlinksAccessStatus", projectId],
|
||||||
@ -45,7 +83,6 @@ export function useBacklinksPageData({
|
|||||||
});
|
});
|
||||||
const backlinksEnabled = accessGate.enabled;
|
const backlinksEnabled = accessGate.enabled;
|
||||||
const retryAccessGate = accessGate.onRetry;
|
const retryAccessGate = accessGate.onRetry;
|
||||||
const requestInput = buildBacklinksRequestInput(projectId, searchState);
|
|
||||||
const searchCardInitialValues = useMemo(
|
const searchCardInitialValues = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
target: searchState.target,
|
target: searchState.target,
|
||||||
@ -54,33 +91,118 @@ export function useBacklinksPageData({
|
|||||||
[searchState.scope, searchState.target],
|
[searchState.scope, searchState.target],
|
||||||
);
|
);
|
||||||
|
|
||||||
const baseQueryKeyParts = [
|
const { target, scope, tab, page, pageSize, sort, order, view } = searchState;
|
||||||
projectId,
|
const rowsMode = view === "all" ? "as_is" : "one_per_domain";
|
||||||
searchState.scope,
|
const targetReady = backlinksEnabled && Boolean(target);
|
||||||
searchState.target,
|
const baseQueryKeyParts = [projectId, scope, target] as const;
|
||||||
] as const;
|
const pageInputBase = { projectId, target, scope, page, pageSize };
|
||||||
|
|
||||||
const overviewQuery = useQuery({
|
const overviewQuery = useQuery({
|
||||||
queryKey: ["backlinksOverview", ...baseQueryKeyParts],
|
queryKey: ["backlinksOverview", ...baseQueryKeyParts],
|
||||||
enabled: backlinksEnabled && Boolean(searchState.target),
|
enabled: targetReady,
|
||||||
queryFn: () => getBacklinksOverview({ data: requestInput }),
|
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({
|
const referringDomainsQuery = useQuery({
|
||||||
queryKey: ["backlinksReferringDomains", ...baseQueryKeyParts],
|
queryKey: [
|
||||||
enabled:
|
"backlinksReferringDomains",
|
||||||
backlinksEnabled &&
|
...baseQueryKeyParts,
|
||||||
Boolean(searchState.target) &&
|
page,
|
||||||
searchState.tab === "domains",
|
pageSize,
|
||||||
queryFn: () => getBacklinksReferringDomains({ data: requestInput }),
|
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({
|
const topPagesQuery = useQuery({
|
||||||
queryKey: ["backlinksTopPages", ...baseQueryKeyParts],
|
queryKey: [
|
||||||
enabled:
|
"backlinksTopPages",
|
||||||
backlinksEnabled &&
|
...baseQueryKeyParts,
|
||||||
Boolean(searchState.target) &&
|
page,
|
||||||
searchState.tab === "pages",
|
pageSize,
|
||||||
queryFn: () => getBacklinksTopPages({ data: requestInput }),
|
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(
|
const overviewErrorMessage = getBacklinksErrorMessage(
|
||||||
@ -89,17 +211,18 @@ export function useBacklinksPageData({
|
|||||||
);
|
);
|
||||||
const backlinksDisabledByError =
|
const backlinksDisabledByError =
|
||||||
getErrorCode(overviewQuery.error) === "BACKLINKS_NOT_ENABLED";
|
getErrorCode(overviewQuery.error) === "BACKLINKS_NOT_ENABLED";
|
||||||
const activeTabError = getActiveTabError(
|
const activeTabQuery =
|
||||||
searchState,
|
tab === "backlinks"
|
||||||
referringDomainsQuery.error,
|
? rowsQuery
|
||||||
topPagesQuery.error,
|
: tab === "domains"
|
||||||
);
|
? referringDomainsQuery
|
||||||
|
: topPagesQuery;
|
||||||
const activeTabErrorMessage = getBacklinksErrorMessage(
|
const activeTabErrorMessage = getBacklinksErrorMessage(
|
||||||
activeTabError,
|
activeTabQuery.error,
|
||||||
"Could not load this tab.",
|
"Could not load this tab.",
|
||||||
);
|
);
|
||||||
const backlinksDisabledByTabError =
|
const backlinksDisabledByTabError =
|
||||||
getErrorCode(activeTabError) === "BACKLINKS_NOT_ENABLED";
|
getErrorCode(activeTabQuery.error) === "BACKLINKS_NOT_ENABLED";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
@ -118,10 +241,12 @@ export function useBacklinksPageData({
|
|||||||
return {
|
return {
|
||||||
accessGate,
|
accessGate,
|
||||||
activeTabErrorMessage,
|
activeTabErrorMessage,
|
||||||
|
activeTabQuery,
|
||||||
backlinksDisabledByError,
|
backlinksDisabledByError,
|
||||||
overviewErrorMessage,
|
overviewErrorMessage,
|
||||||
overviewQuery,
|
overviewQuery,
|
||||||
referringDomainsQuery,
|
referringDomainsQuery,
|
||||||
|
rowsQuery,
|
||||||
searchCardInitialValues,
|
searchCardInitialValues,
|
||||||
topPagesQuery,
|
topPagesQuery,
|
||||||
};
|
};
|
||||||
@ -137,39 +262,10 @@ export function navigateToBacklinksSearch(
|
|||||||
target: values.target,
|
target: values.target,
|
||||||
scope: getPersistedBacklinksSearchScope(values.target, values.scope),
|
scope: getPersistedBacklinksSearchScope(values.target, values.scope),
|
||||||
tab: undefined,
|
tab: undefined,
|
||||||
|
page: undefined,
|
||||||
|
sort: undefined,
|
||||||
|
order: undefined,
|
||||||
}),
|
}),
|
||||||
replace: true,
|
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;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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 { AlertTriangle, RotateCcw } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
FilterNumberInput,
|
FilterNumberInput,
|
||||||
@ -36,6 +42,11 @@ type Props<TValues extends FilterValues> = {
|
|||||||
countConditions: (values: TValues) => number;
|
countConditions: (values: TValues) => number;
|
||||||
onApply: (values: TValues) => void;
|
onApply: (values: TValues) => void;
|
||||||
onClear: () => 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<TValues extends FilterValues>({
|
export function DomainFilterPanel<TValues extends FilterValues>({
|
||||||
@ -48,6 +59,7 @@ export function DomainFilterPanel<TValues extends FilterValues>({
|
|||||||
countConditions,
|
countConditions,
|
||||||
onApply,
|
onApply,
|
||||||
onClear,
|
onClear,
|
||||||
|
renderExtra,
|
||||||
}: Props<TValues>) {
|
}: Props<TValues>) {
|
||||||
const appliedKey = useMemo(
|
const appliedKey = useMemo(
|
||||||
() => fields.map((key) => appliedFilters[key]).join("|"),
|
() => fields.map((key) => appliedFilters[key]).join("|"),
|
||||||
@ -97,10 +109,19 @@ export function DomainFilterPanel<TValues extends FilterValues>({
|
|||||||
}, [appliedFilters, debugName]);
|
}, [appliedFilters, debugName]);
|
||||||
const resetFilters = useCallback(() => {
|
const resetFilters = useCallback(() => {
|
||||||
debugDomain(`${debugName}:clear`);
|
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();
|
onClear();
|
||||||
}, [debugName, onClear]);
|
}, [debugName, fields, onClear]);
|
||||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||||
if (event.key !== "Enter") return;
|
if (event.key !== "Enter") return;
|
||||||
|
// Let buttons (Cancel, toggles) handle their own Enter activation.
|
||||||
|
if (event.target instanceof HTMLButtonElement) return;
|
||||||
if (meta.overLimit) return;
|
if (meta.overLimit) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
applyFilters();
|
applyFilters();
|
||||||
@ -177,6 +198,8 @@ export function DomainFilterPanel<TValues extends FilterValues>({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{renderExtra ? renderExtra(draftFilters, handleValueChange) : null}
|
||||||
|
|
||||||
{meta.overLimit ? (
|
{meta.overLimit ? (
|
||||||
<div className="alert alert-warning py-2 text-xs">
|
<div className="alert alert-warning py-2 text-xs">
|
||||||
<AlertTriangle className="size-4 shrink-0" />
|
<AlertTriangle className="size-4 shrink-0" />
|
||||||
|
|||||||
@ -1,7 +1,10 @@
|
|||||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||||
import { BacklinksPage } from "@/client/features/backlinks/BacklinksPage";
|
import { BacklinksPage } from "@/client/features/backlinks/BacklinksPage";
|
||||||
import { inferBacklinksSearchScopeFromTarget } from "@/client/features/backlinks/backlinksSearchScope";
|
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")({
|
export const Route = createFileRoute("/_project/p/$projectId/backlinks")({
|
||||||
validateSearch: backlinksSearchSchema,
|
validateSearch: backlinksSearchSchema,
|
||||||
@ -11,7 +14,16 @@ export const Route = createFileRoute("/_project/p/$projectId/backlinks")({
|
|||||||
function BacklinksRoute() {
|
function BacklinksRoute() {
|
||||||
const { projectId } = Route.useParams();
|
const { projectId } = Route.useParams();
|
||||||
const navigate = useNavigate({ from: Route.fullPath });
|
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);
|
const scope = rawScope ?? inferBacklinksSearchScopeFromTarget(target);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -22,6 +34,11 @@ function BacklinksRoute() {
|
|||||||
target,
|
target,
|
||||||
scope,
|
scope,
|
||||||
tab,
|
tab,
|
||||||
|
page,
|
||||||
|
pageSize: size,
|
||||||
|
sort,
|
||||||
|
order,
|
||||||
|
view,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -37,6 +37,15 @@ const billingCustomer = {
|
|||||||
userEmail: "team@example.com",
|
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<string, string>();
|
const cache = new Map<string, string>();
|
||||||
const service = createBacklinksService({
|
const service = createBacklinksService({
|
||||||
async get(key) {
|
async get(key) {
|
||||||
@ -53,7 +62,7 @@ beforeEach(() => {
|
|||||||
vi.clearAllMocks();
|
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({
|
vi.mocked(normalizeBacklinksTarget).mockReturnValue({
|
||||||
apiTarget: "example.com",
|
apiTarget: "example.com",
|
||||||
displayTarget: "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,
|
new_referring_domains: 8,
|
||||||
lost_referring_domains: 2,
|
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([
|
backlinksHistoryMock.mockResolvedValue([
|
||||||
{
|
{
|
||||||
date: "2026-02-01",
|
date: "2026-02-01",
|
||||||
@ -116,8 +104,9 @@ it("profiles only the initial overview calls and reuses cache on repeat", async
|
|||||||
billingCustomer,
|
billingCustomer,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(first.overview.referringDomains).toEqual([]);
|
expect(first.overview.summary.backlinks).toBe(1200);
|
||||||
expect(first.overview.topPages).toEqual([]);
|
expect(first.overview.trends).toHaveLength(1);
|
||||||
|
expect(backlinksRowsMock).not.toHaveBeenCalled();
|
||||||
expect(referringDomainsMock).not.toHaveBeenCalled();
|
expect(referringDomainsMock).not.toHaveBeenCalled();
|
||||||
expect(domainPagesMock).not.toHaveBeenCalled();
|
expect(domainPagesMock).not.toHaveBeenCalled();
|
||||||
expect(backlinksSummaryMock).toHaveBeenCalledOnce();
|
expect(backlinksSummaryMock).toHaveBeenCalledOnce();
|
||||||
@ -125,13 +114,110 @@ it("profiles only the initial overview calls and reuses cache on repeat", async
|
|||||||
expect(second).toEqual(first);
|
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({
|
vi.mocked(normalizeBacklinksTarget).mockReturnValue({
|
||||||
apiTarget: "https://example.com/foo",
|
apiTarget: "https://example.com/foo",
|
||||||
displayTarget: "https://example.com/foo",
|
displayTarget: "https://example.com/foo",
|
||||||
scope: "page",
|
scope: "page",
|
||||||
});
|
});
|
||||||
referringDomainsMock.mockResolvedValue([
|
referringDomainsMock.mockResolvedValue({
|
||||||
|
items: [
|
||||||
{
|
{
|
||||||
domain: "source.example",
|
domain: "source.example",
|
||||||
backlinks: 4,
|
backlinks: 4,
|
||||||
@ -143,8 +229,11 @@ it("profiles referring domains and top pages separately", async () => {
|
|||||||
backlinks_spam_score: 2,
|
backlinks_spam_score: 2,
|
||||||
target_spam_score: 4,
|
target_spam_score: 4,
|
||||||
},
|
},
|
||||||
]);
|
],
|
||||||
domainPagesMock.mockResolvedValue([
|
totalCount: 1,
|
||||||
|
});
|
||||||
|
domainPagesMock.mockResolvedValue({
|
||||||
|
items: [
|
||||||
{
|
{
|
||||||
page: "https://example.com/foo",
|
page: "https://example.com/foo",
|
||||||
backlinks: 100,
|
backlinks: 100,
|
||||||
@ -152,19 +241,30 @@ it("profiles referring domains and top pages separately", async () => {
|
|||||||
rank: 50,
|
rank: 50,
|
||||||
broken_backlinks: 0,
|
broken_backlinks: 0,
|
||||||
},
|
},
|
||||||
]);
|
],
|
||||||
|
totalCount: 1,
|
||||||
|
});
|
||||||
|
|
||||||
const domains = await service.profileReferringDomains(
|
const domains = await service.profileReferringDomainsPage(
|
||||||
{ target: "https://example.com/foo" },
|
{
|
||||||
|
...pageInputDefaults,
|
||||||
|
target: "https://example.com/foo",
|
||||||
|
sortField: "backlinks",
|
||||||
|
},
|
||||||
billingCustomer,
|
billingCustomer,
|
||||||
);
|
);
|
||||||
const pages = await service.profileTopPages(
|
const pages = await service.profileTopPagesPage(
|
||||||
{ target: "https://example.com/foo" },
|
{
|
||||||
|
...pageInputDefaults,
|
||||||
|
target: "https://example.com/foo",
|
||||||
|
sortField: "backlinks",
|
||||||
|
},
|
||||||
billingCustomer,
|
billingCustomer,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(domains.rows).toHaveLength(1);
|
expect(domains.rows).toHaveLength(1);
|
||||||
expect(domains.rows[0]?.spamScore).toBe(2);
|
expect(domains.rows[0]?.spamScore).toBe(2);
|
||||||
|
expect(domains.hasMore).toBe(false);
|
||||||
expect(pages.rows).toHaveLength(1);
|
expect(pages.rows).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -174,7 +274,8 @@ it("does not fall back to target spam score for referring domains", async () =>
|
|||||||
displayTarget: "example.com",
|
displayTarget: "example.com",
|
||||||
scope: "domain",
|
scope: "domain",
|
||||||
});
|
});
|
||||||
referringDomainsMock.mockResolvedValue([
|
referringDomainsMock.mockResolvedValue({
|
||||||
|
items: [
|
||||||
{
|
{
|
||||||
domain: "source.example",
|
domain: "source.example",
|
||||||
backlinks: 4,
|
backlinks: 4,
|
||||||
@ -186,10 +287,16 @@ it("does not fall back to target spam score for referring domains", async () =>
|
|||||||
backlinks_spam_score: null,
|
backlinks_spam_score: null,
|
||||||
target_spam_score: 4,
|
target_spam_score: 4,
|
||||||
},
|
},
|
||||||
]);
|
],
|
||||||
|
totalCount: 1,
|
||||||
|
});
|
||||||
|
|
||||||
const domains = await service.profileReferringDomains(
|
const domains = await service.profileReferringDomainsPage(
|
||||||
{ target: "example.com" },
|
{
|
||||||
|
...pageInputDefaults,
|
||||||
|
target: "example.com",
|
||||||
|
sortField: "backlinks",
|
||||||
|
},
|
||||||
billingCustomer,
|
billingCustomer,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -197,39 +304,33 @@ it("does not fall back to target spam score for referring domains", async () =>
|
|||||||
expect(domains.rows[0]?.spamScore).toBeNull();
|
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({
|
vi.mocked(normalizeBacklinksTarget).mockReturnValue({
|
||||||
apiTarget: "example.com",
|
apiTarget: "example.com",
|
||||||
displayTarget: "example.com",
|
displayTarget: "example.com",
|
||||||
scope: "domain",
|
scope: "domain",
|
||||||
});
|
});
|
||||||
backlinksSummaryMock.mockResolvedValue({
|
backlinksRowsMock.mockResolvedValue({ items: [], totalCount: 0 });
|
||||||
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([]);
|
|
||||||
|
|
||||||
const input = { target: "example.com" };
|
const input = {
|
||||||
|
...pageInputDefaults,
|
||||||
|
target: "example.com",
|
||||||
|
sortField: "rank",
|
||||||
|
} as const;
|
||||||
|
|
||||||
await service.profileOverview(input, billingCustomer);
|
await service.profileBacklinksPage(input, billingCustomer);
|
||||||
await service.profileOverview(input, {
|
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",
|
organizationId: "org_456",
|
||||||
userId: "user_456",
|
userId: "user_456",
|
||||||
userEmail: "other@example.com",
|
userEmail: "other@example.com",
|
||||||
});
|
});
|
||||||
|
expect(backlinksRowsMock).toHaveBeenCalledTimes(3);
|
||||||
expect(backlinksSummaryMock).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function parseCachedValue(raw: string): unknown {
|
function parseCachedValue(raw: string): unknown {
|
||||||
|
|||||||
@ -2,37 +2,63 @@ import { buildCacheKey, getCached, setCached } from "@/server/lib/r2-cache";
|
|||||||
import { normalizeBacklinksTarget } from "@/server/lib/dataforseo";
|
import { normalizeBacklinksTarget } from "@/server/lib/dataforseo";
|
||||||
import {
|
import {
|
||||||
normalizeBacklinksSpamFilterOptions,
|
normalizeBacklinksSpamFilterOptions,
|
||||||
|
type BacklinksLookupInput,
|
||||||
type BacklinksSpamFilterOptions,
|
type BacklinksSpamFilterOptions,
|
||||||
} from "@/types/schemas/backlinks";
|
} from "@/types/schemas/backlinks";
|
||||||
import {
|
import {
|
||||||
profileBacklinksOverview,
|
profileBacklinksOverview,
|
||||||
profileReferringDomainsRows,
|
profileBacklinksRowsPage,
|
||||||
profileTopPagesRows,
|
profileReferringDomainsPage,
|
||||||
|
profileTopPagesPage,
|
||||||
type BacklinksCache,
|
type BacklinksCache,
|
||||||
|
type BacklinksRowsPageServiceInput,
|
||||||
|
type ReferringDomainsPageServiceInput,
|
||||||
|
type TopPagesPageServiceInput,
|
||||||
} from "@/server/features/backlinks/services/backlinksServiceData";
|
} from "@/server/features/backlinks/services/backlinksServiceData";
|
||||||
import type { BillingCustomerContext } from "@/server/billing/subscription";
|
import type { BillingCustomerContext } from "@/server/billing/subscription";
|
||||||
import type { BacklinksLookupInput } from "@/types/schemas/backlinks";
|
|
||||||
|
|
||||||
const defaultCache: BacklinksCache = {
|
const defaultCache: BacklinksCache = {
|
||||||
get: getCached,
|
get: getCached,
|
||||||
set: setCached,
|
set: setCached,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type BacklinksPageCacheInput = {
|
||||||
|
target: string;
|
||||||
|
scope?: "domain" | "page";
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
sortField: string;
|
||||||
|
sortOrder: string;
|
||||||
|
filters: Record<string, unknown>;
|
||||||
|
/** Backlinks rows only: DataForSEO result grouping. */
|
||||||
|
mode?: string;
|
||||||
|
};
|
||||||
|
|
||||||
function createBacklinksService(cache: BacklinksCache = defaultCache) {
|
function createBacklinksService(cache: BacklinksCache = defaultCache) {
|
||||||
return {
|
return {
|
||||||
async profileOverview(
|
async profileOverview(
|
||||||
input: BacklinksLookupInput,
|
input: BacklinksLookupInput,
|
||||||
billingCustomer: BillingCustomerContext,
|
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,
|
options?: BacklinksSpamFilterOptions,
|
||||||
) {
|
) {
|
||||||
const cacheKey = await buildBacklinksCacheKey(
|
const cacheKey = await buildPageCacheKey(
|
||||||
"backlinks:overview",
|
"backlinks:rows-page",
|
||||||
input,
|
input,
|
||||||
billingCustomer,
|
billingCustomer,
|
||||||
options,
|
options,
|
||||||
);
|
);
|
||||||
|
|
||||||
return profileBacklinksOverview(
|
return profileBacklinksRowsPage(
|
||||||
cache,
|
cache,
|
||||||
cacheKey,
|
cacheKey,
|
||||||
input,
|
input,
|
||||||
@ -40,19 +66,19 @@ function createBacklinksService(cache: BacklinksCache = defaultCache) {
|
|||||||
options,
|
options,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
async profileReferringDomains(
|
async profileReferringDomainsPage(
|
||||||
input: BacklinksLookupInput,
|
input: ReferringDomainsPageServiceInput,
|
||||||
billingCustomer: BillingCustomerContext,
|
billingCustomer: BillingCustomerContext,
|
||||||
options?: BacklinksSpamFilterOptions,
|
options?: BacklinksSpamFilterOptions,
|
||||||
) {
|
) {
|
||||||
const cacheKey = await buildBacklinksCacheKey(
|
const cacheKey = await buildPageCacheKey(
|
||||||
"backlinks:referring-domains",
|
"backlinks:referring-domains-page",
|
||||||
input,
|
input,
|
||||||
billingCustomer,
|
billingCustomer,
|
||||||
options,
|
options,
|
||||||
);
|
);
|
||||||
|
|
||||||
return profileReferringDomainsRows(
|
return profileReferringDomainsPage(
|
||||||
cache,
|
cache,
|
||||||
cacheKey,
|
cacheKey,
|
||||||
input,
|
input,
|
||||||
@ -60,44 +86,52 @@ function createBacklinksService(cache: BacklinksCache = defaultCache) {
|
|||||||
options,
|
options,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
async profileTopPages(
|
async profileTopPagesPage(
|
||||||
input: BacklinksLookupInput,
|
input: TopPagesPageServiceInput,
|
||||||
billingCustomer: BillingCustomerContext,
|
billingCustomer: BillingCustomerContext,
|
||||||
) {
|
) {
|
||||||
const cacheKey = await buildBacklinksCacheKey(
|
const cacheKey = await buildPageCacheKey(
|
||||||
"backlinks:top-pages",
|
"backlinks:top-pages-page",
|
||||||
input,
|
input,
|
||||||
billingCustomer,
|
billingCustomer,
|
||||||
);
|
);
|
||||||
|
|
||||||
return profileTopPagesRows(cache, cacheKey, input, billingCustomer);
|
return profileTopPagesPage(cache, cacheKey, input, billingCustomer);
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function buildBacklinksCacheKey(
|
function buildTargetCacheInput(
|
||||||
prefix: string,
|
|
||||||
input: BacklinksLookupInput,
|
input: BacklinksLookupInput,
|
||||||
billingCustomer: BillingCustomerContext,
|
billingCustomer: BillingCustomerContext,
|
||||||
options?: BacklinksSpamFilterOptions,
|
) {
|
||||||
): Promise<string> {
|
|
||||||
const normalizedTarget = normalizeBacklinksTarget(input.target, {
|
const normalizedTarget = normalizeBacklinksTarget(input.target, {
|
||||||
scope: input.scope,
|
scope: input.scope,
|
||||||
});
|
});
|
||||||
const cacheKeyInput = {
|
|
||||||
|
return {
|
||||||
organizationId: billingCustomer.organizationId,
|
organizationId: billingCustomer.organizationId,
|
||||||
target: normalizedTarget.apiTarget,
|
target: normalizedTarget.apiTarget,
|
||||||
scope: normalizedTarget.scope,
|
scope: normalizedTarget.scope,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (!options) {
|
async function buildPageCacheKey(
|
||||||
return buildCacheKey(prefix, cacheKeyInput);
|
prefix: string,
|
||||||
}
|
input: BacklinksPageCacheInput,
|
||||||
|
billingCustomer: BillingCustomerContext,
|
||||||
|
options?: BacklinksSpamFilterOptions,
|
||||||
|
): Promise<string> {
|
||||||
const spamFilterOptions = normalizeBacklinksSpamFilterOptions(options);
|
const spamFilterOptions = normalizeBacklinksSpamFilterOptions(options);
|
||||||
|
|
||||||
return buildCacheKey(prefix, {
|
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),
|
hideSpam: String(spamFilterOptions.hideSpam),
|
||||||
...(spamFilterOptions.hideSpam
|
...(spamFilterOptions.hideSpam
|
||||||
? { spamThreshold: String(spamFilterOptions.spamThreshold) }
|
? { spamThreshold: String(spamFilterOptions.spamThreshold) }
|
||||||
|
|||||||
@ -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));
|
||||||
|
});
|
||||||
|
});
|
||||||
179
src/server/features/backlinks/services/backlinksApiFilters.ts
Normal file
179
src/server/features/backlinks/services/backlinksApiFilters.ts
Normal file
@ -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<BacklinksRowsSortField, string> = {
|
||||||
|
rank: "rank",
|
||||||
|
domainRank: "domain_from_rank",
|
||||||
|
spamScore: "backlink_spam_score",
|
||||||
|
firstSeen: "first_seen",
|
||||||
|
};
|
||||||
|
|
||||||
|
const REFERRING_DOMAINS_SORT_FIELDS: Record<ReferringDomainsSortField, string> =
|
||||||
|
{
|
||||||
|
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<TopPagesSortField, string> = {
|
||||||
|
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",
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -19,7 +19,7 @@ const backlinksRowSchema = z.object({
|
|||||||
linksCount: z.number().nullable(),
|
linksCount: z.number().nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const referringDomainRowSchema = z.object({
|
const referringDomainRowSchema = z.object({
|
||||||
domain: z.string().nullable(),
|
domain: z.string().nullable(),
|
||||||
backlinks: z.number().nullable(),
|
backlinks: z.number().nullable(),
|
||||||
referringPages: z.number().nullable(),
|
referringPages: z.number().nullable(),
|
||||||
@ -30,7 +30,7 @@ export const referringDomainRowSchema = z.object({
|
|||||||
brokenPages: z.number().nullable(),
|
brokenPages: z.number().nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const topPageRowSchema = z.object({
|
const topPageRowSchema = z.object({
|
||||||
page: z.string().nullable(),
|
page: z.string().nullable(),
|
||||||
backlinks: z.number().nullable(),
|
backlinks: z.number().nullable(),
|
||||||
referringDomains: z.number().nullable(),
|
referringDomains: z.number().nullable(),
|
||||||
@ -71,12 +71,35 @@ export const backlinksOverviewSchema = z.object({
|
|||||||
newReferringDomains: z.number().nullable(),
|
newReferringDomains: z.number().nullable(),
|
||||||
lostReferringDomains: z.number().nullable(),
|
lostReferringDomains: z.number().nullable(),
|
||||||
}),
|
}),
|
||||||
backlinks: z.array(backlinksRowSchema),
|
|
||||||
referringDomains: z.array(referringDomainRowSchema),
|
|
||||||
topPages: z.array(topPageRowSchema),
|
|
||||||
trends: z.array(backlinksTrendRowSchema),
|
trends: z.array(backlinksTrendRowSchema),
|
||||||
newLostTrends: z.array(backlinksNewLostTrendRowSchema),
|
newLostTrends: z.array(backlinksNewLostTrendRowSchema),
|
||||||
fetchedAt: z.string(),
|
fetchedAt: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type BacklinksOverviewResult = z.infer<typeof backlinksOverviewSchema>;
|
export type BacklinksOverviewResult = z.infer<typeof backlinksOverviewSchema>;
|
||||||
|
|
||||||
|
function buildPageResultSchema<T extends z.ZodTypeAny>(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<typeof topPagesPageResultSchema>;
|
||||||
|
|||||||
@ -9,21 +9,52 @@ import {
|
|||||||
type DomainPageSummaryItem,
|
type DomainPageSummaryItem,
|
||||||
type ReferringDomainItem,
|
type ReferringDomainItem,
|
||||||
} from "@/server/lib/dataforseo";
|
} from "@/server/lib/dataforseo";
|
||||||
import {
|
import type {
|
||||||
normalizeBacklinksSpamFilterOptions,
|
BacklinksLookupInput,
|
||||||
type BacklinksSpamFilterOptions,
|
BacklinksRowsPageInput,
|
||||||
|
BacklinksSpamFilterOptions,
|
||||||
|
ReferringDomainsPageInput,
|
||||||
|
TopPagesPageInput,
|
||||||
} from "@/types/schemas/backlinks";
|
} from "@/types/schemas/backlinks";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
backlinksOverviewSchema,
|
backlinksOverviewSchema,
|
||||||
referringDomainRowSchema,
|
backlinksRowsPageResultSchema,
|
||||||
topPageRowSchema,
|
referringDomainsPageResultSchema,
|
||||||
|
topPagesPageResultSchema,
|
||||||
type BacklinksOverviewResult,
|
type BacklinksOverviewResult,
|
||||||
|
type BacklinksRowsPageResult,
|
||||||
|
type ReferringDomainsPageResult,
|
||||||
|
type TopPagesPageResult,
|
||||||
} from "@/server/features/backlinks/services/backlinksOverviewSchema";
|
} 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<TopPagesPageInput, "projectId">;
|
||||||
|
|
||||||
const BACKLINKS_OVERVIEW_TTL_SECONDS = 6 * 60 * 60;
|
const BACKLINKS_OVERVIEW_TTL_SECONDS = 6 * 60 * 60;
|
||||||
const BACKLINKS_TAB_TTL_SECONDS = 6 * 60 * 60;
|
const BACKLINKS_TAB_TTL_SECONDS = 6 * 60 * 60;
|
||||||
|
|
||||||
|
const backlinksOverviewCacheSchema = z.object({
|
||||||
|
overview: backlinksOverviewSchema,
|
||||||
|
});
|
||||||
|
|
||||||
export type BacklinksCache = {
|
export type BacklinksCache = {
|
||||||
get(key: string): Promise<unknown>;
|
get(key: string): Promise<unknown>;
|
||||||
set(key: string, data: unknown, ttlSeconds: number): Promise<void>;
|
set(key: string, data: unknown, ttlSeconds: number): Promise<void>;
|
||||||
@ -33,24 +64,6 @@ type BacklinksOverviewProfile = {
|
|||||||
overview: BacklinksOverviewResult;
|
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 = {
|
type BacklinksDateRange = {
|
||||||
dateFrom: string;
|
dateFrom: string;
|
||||||
dateTo: string;
|
dateTo: string;
|
||||||
@ -61,10 +74,10 @@ export async function profileBacklinksOverview(
|
|||||||
cacheKey: string,
|
cacheKey: string,
|
||||||
input: BacklinksLookupInput,
|
input: BacklinksLookupInput,
|
||||||
billingCustomer: BillingCustomerContext,
|
billingCustomer: BillingCustomerContext,
|
||||||
options?: BacklinksSpamFilterOptions,
|
|
||||||
): Promise<BacklinksOverviewProfile> {
|
): Promise<BacklinksOverviewProfile> {
|
||||||
const cachedRaw = await cache.get(cacheKey);
|
const cached = backlinksOverviewCacheSchema.safeParse(
|
||||||
const cached = backlinksOverviewCacheSchema.safeParse(cachedRaw);
|
await cache.get(cacheKey),
|
||||||
|
);
|
||||||
if (cached.success) {
|
if (cached.success) {
|
||||||
return {
|
return {
|
||||||
overview: cached.data.overview,
|
overview: cached.data.overview,
|
||||||
@ -77,16 +90,10 @@ export async function profileBacklinksOverview(
|
|||||||
const normalizedTarget = normalizeBacklinksTarget(input.target, {
|
const normalizedTarget = normalizeBacklinksTarget(input.target, {
|
||||||
scope: input.scope,
|
scope: input.scope,
|
||||||
});
|
});
|
||||||
const request = buildBacklinksListRequest(
|
|
||||||
normalizedTarget.apiTarget,
|
|
||||||
100,
|
|
||||||
options,
|
|
||||||
);
|
|
||||||
const dateRange = buildBacklinksDateRange(now);
|
const dateRange = buildBacklinksDateRange(now);
|
||||||
|
|
||||||
const [summary, backlinks, history] = await Promise.all([
|
const [summary, history] = await Promise.all([
|
||||||
dataforseo.backlinks.summary({ target: request.target }),
|
dataforseo.backlinks.summary({ target: normalizedTarget.apiTarget }),
|
||||||
dataforseo.backlinks.rows(request),
|
|
||||||
normalizedTarget.scope === "domain"
|
normalizedTarget.scope === "domain"
|
||||||
? dataforseo.backlinks.history({
|
? dataforseo.backlinks.history({
|
||||||
target: normalizedTarget.apiTarget,
|
target: normalizedTarget.apiTarget,
|
||||||
@ -99,7 +106,6 @@ export async function profileBacklinksOverview(
|
|||||||
normalizedTarget,
|
normalizedTarget,
|
||||||
now,
|
now,
|
||||||
summary,
|
summary,
|
||||||
backlinks,
|
|
||||||
history,
|
history,
|
||||||
});
|
});
|
||||||
await cacheValue(
|
await cacheValue(
|
||||||
@ -112,76 +118,131 @@ export async function profileBacklinksOverview(
|
|||||||
return { overview };
|
return { overview };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function profileReferringDomainsRows(
|
export async function profileBacklinksRowsPage(
|
||||||
cache: BacklinksCache,
|
cache: BacklinksCache,
|
||||||
cacheKey: string,
|
cacheKey: string,
|
||||||
input: BacklinksLookupInput,
|
input: BacklinksRowsPageServiceInput,
|
||||||
billingCustomer: BillingCustomerContext,
|
billingCustomer: BillingCustomerContext,
|
||||||
options?: BacklinksSpamFilterOptions,
|
spamOptions?: BacklinksSpamFilterOptions,
|
||||||
): Promise<ReferringDomainsProfile> {
|
): Promise<BacklinksRowsPageResult> {
|
||||||
const cachedRaw = await cache.get(cacheKey);
|
const cached = backlinksRowsPageResultSchema.safeParse(
|
||||||
const cached = referringDomainsCacheSchema.safeParse(cachedRaw);
|
await cache.get(cacheKey),
|
||||||
if (cached.success) {
|
|
||||||
return {
|
|
||||||
rows: cached.data.rows,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const dataforseo = createDataforseoClient(billingCustomer);
|
|
||||||
|
|
||||||
const request = buildBacklinksListRequest(
|
|
||||||
normalizeBacklinksTarget(input.target, { scope: input.scope }).apiTarget,
|
|
||||||
100,
|
|
||||||
options,
|
|
||||||
);
|
);
|
||||||
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<TopPagesProfile> {
|
|
||||||
const cachedRaw = await cache.get(cacheKey);
|
|
||||||
const cached = topPagesCacheSchema.safeParse(cachedRaw);
|
|
||||||
if (cached.success) {
|
if (cached.success) {
|
||||||
return {
|
return cached.data;
|
||||||
rows: cached.data.rows,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const dataforseo = createDataforseoClient(billingCustomer);
|
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 })
|
target: normalizeBacklinksTarget(input.target, { scope: input.scope })
|
||||||
.apiTarget,
|
.apiTarget,
|
||||||
};
|
limit: input.pageSize,
|
||||||
const response = await dataforseo.backlinks.domainPages({
|
offset,
|
||||||
...request,
|
orderBy: buildBacklinksRowsOrderBy(input.sortField, input.sortOrder),
|
||||||
limit: 100,
|
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(
|
export async function profileReferringDomainsPage(
|
||||||
target: string,
|
cache: BacklinksCache,
|
||||||
limit: number,
|
cacheKey: string,
|
||||||
options?: BacklinksSpamFilterOptions,
|
input: ReferringDomainsPageServiceInput,
|
||||||
|
billingCustomer: BillingCustomerContext,
|
||||||
|
spamOptions?: BacklinksSpamFilterOptions,
|
||||||
|
): Promise<ReferringDomainsPageResult> {
|
||||||
|
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<TopPagesPageResult> {
|
||||||
|
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<TRow>(
|
||||||
|
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 {
|
return {
|
||||||
target,
|
rows: data.rows,
|
||||||
limit,
|
totalCount: data.totalCount,
|
||||||
...normalizeBacklinksSpamFilterOptions(options),
|
hasMore,
|
||||||
|
page: input.page,
|
||||||
|
pageSize: input.pageSize,
|
||||||
|
fetchedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -205,7 +266,6 @@ function buildOverviewResult(args: {
|
|||||||
normalizedTarget: ReturnType<typeof normalizeBacklinksTarget>;
|
normalizedTarget: ReturnType<typeof normalizeBacklinksTarget>;
|
||||||
now: Date;
|
now: Date;
|
||||||
summary: BacklinksSummaryItem;
|
summary: BacklinksSummaryItem;
|
||||||
backlinks: BacklinksItem[];
|
|
||||||
history: BacklinksHistoryItem[];
|
history: BacklinksHistoryItem[];
|
||||||
}): BacklinksOverviewResult {
|
}): BacklinksOverviewResult {
|
||||||
const historyRows = args.history
|
const historyRows = args.history
|
||||||
@ -253,9 +313,6 @@ function buildOverviewResult(args: {
|
|||||||
args.summary.lost_reffering_domains ??
|
args.summary.lost_reffering_domains ??
|
||||||
null,
|
null,
|
||||||
},
|
},
|
||||||
backlinks: mapBacklinksRows(args.backlinks),
|
|
||||||
referringDomains: [],
|
|
||||||
topPages: [],
|
|
||||||
trends: historyRows.map((item) => ({
|
trends: historyRows.map((item) => ({
|
||||||
date: item.date,
|
date: item.date,
|
||||||
backlinks: item.backlinks,
|
backlinks: item.backlinks,
|
||||||
|
|||||||
@ -1,8 +1,12 @@
|
|||||||
import {
|
import {
|
||||||
MAX_DATAFORSEO_FILTER_CONDITIONS,
|
assertFilterConditionBudget,
|
||||||
type DomainKeywordsFilters,
|
collectNumericRange,
|
||||||
} from "@/types/schemas/domain";
|
escapeLikeTerm,
|
||||||
import { AppError } from "@/server/lib/errors";
|
joinClauses,
|
||||||
|
parseFilterTerms,
|
||||||
|
type FilterClause,
|
||||||
|
} from "@/server/lib/dataforseo/filters";
|
||||||
|
import type { DomainKeywordsFilters } from "@/types/schemas/domain";
|
||||||
|
|
||||||
export type DomainKeywordsSortMode =
|
export type DomainKeywordsSortMode =
|
||||||
| "rank"
|
| "rank"
|
||||||
@ -27,62 +31,26 @@ export function buildOrderBy(
|
|||||||
return [`${SORT_FIELD_BY_MODE[sortMode]},${sortOrder}`];
|
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
|
* Each include/exclude term is one ilike clause; numeric ranges add one per
|
||||||
* "and"/"or" operators. Each include/exclude term is one ilike clause;
|
* bound; the free-text search term adds one OR-group of two (keyword OR url).
|
||||||
* numeric ranges add one per bound; the free-text search term adds one OR-
|
* The client surfaces the same condition count and disables Apply when over
|
||||||
* group of two (keyword OR url). The client surfaces the same condition
|
* the DataForSEO budget.
|
||||||
* count and disables Apply when over budget, so reaching the cap here
|
|
||||||
* indicates a misbehaving client — we throw rather than silently truncate.
|
|
||||||
*/
|
*/
|
||||||
type Clause = unknown[];
|
|
||||||
|
|
||||||
export function buildKeywordFilters(
|
export function buildKeywordFilters(
|
||||||
filters: DomainKeywordsFilters,
|
filters: DomainKeywordsFilters,
|
||||||
searchTerm?: string,
|
searchTerm?: string,
|
||||||
): unknown[] {
|
): unknown[] {
|
||||||
const conditions: Clause[] = [];
|
const conditions: FilterClause[] = [];
|
||||||
|
|
||||||
for (const term of parseTerms(filters.include)) {
|
for (const term of parseFilterTerms(filters.include)) {
|
||||||
conditions.push([
|
conditions.push([
|
||||||
"keyword_data.keyword",
|
"keyword_data.keyword",
|
||||||
"ilike",
|
"ilike",
|
||||||
`%${escapeLikeTerm(term)}%`,
|
`%${escapeLikeTerm(term)}%`,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
for (const term of parseTerms(filters.exclude)) {
|
for (const term of parseFilterTerms(filters.exclude)) {
|
||||||
conditions.push([
|
conditions.push([
|
||||||
"keyword_data.keyword",
|
"keyword_data.keyword",
|
||||||
"not_ilike",
|
"not_ilike",
|
||||||
@ -125,21 +93,15 @@ export function buildKeywordFilters(
|
|||||||
const searchGroup = trimmedSearch ? buildSearchGroup(trimmedSearch) : null;
|
const searchGroup = trimmedSearch ? buildSearchGroup(trimmedSearch) : null;
|
||||||
|
|
||||||
// The search OR-group costs 2 slots; everything else is 1.
|
// The search OR-group costs 2 slots; everything else is 1.
|
||||||
const totalConditions = conditions.length + (searchGroup ? 2 : 0);
|
assertFilterConditionBudget(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).`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const expressions: unknown[] = [];
|
return joinClauses(
|
||||||
for (const clause of conditions) pushAnd(expressions, clause);
|
searchGroup ? [...conditions, searchGroup] : conditions,
|
||||||
if (searchGroup) pushAnd(expressions, searchGroup);
|
"and",
|
||||||
return expressions;
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSearchGroup(term: string): Clause {
|
function buildSearchGroup(term: string): FilterClause {
|
||||||
const escaped = escapeLikeTerm(term);
|
const escaped = escapeLikeTerm(term);
|
||||||
return [
|
return [
|
||||||
["keyword_data.keyword", "ilike", `%${escaped}%`],
|
["keyword_data.keyword", "ilike", `%${escaped}%`],
|
||||||
|
|||||||
@ -209,7 +209,7 @@ describe("fetchBacklinksSummary", () => {
|
|||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
fetchBacklinksRows({ target: "example.com" }),
|
fetchBacklinksRows({ target: "example.com" }),
|
||||||
).resolves.toMatchObject({ data: [] });
|
).resolves.toMatchObject({ data: { items: [], totalCount: null } });
|
||||||
await expect(
|
await expect(
|
||||||
fetchBacklinksHistory({
|
fetchBacklinksHistory({
|
||||||
target: "example.com",
|
target: "example.com",
|
||||||
|
|||||||
@ -17,6 +17,7 @@ import {
|
|||||||
assertOk,
|
assertOk,
|
||||||
buildTaskBilling,
|
buildTaskBilling,
|
||||||
parseTaskItems,
|
parseTaskItems,
|
||||||
|
parseTaskTotalCount,
|
||||||
type DataforseoApiResponse,
|
type DataforseoApiResponse,
|
||||||
} from "@/server/lib/dataforseo/envelope";
|
} from "@/server/lib/dataforseo/envelope";
|
||||||
|
|
||||||
@ -24,7 +25,16 @@ export { normalizeBacklinksTarget } from "@/server/lib/dataforseoBacklinksTarget
|
|||||||
|
|
||||||
type BacklinksRequest = { target: string };
|
type BacklinksRequest = { target: string };
|
||||||
type BacklinksListRequest = BacklinksRequest &
|
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 = {
|
type BacklinksTimeseriesRequest = {
|
||||||
target: string;
|
target: string;
|
||||||
dateFrom: string;
|
dateFrom: string;
|
||||||
@ -146,6 +156,24 @@ function buildCommonPayload(input: BacklinksRequest) {
|
|||||||
const assertOptions = (path: string) =>
|
const assertOptions = (path: string) =>
|
||||||
({ classify: classifyBacklinksError, classifyPath: path }) as const;
|
({ 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) {
|
export async function fetchBacklinksSummary(input: BacklinksRequest) {
|
||||||
const response = await backlinksApi(classifyBacklinksError).summaryLive([
|
const response = await backlinksApi(classifyBacklinksError).summaryLive([
|
||||||
new BacklinksSummaryLiveRequestInfo(buildCommonPayload(input)),
|
new BacklinksSummaryLiveRequestInfo(buildCommonPayload(input)),
|
||||||
@ -179,14 +207,19 @@ export async function fetchBacklinksSummary(input: BacklinksRequest) {
|
|||||||
|
|
||||||
export async function fetchBacklinksRows(input: BacklinksListRequest) {
|
export async function fetchBacklinksRows(input: BacklinksListRequest) {
|
||||||
const spamFilterOptions = normalizeBacklinksSpamFilterOptions(input);
|
const spamFilterOptions = normalizeBacklinksSpamFilterOptions(input);
|
||||||
const filters = spamFilterOptions.hideSpam
|
const filters = combineFilters(
|
||||||
? [["backlink_spam_score", "<=", spamFilterOptions.spamThreshold]]
|
input.filters,
|
||||||
: undefined;
|
spamFilterOptions.hideSpam
|
||||||
|
? ["backlink_spam_score", "<=", spamFilterOptions.spamThreshold]
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
const response = await backlinksApi(classifyBacklinksError).backlinksLive([
|
const response = await backlinksApi(classifyBacklinksError).backlinksLive([
|
||||||
new BacklinksBacklinksLiveRequestInfo({
|
new BacklinksBacklinksLiveRequestInfo({
|
||||||
...buildCommonPayload(input),
|
...buildCommonPayload(input),
|
||||||
limit: input.limit ?? 100,
|
limit: input.limit ?? 100,
|
||||||
order_by: ["rank,desc"],
|
offset: input.offset,
|
||||||
|
order_by: input.orderBy ?? ["rank,desc"],
|
||||||
|
mode: input.mode,
|
||||||
...(filters ? { filters } : {}),
|
...(filters ? { filters } : {}),
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
@ -195,23 +228,30 @@ export async function fetchBacklinksRows(input: BacklinksListRequest) {
|
|||||||
assertOptions("/v3/backlinks/backlinks/live"),
|
assertOptions("/v3/backlinks/backlinks/live"),
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
data: parseTaskItems("backlinks-live", task, backlinksItemSchema),
|
data: {
|
||||||
|
items: parseTaskItems("backlinks-live", task, backlinksItemSchema),
|
||||||
|
totalCount: parseTaskTotalCount(task),
|
||||||
|
},
|
||||||
billing: buildTaskBilling(task),
|
billing: buildTaskBilling(task),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchReferringDomains(input: BacklinksListRequest) {
|
export async function fetchReferringDomains(input: BacklinksListRequest) {
|
||||||
const spamFilterOptions = normalizeBacklinksSpamFilterOptions(input);
|
const spamFilterOptions = normalizeBacklinksSpamFilterOptions(input);
|
||||||
const filters = spamFilterOptions.hideSpam
|
const filters = combineFilters(
|
||||||
? [["backlinks_spam_score", "<=", spamFilterOptions.spamThreshold]]
|
input.filters,
|
||||||
: undefined;
|
spamFilterOptions.hideSpam
|
||||||
|
? ["backlinks_spam_score", "<=", spamFilterOptions.spamThreshold]
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
const response = await backlinksApi(
|
const response = await backlinksApi(
|
||||||
classifyBacklinksError,
|
classifyBacklinksError,
|
||||||
).referringDomainsLive([
|
).referringDomainsLive([
|
||||||
new BacklinksReferringDomainsLiveRequestInfo({
|
new BacklinksReferringDomainsLiveRequestInfo({
|
||||||
...buildCommonPayload(input),
|
...buildCommonPayload(input),
|
||||||
limit: input.limit ?? 100,
|
limit: input.limit ?? 100,
|
||||||
order_by: ["backlinks,desc"],
|
offset: input.offset,
|
||||||
|
order_by: input.orderBy ?? ["backlinks,desc"],
|
||||||
...(filters ? { filters } : {}),
|
...(filters ? { filters } : {}),
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
@ -220,23 +260,30 @@ export async function fetchReferringDomains(input: BacklinksListRequest) {
|
|||||||
assertOptions("/v3/backlinks/referring_domains/live"),
|
assertOptions("/v3/backlinks/referring_domains/live"),
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
data: parseTaskItems(
|
data: {
|
||||||
|
items: parseTaskItems(
|
||||||
"referring-domains-live",
|
"referring-domains-live",
|
||||||
task,
|
task,
|
||||||
referringDomainItemSchema,
|
referringDomainItemSchema,
|
||||||
),
|
),
|
||||||
|
totalCount: parseTaskTotalCount(task),
|
||||||
|
},
|
||||||
billing: buildTaskBilling(task),
|
billing: buildTaskBilling(task),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchDomainPagesSummary(input: BacklinksListRequest) {
|
export async function fetchDomainPagesSummary(input: BacklinksListRequest) {
|
||||||
|
const filters =
|
||||||
|
input.filters && input.filters.length > 0 ? input.filters : undefined;
|
||||||
const response = await backlinksApi(
|
const response = await backlinksApi(
|
||||||
classifyBacklinksError,
|
classifyBacklinksError,
|
||||||
).domainPagesSummaryLive([
|
).domainPagesSummaryLive([
|
||||||
new BacklinksDomainPagesSummaryLiveRequestInfo({
|
new BacklinksDomainPagesSummaryLiveRequestInfo({
|
||||||
...buildCommonPayload(input),
|
...buildCommonPayload(input),
|
||||||
limit: input.limit ?? 100,
|
limit: input.limit ?? 100,
|
||||||
order_by: ["backlinks,desc"],
|
offset: input.offset,
|
||||||
|
order_by: input.orderBy ?? ["backlinks,desc"],
|
||||||
|
...(filters ? { filters } : {}),
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
const task = assertOk(
|
const task = assertOk(
|
||||||
@ -244,11 +291,14 @@ export async function fetchDomainPagesSummary(input: BacklinksListRequest) {
|
|||||||
assertOptions("/v3/backlinks/domain_pages_summary/live"),
|
assertOptions("/v3/backlinks/domain_pages_summary/live"),
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
data: parseTaskItems(
|
data: {
|
||||||
|
items: parseTaskItems(
|
||||||
"domain-pages-summary-live",
|
"domain-pages-summary-live",
|
||||||
task,
|
task,
|
||||||
domainPageSummaryItemSchema,
|
domainPageSummaryItemSchema,
|
||||||
),
|
),
|
||||||
|
totalCount: parseTaskTotalCount(task),
|
||||||
|
},
|
||||||
billing: buildTaskBilling(task),
|
billing: buildTaskBilling(task),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -148,6 +148,13 @@ export function isRecord(value: unknown): value is Record<string, unknown> {
|
|||||||
return typeof value === "object" && value !== null;
|
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. */
|
/** Reads `task.result[0].items`, validating against a Zod schema for loosely-typed endpoints. */
|
||||||
export function parseTaskItems<T extends z.ZodTypeAny>(
|
export function parseTaskItems<T extends z.ZodTypeAny>(
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
|
|||||||
89
src/server/lib/dataforseo/filters.ts
Normal file
89
src/server/lib/dataforseo/filters.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
@ -57,24 +57,28 @@ export const getBacklinksOverviewTool = {
|
|||||||
const lookup = { target: args.target, scope: args.scope };
|
const lookup = { target: args.target, scope: args.scope };
|
||||||
const spamOptions = { hideSpam: args.hideSpam ?? true };
|
const spamOptions = { hideSpam: args.hideSpam ?? true };
|
||||||
const [overview, refDomains] = await Promise.all([
|
const [overview, refDomains] = await Promise.all([
|
||||||
BacklinksService.profileOverview(lookup, context.billing, spamOptions),
|
BacklinksService.profileOverview(lookup, context.billing),
|
||||||
BacklinksService.profileReferringDomains(
|
BacklinksService.profileReferringDomainsPage(
|
||||||
lookup,
|
{
|
||||||
|
...lookup,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 100,
|
||||||
|
sortField: "backlinks",
|
||||||
|
sortOrder: "desc",
|
||||||
|
filters: {},
|
||||||
|
},
|
||||||
context.billing,
|
context.billing,
|
||||||
spamOptions,
|
spamOptions,
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
const topDomains = refDomains.rows ?? [];
|
const topDomains = refDomains.rows ?? [];
|
||||||
const overviewRecord =
|
const summary = overview.overview.summary;
|
||||||
overview && typeof overview === "object"
|
|
||||||
? (overview as Record<string, unknown>)
|
|
||||||
: {};
|
|
||||||
const text = [
|
const text = [
|
||||||
`Backlinks profile for ${args.target} (${args.scope ?? "domain"}):`,
|
`Backlinks profile for ${args.target} (${args.scope ?? "domain"}):`,
|
||||||
`- backlinks: ${formatMetric(overviewRecord.backlinks)}`,
|
`- backlinks: ${formatMetric(summary.backlinks)}`,
|
||||||
`- referring domains: ${formatMetric(overviewRecord.referring_domains)}`,
|
`- referring domains: ${formatMetric(summary.referringDomains)}`,
|
||||||
`- referring pages: ${formatMetric(overviewRecord.referring_pages)}`,
|
`- referring pages: ${formatMetric(summary.referringPages)}`,
|
||||||
`- rank: ${formatMetric(overviewRecord.rank)}`,
|
`- rank: ${formatMetric(summary.rank)}`,
|
||||||
"",
|
"",
|
||||||
`Top referring domains (${Math.min(topDomains.length, 10)} shown):`,
|
`Top referring domains (${Math.min(topDomains.length, 10)} shown):`,
|
||||||
...topDomains
|
...topDomains
|
||||||
|
|||||||
@ -1,7 +1,16 @@
|
|||||||
import { createServerFn } from "@tanstack/react-start";
|
import { createServerFn } from "@tanstack/react-start";
|
||||||
import { BacklinksService } from "@/server/features/backlinks/services/BacklinksService";
|
import { BacklinksService } from "@/server/features/backlinks/services/BacklinksService";
|
||||||
import { requireProjectContext } from "@/serverFunctions/middleware";
|
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({
|
export const getBacklinksOverview = createServerFn({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@ -9,49 +18,45 @@ export const getBacklinksOverview = createServerFn({
|
|||||||
.middleware(requireProjectContext)
|
.middleware(requireProjectContext)
|
||||||
.inputValidator((data: unknown) => backlinksOverviewInputSchema.parse(data))
|
.inputValidator((data: unknown) => backlinksOverviewInputSchema.parse(data))
|
||||||
.handler(async ({ data, context }) => {
|
.handler(async ({ data, context }) => {
|
||||||
const input = {
|
const profile = await BacklinksService.profileOverview(
|
||||||
|
{
|
||||||
target: data.target,
|
target: data.target,
|
||||||
scope: data.scope,
|
scope: data.scope,
|
||||||
};
|
},
|
||||||
const spamOptions = {
|
|
||||||
hideSpam: data.hideSpam,
|
|
||||||
spamThreshold: data.spamThreshold,
|
|
||||||
};
|
|
||||||
const profile = await BacklinksService.profileOverview(
|
|
||||||
input,
|
|
||||||
context,
|
context,
|
||||||
spamOptions,
|
|
||||||
);
|
);
|
||||||
return profile.overview;
|
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({
|
export const getBacklinksReferringDomains = createServerFn({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
})
|
})
|
||||||
.middleware(requireProjectContext)
|
.middleware(requireProjectContext)
|
||||||
.inputValidator((data: unknown) => backlinksOverviewInputSchema.parse(data))
|
.inputValidator((data: unknown) =>
|
||||||
.handler(async ({ data, context }) => {
|
referringDomainsPageRequestSchema.parse(data),
|
||||||
const input = {
|
)
|
||||||
target: data.target,
|
.handler(({ data, context }) =>
|
||||||
scope: data.scope,
|
BacklinksService.profileReferringDomainsPage(
|
||||||
};
|
data,
|
||||||
const profile = await BacklinksService.profileReferringDomains(
|
|
||||||
input,
|
|
||||||
context,
|
context,
|
||||||
|
WEB_SPAM_OPTIONS,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
return profile.rows;
|
|
||||||
});
|
|
||||||
|
|
||||||
export const getBacklinksTopPages = createServerFn({
|
export const getBacklinksTopPages = createServerFn({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
})
|
})
|
||||||
.middleware(requireProjectContext)
|
.middleware(requireProjectContext)
|
||||||
.inputValidator((data: unknown) => backlinksOverviewInputSchema.parse(data))
|
.inputValidator((data: unknown) => topPagesPageRequestSchema.parse(data))
|
||||||
.handler(async ({ data, context }) => {
|
.handler(({ data, context }) =>
|
||||||
const input = {
|
BacklinksService.profileTopPagesPage(data, context),
|
||||||
target: data.target,
|
);
|
||||||
scope: data.scope,
|
|
||||||
};
|
|
||||||
const profile = await BacklinksService.profileTopPages(input, context);
|
|
||||||
return profile.rows;
|
|
||||||
});
|
|
||||||
|
|||||||
@ -40,23 +40,192 @@ export const backlinksProjectSchema = z.object({
|
|||||||
projectId: z.string().min(1),
|
projectId: z.string().min(1),
|
||||||
});
|
});
|
||||||
|
|
||||||
const backlinksSpamFilterSchema = z.object({
|
export const backlinksOverviewInputSchema = backlinksLookupSchema.extend({
|
||||||
hideSpam: z.boolean().optional(),
|
projectId: z.string().min(1),
|
||||||
spamThreshold: z.number().int().min(0).max(100).optional(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const backlinksOverviewInputSchema = backlinksLookupSchema
|
/* ------------------------------------------------------------------ */
|
||||||
.extend({
|
/* 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<typeof backlinksTabSchema>,
|
||||||
|
{ field: string; order: z.infer<typeof backlinksSortOrderSchema> }
|
||||||
|
>;
|
||||||
|
|
||||||
|
const backlinksPageRequestBase = backlinksLookupSchema.extend({
|
||||||
projectId: z.string().min(1),
|
projectId: z.string().min(1),
|
||||||
})
|
page: z.number().int().positive().default(1),
|
||||||
.merge(backlinksSpamFilterSchema);
|
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({
|
export const backlinksSearchSchema = z.object({
|
||||||
target: z.string().optional(),
|
target: z.string().optional(),
|
||||||
scope: backlinksTargetScopeSchema.optional(),
|
scope: backlinksTargetScopeSchema.optional(),
|
||||||
tab: backlinksTabSchema.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<typeof backlinksLookupSchema>;
|
export type BacklinksLookupInput = z.infer<typeof backlinksLookupSchema>;
|
||||||
export type BacklinksTab = z.infer<typeof backlinksTabSchema>;
|
export type BacklinksTab = z.infer<typeof backlinksTabSchema>;
|
||||||
export type BacklinksTargetScope = z.infer<typeof backlinksTargetScopeSchema>;
|
export type BacklinksTargetScope = z.infer<typeof backlinksTargetScopeSchema>;
|
||||||
|
export type BacklinksSortOrder = z.infer<typeof backlinksSortOrderSchema>;
|
||||||
|
export type BacklinksRowsSortField = z.infer<
|
||||||
|
typeof backlinksRowsSortFieldSchema
|
||||||
|
>;
|
||||||
|
export type ReferringDomainsSortField = z.infer<
|
||||||
|
typeof referringDomainsSortFieldSchema
|
||||||
|
>;
|
||||||
|
export type TopPagesSortField = z.infer<typeof topPagesSortFieldSchema>;
|
||||||
|
export type BacklinksRowsFilters = z.infer<typeof backlinksRowsFiltersSchema>;
|
||||||
|
export type ReferringDomainsFilters = z.infer<
|
||||||
|
typeof referringDomainsFiltersSchema
|
||||||
|
>;
|
||||||
|
export type TopPagesFilters = z.infer<typeof topPagesFiltersSchema>;
|
||||||
|
export type BacklinksRowsPageInput = z.infer<
|
||||||
|
typeof backlinksRowsPageRequestSchema
|
||||||
|
>;
|
||||||
|
export type ReferringDomainsPageInput = z.infer<
|
||||||
|
typeof referringDomainsPageRequestSchema
|
||||||
|
>;
|
||||||
|
export type TopPagesPageInput = z.infer<typeof topPagesPageRequestSchema>;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user