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