Refine rank tracking onboarding and add ahrefs backlinks filter (#253)
This commit is contained in:
parent
c38b453ab0
commit
2db6fdec71
@ -106,8 +106,10 @@ function CompactRangeInput({
|
|||||||
|
|
||||||
function BacklinksTabFilters({
|
function BacklinksTabFilters({
|
||||||
form,
|
form,
|
||||||
|
showAhrefsDrFilter,
|
||||||
}: {
|
}: {
|
||||||
form: BacklinksFiltersState["backlinks"]["form"];
|
form: BacklinksFiltersState["backlinks"]["form"];
|
||||||
|
showAhrefsDrFilter: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -133,6 +135,14 @@ function BacklinksTabFilters({
|
|||||||
minName="minDomainRank"
|
minName="minDomainRank"
|
||||||
maxName="maxDomainRank"
|
maxName="maxDomainRank"
|
||||||
/>
|
/>
|
||||||
|
{showAhrefsDrFilter ? (
|
||||||
|
<FilterRangeInputs
|
||||||
|
form={form}
|
||||||
|
title="Ahrefs DR"
|
||||||
|
minName="minAhrefsDr"
|
||||||
|
maxName="maxAhrefsDr"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<FilterRangeInputs
|
<FilterRangeInputs
|
||||||
form={form}
|
form={form}
|
||||||
title="Link Authority"
|
title="Link Authority"
|
||||||
@ -219,8 +229,10 @@ function BacklinksTabFilters({
|
|||||||
|
|
||||||
function ReferringDomainsFilters({
|
function ReferringDomainsFilters({
|
||||||
form,
|
form,
|
||||||
|
showAhrefsDrFilter,
|
||||||
}: {
|
}: {
|
||||||
form: BacklinksFiltersState["domains"]["form"];
|
form: BacklinksFiltersState["domains"]["form"];
|
||||||
|
showAhrefsDrFilter: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -252,6 +264,14 @@ function ReferringDomainsFilters({
|
|||||||
minName="minRank"
|
minName="minRank"
|
||||||
maxName="maxRank"
|
maxName="maxRank"
|
||||||
/>
|
/>
|
||||||
|
{showAhrefsDrFilter ? (
|
||||||
|
<FilterRangeInputs
|
||||||
|
form={form}
|
||||||
|
title="Ahrefs DR"
|
||||||
|
minName="minAhrefsDr"
|
||||||
|
maxName="maxAhrefsDr"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<FilterRangeInputs
|
<FilterRangeInputs
|
||||||
form={form}
|
form={form}
|
||||||
title="Spam Score"
|
title="Spam Score"
|
||||||
@ -313,9 +333,13 @@ function TopPagesFilters({
|
|||||||
export function BacklinksFilterPanel({
|
export function BacklinksFilterPanel({
|
||||||
activeTab,
|
activeTab,
|
||||||
filters,
|
filters,
|
||||||
|
showAhrefsDrFilter,
|
||||||
|
activeFilterCount,
|
||||||
}: {
|
}: {
|
||||||
activeTab: BacklinksTab;
|
activeTab: BacklinksTab;
|
||||||
filters: BacklinksFiltersState;
|
filters: BacklinksFiltersState;
|
||||||
|
showAhrefsDrFilter: boolean;
|
||||||
|
activeFilterCount: number;
|
||||||
}) {
|
}) {
|
||||||
const current = filters[activeTab];
|
const current = filters[activeTab];
|
||||||
|
|
||||||
@ -324,9 +348,9 @@ export function BacklinksFilterPanel({
|
|||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<p className="text-sm font-semibold">Refine results</p>
|
<p className="text-sm font-semibold">Refine results</p>
|
||||||
{current.activeFilterCount > 0 ? (
|
{activeFilterCount > 0 ? (
|
||||||
<span className="badge badge-xs badge-primary border-0 text-primary-content">
|
<span className="badge badge-xs badge-primary border-0 text-primary-content">
|
||||||
{current.activeFilterCount} active
|
{activeFilterCount} active
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@ -334,7 +358,7 @@ export function BacklinksFilterPanel({
|
|||||||
type="button"
|
type="button"
|
||||||
className="btn btn-xs btn-ghost gap-1"
|
className="btn btn-xs btn-ghost gap-1"
|
||||||
onClick={current.reset}
|
onClick={current.reset}
|
||||||
disabled={current.activeFilterCount === 0}
|
disabled={activeFilterCount === 0}
|
||||||
>
|
>
|
||||||
<RotateCcw className="size-3" />
|
<RotateCcw className="size-3" />
|
||||||
Clear all
|
Clear all
|
||||||
@ -342,10 +366,16 @@ export function BacklinksFilterPanel({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{activeTab === "backlinks" ? (
|
{activeTab === "backlinks" ? (
|
||||||
<BacklinksTabFilters form={filters.backlinks.form} />
|
<BacklinksTabFilters
|
||||||
|
form={filters.backlinks.form}
|
||||||
|
showAhrefsDrFilter={showAhrefsDrFilter}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{activeTab === "domains" ? (
|
{activeTab === "domains" ? (
|
||||||
<ReferringDomainsFilters form={filters.domains.form} />
|
<ReferringDomainsFilters
|
||||||
|
form={filters.domains.form}
|
||||||
|
showAhrefsDrFilter={showAhrefsDrFilter}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{activeTab === "pages" ? (
|
{activeTab === "pages" ? (
|
||||||
<TopPagesFilters form={filters.pages.form} />
|
<TopPagesFilters form={filters.pages.form} />
|
||||||
|
|||||||
@ -23,6 +23,10 @@ import {
|
|||||||
BacklinksExportMenu,
|
BacklinksExportMenu,
|
||||||
} from "./BacklinksToolbarMenus";
|
} from "./BacklinksToolbarMenus";
|
||||||
import { buildBacklinksTabExport } from "./export";
|
import { buildBacklinksTabExport } from "./export";
|
||||||
|
import {
|
||||||
|
filterBacklinkRows,
|
||||||
|
filterReferringDomainRows,
|
||||||
|
} from "./backlinksFiltering";
|
||||||
import type { BacklinksFiltersState } from "./useBacklinksFilters";
|
import type { BacklinksFiltersState } from "./useBacklinksFilters";
|
||||||
import { useAhrefsDomainRatings } from "./useAhrefsDomainRatings";
|
import { useAhrefsDomainRatings } from "./useAhrefsDomainRatings";
|
||||||
|
|
||||||
@ -101,21 +105,49 @@ export function BacklinksResultsCard({
|
|||||||
exportTarget: string;
|
exportTarget: string;
|
||||||
onTabChange: (tab: BacklinksSearchState["tab"]) => void;
|
onTabChange: (tab: BacklinksSearchState["tab"]) => void;
|
||||||
}) {
|
}) {
|
||||||
const currentFilterCount = filters[activeTab].activeFilterCount;
|
|
||||||
const exportTable = useMemo(
|
|
||||||
() => buildBacklinksTabExport({ tab: activeTab, rows: filteredData }),
|
|
||||||
[activeTab, filteredData],
|
|
||||||
);
|
|
||||||
const {
|
const {
|
||||||
ratings: domainRatings,
|
ratings: domainRatings,
|
||||||
isLoading: isLoadingRatings,
|
isLoading: isLoadingRatings,
|
||||||
loadRatings,
|
loadRatings,
|
||||||
} = useAhrefsDomainRatings(projectId);
|
} = 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 exportTable = useMemo(
|
||||||
|
() =>
|
||||||
|
buildBacklinksTabExport({ tab: activeTab, rows: visibleFilteredData }),
|
||||||
|
[activeTab, visibleFilteredData],
|
||||||
|
);
|
||||||
// Domains keyed by both tables that the DR column can enrich. The Referring
|
// 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 list loads lazily, so this grows once that tab is opened.
|
||||||
const ratableDomains = useMemo(
|
const ratableDomains = useMemo(
|
||||||
() => collectRatableDomains(filteredData),
|
() => collectRatableDomains(visibleFilteredData),
|
||||||
[filteredData],
|
[visibleFilteredData],
|
||||||
);
|
);
|
||||||
// Once the user has opted in, keep newly loaded domains enriched without a
|
// Once the user has opted in, keep newly loaded domains enriched without a
|
||||||
// re-click (e.g. after switching to the lazily-loaded Referring Domains tab).
|
// re-click (e.g. after switching to the lazily-loaded Referring Domains tab).
|
||||||
@ -152,7 +184,7 @@ export function BacklinksResultsCard({
|
|||||||
<BacklinksExportMenu
|
<BacklinksExportMenu
|
||||||
activeTab={activeTab}
|
activeTab={activeTab}
|
||||||
exportTarget={exportTarget}
|
exportTarget={exportTarget}
|
||||||
filteredData={filteredData}
|
filteredData={visibleFilteredData}
|
||||||
headers={exportTable.headers}
|
headers={exportTable.headers}
|
||||||
rows={exportTable.rows}
|
rows={exportTable.rows}
|
||||||
/>
|
/>
|
||||||
@ -183,7 +215,12 @@ export function BacklinksResultsCard({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{filters.showFilters ? (
|
{filters.showFilters ? (
|
||||||
<BacklinksFilterPanel activeTab={activeTab} filters={filters} />
|
<BacklinksFilterPanel
|
||||||
|
activeTab={activeTab}
|
||||||
|
filters={filters}
|
||||||
|
showAhrefsDrFilter={showAhrefsDrFilter}
|
||||||
|
activeFilterCount={currentFilterCount}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
@ -194,7 +231,7 @@ export function BacklinksResultsCard({
|
|||||||
) : null}
|
) : null}
|
||||||
{activeTab === "backlinks" ? (
|
{activeTab === "backlinks" ? (
|
||||||
<BacklinksTable
|
<BacklinksTable
|
||||||
rows={filteredData.backlinks}
|
rows={visibleFilteredData.backlinks}
|
||||||
domainRatings={domainRatings}
|
domainRatings={domainRatings}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
@ -203,7 +240,7 @@ export function BacklinksResultsCard({
|
|||||||
) : null}
|
) : null}
|
||||||
{activeTab === "domains" && !isTabLoading && !tabErrorMessage ? (
|
{activeTab === "domains" && !isTabLoading && !tabErrorMessage ? (
|
||||||
<ReferringDomainsTable
|
<ReferringDomainsTable
|
||||||
rows={filteredData.referringDomains}
|
rows={visibleFilteredData.referringDomains}
|
||||||
domainRatings={domainRatings}
|
domainRatings={domainRatings}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
@ -235,6 +272,21 @@ function collectRatableDomains(filteredData: {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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({
|
function OverviewGrid({
|
||||||
data,
|
data,
|
||||||
summaryStats,
|
summaryStats,
|
||||||
|
|||||||
@ -3,6 +3,8 @@ export type BacklinksTabFilterValues = {
|
|||||||
exclude: string;
|
exclude: string;
|
||||||
minDomainRank: string;
|
minDomainRank: string;
|
||||||
maxDomainRank: string;
|
maxDomainRank: string;
|
||||||
|
minAhrefsDr: string;
|
||||||
|
maxAhrefsDr: string;
|
||||||
minLinkAuthority: string;
|
minLinkAuthority: string;
|
||||||
maxLinkAuthority: string;
|
maxLinkAuthority: string;
|
||||||
minSpamScore: string;
|
minSpamScore: string;
|
||||||
@ -19,6 +21,8 @@ export type ReferringDomainsFilterValues = {
|
|||||||
maxBacklinks: string;
|
maxBacklinks: string;
|
||||||
minRank: string;
|
minRank: string;
|
||||||
maxRank: string;
|
maxRank: string;
|
||||||
|
minAhrefsDr: string;
|
||||||
|
maxAhrefsDr: string;
|
||||||
minSpamScore: string;
|
minSpamScore: string;
|
||||||
maxSpamScore: string;
|
maxSpamScore: string;
|
||||||
};
|
};
|
||||||
@ -39,6 +43,8 @@ export const EMPTY_BACKLINKS_FILTERS: BacklinksTabFilterValues = {
|
|||||||
exclude: "",
|
exclude: "",
|
||||||
minDomainRank: "",
|
minDomainRank: "",
|
||||||
maxDomainRank: "",
|
maxDomainRank: "",
|
||||||
|
minAhrefsDr: "",
|
||||||
|
maxAhrefsDr: "",
|
||||||
minLinkAuthority: "",
|
minLinkAuthority: "",
|
||||||
maxLinkAuthority: "",
|
maxLinkAuthority: "",
|
||||||
minSpamScore: "",
|
minSpamScore: "",
|
||||||
@ -55,6 +61,8 @@ export const EMPTY_REFERRING_DOMAINS_FILTERS: ReferringDomainsFilterValues = {
|
|||||||
maxBacklinks: "",
|
maxBacklinks: "",
|
||||||
minRank: "",
|
minRank: "",
|
||||||
maxRank: "",
|
maxRank: "",
|
||||||
|
minAhrefsDr: "",
|
||||||
|
maxAhrefsDr: "",
|
||||||
minSpamScore: "",
|
minSpamScore: "",
|
||||||
maxSpamScore: "",
|
maxSpamScore: "",
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,10 +1,39 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type { BacklinksOverviewData } from "./backlinksPageTypes";
|
import type { BacklinksOverviewData } from "./backlinksPageTypes";
|
||||||
import { EMPTY_REFERRING_DOMAINS_FILTERS } from "./backlinksFilterTypes";
|
import {
|
||||||
import { filterReferringDomainRows } from "./backlinksFiltering";
|
EMPTY_BACKLINKS_FILTERS,
|
||||||
|
EMPTY_REFERRING_DOMAINS_FILTERS,
|
||||||
|
} from "./backlinksFilterTypes";
|
||||||
|
import {
|
||||||
|
filterBacklinkRows,
|
||||||
|
filterReferringDomainRows,
|
||||||
|
} from "./backlinksFiltering";
|
||||||
|
|
||||||
|
type BacklinkRow = BacklinksOverviewData["backlinks"][number];
|
||||||
type ReferringDomainRow = BacklinksOverviewData["referringDomains"][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(
|
function makeReferringDomainRow(
|
||||||
overrides: Partial<ReferringDomainRow> = {},
|
overrides: Partial<ReferringDomainRow> = {},
|
||||||
): ReferringDomainRow {
|
): ReferringDomainRow {
|
||||||
@ -21,6 +50,57 @@ function makeReferringDomainRow(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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", () => {
|
describe("filterReferringDomainRows", () => {
|
||||||
it("filters by spam score range", () => {
|
it("filters by spam score range", () => {
|
||||||
const rows = [
|
const rows = [
|
||||||
@ -43,4 +123,53 @@ describe("filterReferringDomainRows", () => {
|
|||||||
}),
|
}),
|
||||||
).toEqual([rows[1], rows[2]]);
|
).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]]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import type {
|
|||||||
ReferringDomainsFilterValues,
|
ReferringDomainsFilterValues,
|
||||||
TopPagesFilterValues,
|
TopPagesFilterValues,
|
||||||
} from "./backlinksFilterTypes";
|
} from "./backlinksFilterTypes";
|
||||||
|
import type { DomainRatings } from "./useAhrefsDomainRatings";
|
||||||
|
|
||||||
function passesNumericFilter(
|
function passesNumericFilter(
|
||||||
value: number | null | undefined,
|
value: number | null | undefined,
|
||||||
@ -40,6 +41,7 @@ function passesTextFilter(
|
|||||||
export function filterBacklinkRows(
|
export function filterBacklinkRows(
|
||||||
rows: BacklinksOverviewData["backlinks"],
|
rows: BacklinksOverviewData["backlinks"],
|
||||||
filters: BacklinksTabFilterValues,
|
filters: BacklinksTabFilterValues,
|
||||||
|
domainRatings?: DomainRatings | null,
|
||||||
): BacklinksOverviewData["backlinks"] {
|
): BacklinksOverviewData["backlinks"] {
|
||||||
const includeTerms = parseTerms(filters.include);
|
const includeTerms = parseTerms(filters.include);
|
||||||
const excludeTerms = parseTerms(filters.exclude);
|
const excludeTerms = parseTerms(filters.exclude);
|
||||||
@ -58,6 +60,17 @@ export function filterBacklinkRows(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
return false;
|
return false;
|
||||||
|
if (
|
||||||
|
domainRatings &&
|
||||||
|
!passesNumericFilter(
|
||||||
|
row.domainFrom
|
||||||
|
? domainRatings[row.domainFrom.replace(/^www\./, "")]
|
||||||
|
: null,
|
||||||
|
filters.minAhrefsDr,
|
||||||
|
filters.maxAhrefsDr,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return false;
|
||||||
if (
|
if (
|
||||||
!passesNumericFilter(
|
!passesNumericFilter(
|
||||||
row.rank,
|
row.rank,
|
||||||
@ -90,6 +103,7 @@ export function filterBacklinkRows(
|
|||||||
export function filterReferringDomainRows(
|
export function filterReferringDomainRows(
|
||||||
rows: BacklinksOverviewData["referringDomains"],
|
rows: BacklinksOverviewData["referringDomains"],
|
||||||
filters: ReferringDomainsFilterValues,
|
filters: ReferringDomainsFilterValues,
|
||||||
|
domainRatings?: DomainRatings | null,
|
||||||
): BacklinksOverviewData["referringDomains"] {
|
): BacklinksOverviewData["referringDomains"] {
|
||||||
const includeTerms = parseTerms(filters.include);
|
const includeTerms = parseTerms(filters.include);
|
||||||
const excludeTerms = parseTerms(filters.exclude);
|
const excludeTerms = parseTerms(filters.exclude);
|
||||||
@ -107,6 +121,15 @@ export function filterReferringDomainRows(
|
|||||||
return false;
|
return false;
|
||||||
if (!passesNumericFilter(row.rank, filters.minRank, filters.maxRank))
|
if (!passesNumericFilter(row.rank, filters.minRank, filters.maxRank))
|
||||||
return false;
|
return false;
|
||||||
|
if (
|
||||||
|
domainRatings &&
|
||||||
|
!passesNumericFilter(
|
||||||
|
row.domain ? domainRatings[row.domain] : null,
|
||||||
|
filters.minAhrefsDr,
|
||||||
|
filters.maxAhrefsDr,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return false;
|
||||||
if (
|
if (
|
||||||
!passesNumericFilter(
|
!passesNumericFilter(
|
||||||
row.spamScore,
|
row.spamScore,
|
||||||
|
|||||||
@ -5,11 +5,8 @@ import {
|
|||||||
type RowSelectionState,
|
type RowSelectionState,
|
||||||
type SortingState,
|
type SortingState,
|
||||||
} from "@tanstack/react-table";
|
} from "@tanstack/react-table";
|
||||||
import { Loader2, AlertCircle, FileDown, Sheet, X } from "lucide-react";
|
import { Loader2, AlertCircle, X } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { buildCsv, downloadCsv } from "@/client/lib/csv";
|
|
||||||
import { exportTableToSheets } from "@/client/lib/exportToSheets";
|
|
||||||
import { captureClientEvent } from "@/client/lib/posthog";
|
|
||||||
import { getDomainKeywordSuggestions } from "@/serverFunctions/domain";
|
import { getDomainKeywordSuggestions } from "@/serverFunctions/domain";
|
||||||
import { addTrackingKeywords } from "@/serverFunctions/rank-tracking";
|
import { addTrackingKeywords } from "@/serverFunctions/rank-tracking";
|
||||||
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||||
@ -18,11 +15,6 @@ import {
|
|||||||
makeSelectionColumn,
|
makeSelectionColumn,
|
||||||
useAppTable,
|
useAppTable,
|
||||||
} from "@/client/components/table/AppDataTable";
|
} from "@/client/components/table/AppDataTable";
|
||||||
import {
|
|
||||||
TableBulkActionBar,
|
|
||||||
TableBulkActionButton,
|
|
||||||
TableBulkExportMenu,
|
|
||||||
} from "@/client/components/table/TableBulkActionBar";
|
|
||||||
import { SortableHeader } from "./RankTrackingColumns";
|
import { SortableHeader } from "./RankTrackingColumns";
|
||||||
import {
|
import {
|
||||||
applyShiftRangeSelection,
|
applyShiftRangeSelection,
|
||||||
@ -37,12 +29,6 @@ type SuggestedKeyword = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const PRE_SELECT_COUNT = 20;
|
const PRE_SELECT_COUNT = 20;
|
||||||
const SUGGESTED_KEYWORD_EXPORT_HEADERS = [
|
|
||||||
"Keyword",
|
|
||||||
"Position",
|
|
||||||
"Volume",
|
|
||||||
"Traffic",
|
|
||||||
];
|
|
||||||
|
|
||||||
const baseColumns: ColumnDef<SuggestedKeyword>[] = [
|
const baseColumns: ColumnDef<SuggestedKeyword>[] = [
|
||||||
{
|
{
|
||||||
@ -240,32 +226,6 @@ export function KeywordSuggestionStep({
|
|||||||
addMutation.mutate(selectedKeywords);
|
addMutation.mutate(selectedKeywords);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const selectedSuggestionRows = table
|
|
||||||
.getSelectedRowModel()
|
|
||||||
.rows.map((row) => row.original);
|
|
||||||
const selectedSuggestionExportRows = selectedSuggestionRows.map((row) => [
|
|
||||||
row.keyword,
|
|
||||||
row.position ?? "",
|
|
||||||
row.searchVolume ?? "",
|
|
||||||
row.traffic ?? "",
|
|
||||||
]);
|
|
||||||
const handleExportSelectionToSheets = () => {
|
|
||||||
void exportTableToSheets({
|
|
||||||
headers: SUGGESTED_KEYWORD_EXPORT_HEADERS,
|
|
||||||
rows: selectedSuggestionExportRows,
|
|
||||||
feature: "rank_tracking",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const handleExportSelectionCsv = () => {
|
|
||||||
downloadCsv(
|
|
||||||
`rank-tracking-suggestions-${domain}.csv`,
|
|
||||||
buildCsv(SUGGESTED_KEYWORD_EXPORT_HEADERS, selectedSuggestionExportRows),
|
|
||||||
);
|
|
||||||
captureClientEvent("rank_tracking:suggestions_export_csv", {
|
|
||||||
result_count: selectedSuggestionRows.length,
|
|
||||||
scope: "selection",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const sectionHeader = (title: string) => (
|
const sectionHeader = (title: string) => (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@ -301,18 +261,12 @@ export function KeywordSuggestionStep({
|
|||||||
<div className="flex flex-col items-center justify-center gap-3 py-16">
|
<div className="flex flex-col items-center justify-center gap-3 py-16">
|
||||||
<AlertCircle className="size-8 text-error" />
|
<AlertCircle className="size-8 text-error" />
|
||||||
<p className="text-xs text-base-content/50">
|
<p className="text-xs text-base-content/50">
|
||||||
You can try again or add keywords manually later.
|
You can skip this step and add keywords manually later.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-2 mt-2">
|
<div className="flex gap-2 mt-2">
|
||||||
<button className="btn btn-ghost btn-sm" onClick={onClose}>
|
<button className="btn btn-primary btn-sm" onClick={onClose}>
|
||||||
Skip
|
Skip
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
className="btn btn-primary btn-sm"
|
|
||||||
onClick={() => suggestionsQuery.refetch()}
|
|
||||||
>
|
|
||||||
Try Again
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@ -330,7 +284,7 @@ export function KeywordSuggestionStep({
|
|||||||
add keywords manually.
|
add keywords manually.
|
||||||
</p>
|
</p>
|
||||||
<button className="btn btn-primary btn-sm mt-2" onClick={onClose}>
|
<button className="btn btn-primary btn-sm mt-2" onClick={onClose}>
|
||||||
Continue
|
Skip
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@ -365,44 +319,25 @@ export function KeywordSuggestionStep({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-3 pt-1">
|
<div className="flex items-center justify-between gap-3 pt-1">
|
||||||
<button className="btn btn-ghost btn-sm" onClick={onClose}>
|
<p className="text-xs text-base-content/60">
|
||||||
Skip
|
{selectedCount} of {data.length} selected
|
||||||
</button>
|
</p>
|
||||||
<TableBulkActionBar
|
<div className="flex items-center gap-2">
|
||||||
selectedCount={selectedCount}
|
<button className="btn btn-ghost btn-sm" onClick={onClose}>
|
||||||
selectedLabel={`of ${data.length} selected`}
|
Skip
|
||||||
onClear={() => table.resetRowSelection()}
|
</button>
|
||||||
placement="inline"
|
<button
|
||||||
actions={
|
type="button"
|
||||||
<div className="flex items-center px-1.5">
|
className="btn btn-primary btn-sm"
|
||||||
<TableBulkActionButton
|
onClick={handleAdd}
|
||||||
icon={
|
disabled={addMutation.isPending || selectedCount === 0}
|
||||||
addMutation.isPending ? (
|
>
|
||||||
<Loader2 className="size-3.5 animate-spin" />
|
{addMutation.isPending && (
|
||||||
) : undefined
|
<Loader2 className="size-3.5 animate-spin" />
|
||||||
}
|
)}
|
||||||
onClick={handleAdd}
|
Save Keyword{selectedCount !== 1 ? "s" : ""}
|
||||||
disabled={addMutation.isPending}
|
</button>
|
||||||
>
|
</div>
|
||||||
Add Keyword{selectedCount !== 1 ? "s" : ""}
|
|
||||||
</TableBulkActionButton>
|
|
||||||
<TableBulkExportMenu
|
|
||||||
actions={[
|
|
||||||
{
|
|
||||||
label: "Export to Sheets",
|
|
||||||
icon: <Sheet className="size-4" />,
|
|
||||||
onClick: handleExportSelectionToSheets,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Export CSV",
|
|
||||||
icon: <FileDown className="size-4" />,
|
|
||||||
onClick: handleExportSelectionCsv,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -256,7 +256,6 @@ function RankTrackingDomainDetailInner({
|
|||||||
{/* Portfolio overview */}
|
{/* Portfolio overview */}
|
||||||
{(rows?.length ?? 0) > 0 && (
|
{(rows?.length ?? 0) > 0 && (
|
||||||
<RankTrackingOverview
|
<RankTrackingOverview
|
||||||
rows={rows ?? []}
|
|
||||||
device={activeDevice}
|
device={activeDevice}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
configId={config.id}
|
configId={config.id}
|
||||||
|
|||||||
@ -11,7 +11,6 @@ import {
|
|||||||
} from "recharts";
|
} from "recharts";
|
||||||
import type { TooltipContentProps } from "recharts";
|
import type { TooltipContentProps } from "recharts";
|
||||||
import { getRankConfigTrend } from "@/serverFunctions/rank-tracking";
|
import { getRankConfigTrend } from "@/serverFunctions/rank-tracking";
|
||||||
import type { RankTrackingRow } from "@/types/schemas/rank-tracking";
|
|
||||||
import {
|
import {
|
||||||
formatDateTick,
|
formatDateTick,
|
||||||
TrendRangeToggle,
|
TrendRangeToggle,
|
||||||
@ -32,12 +31,10 @@ interface PayloadEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function RankTrackingOverview({
|
export function RankTrackingOverview({
|
||||||
rows,
|
|
||||||
device,
|
device,
|
||||||
projectId,
|
projectId,
|
||||||
configId,
|
configId,
|
||||||
}: {
|
}: {
|
||||||
rows: RankTrackingRow[];
|
|
||||||
device: "desktop" | "mobile";
|
device: "desktop" | "mobile";
|
||||||
projectId: string;
|
projectId: string;
|
||||||
configId: string;
|
configId: string;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user