diff --git a/src/client/features/backlinks/BacklinksFilterPanel.tsx b/src/client/features/backlinks/BacklinksFilterPanel.tsx index 2d28217..6dad1e9 100644 --- a/src/client/features/backlinks/BacklinksFilterPanel.tsx +++ b/src/client/features/backlinks/BacklinksFilterPanel.tsx @@ -106,8 +106,10 @@ function CompactRangeInput({ function BacklinksTabFilters({ form, + showAhrefsDrFilter, }: { form: BacklinksFiltersState["backlinks"]["form"]; + showAhrefsDrFilter: boolean; }) { return ( <> @@ -133,6 +135,14 @@ function BacklinksTabFilters({ minName="minDomainRank" maxName="maxDomainRank" /> + {showAhrefsDrFilter ? ( + + ) : null} @@ -252,6 +264,14 @@ function ReferringDomainsFilters({ minName="minRank" maxName="maxRank" /> + {showAhrefsDrFilter ? ( + + ) : null}

Refine results

- {current.activeFilterCount > 0 ? ( + {activeFilterCount > 0 ? ( - {current.activeFilterCount} active + {activeFilterCount} active ) : null}
@@ -334,7 +358,7 @@ export function BacklinksFilterPanel({ type="button" className="btn btn-xs btn-ghost gap-1" onClick={current.reset} - disabled={current.activeFilterCount === 0} + disabled={activeFilterCount === 0} > Clear all @@ -342,10 +366,16 @@ export function BacklinksFilterPanel({ {activeTab === "backlinks" ? ( - + ) : null} {activeTab === "domains" ? ( - + ) : null} {activeTab === "pages" ? ( diff --git a/src/client/features/backlinks/BacklinksPageSections.tsx b/src/client/features/backlinks/BacklinksPageSections.tsx index 23e831d..110f608 100644 --- a/src/client/features/backlinks/BacklinksPageSections.tsx +++ b/src/client/features/backlinks/BacklinksPageSections.tsx @@ -23,6 +23,10 @@ import { BacklinksExportMenu, } from "./BacklinksToolbarMenus"; import { buildBacklinksTabExport } from "./export"; +import { + filterBacklinkRows, + filterReferringDomainRows, +} from "./backlinksFiltering"; import type { BacklinksFiltersState } from "./useBacklinksFilters"; import { useAhrefsDomainRatings } from "./useAhrefsDomainRatings"; @@ -101,21 +105,49 @@ export function BacklinksResultsCard({ exportTarget: string; onTabChange: (tab: BacklinksSearchState["tab"]) => void; }) { - const currentFilterCount = filters[activeTab].activeFilterCount; - const exportTable = useMemo( - () => buildBacklinksTabExport({ tab: activeTab, rows: filteredData }), - [activeTab, filteredData], - ); 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 exportTable = useMemo( + () => + buildBacklinksTabExport({ tab: activeTab, rows: visibleFilteredData }), + [activeTab, visibleFilteredData], + ); // 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. const ratableDomains = useMemo( - () => collectRatableDomains(filteredData), - [filteredData], + () => collectRatableDomains(visibleFilteredData), + [visibleFilteredData], ); // 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). @@ -152,7 +184,7 @@ export function BacklinksResultsCard({ @@ -183,7 +215,12 @@ export function BacklinksResultsCard({ {filters.showFilters ? ( - + ) : null}
@@ -194,7 +231,7 @@ export function BacklinksResultsCard({ ) : null} {activeTab === "backlinks" ? ( ) : null} @@ -203,7 +240,7 @@ export function BacklinksResultsCard({ ) : null} {activeTab === "domains" && !isTabLoading && !tabErrorMessage ? ( ) : null} @@ -235,6 +272,21 @@ function collectRatableDomains(filteredData: { ]; } +function countVisibleFilters( + values: Record, + showAhrefsDrFilter: boolean, +) { + return Object.entries(values).filter(([key, value]) => { + if ( + !showAhrefsDrFilter && + (key === "minAhrefsDr" || key === "maxAhrefsDr") + ) { + return false; + } + return value.trim() !== ""; + }).length; +} + function OverviewGrid({ data, summaryStats, diff --git a/src/client/features/backlinks/backlinksFilterTypes.ts b/src/client/features/backlinks/backlinksFilterTypes.ts index cca057f..b621ecf 100644 --- a/src/client/features/backlinks/backlinksFilterTypes.ts +++ b/src/client/features/backlinks/backlinksFilterTypes.ts @@ -3,6 +3,8 @@ export type BacklinksTabFilterValues = { exclude: string; minDomainRank: string; maxDomainRank: string; + minAhrefsDr: string; + maxAhrefsDr: string; minLinkAuthority: string; maxLinkAuthority: string; minSpamScore: string; @@ -19,6 +21,8 @@ export type ReferringDomainsFilterValues = { maxBacklinks: string; minRank: string; maxRank: string; + minAhrefsDr: string; + maxAhrefsDr: string; minSpamScore: string; maxSpamScore: string; }; @@ -39,6 +43,8 @@ export const EMPTY_BACKLINKS_FILTERS: BacklinksTabFilterValues = { exclude: "", minDomainRank: "", maxDomainRank: "", + minAhrefsDr: "", + maxAhrefsDr: "", minLinkAuthority: "", maxLinkAuthority: "", minSpamScore: "", @@ -55,6 +61,8 @@ export const EMPTY_REFERRING_DOMAINS_FILTERS: ReferringDomainsFilterValues = { maxBacklinks: "", minRank: "", maxRank: "", + minAhrefsDr: "", + maxAhrefsDr: "", minSpamScore: "", maxSpamScore: "", }; diff --git a/src/client/features/backlinks/backlinksFiltering.test.ts b/src/client/features/backlinks/backlinksFiltering.test.ts index dcded4a..351c389 100644 --- a/src/client/features/backlinks/backlinksFiltering.test.ts +++ b/src/client/features/backlinks/backlinksFiltering.test.ts @@ -1,10 +1,39 @@ import { describe, expect, it } from "vitest"; import type { BacklinksOverviewData } from "./backlinksPageTypes"; -import { EMPTY_REFERRING_DOMAINS_FILTERS } from "./backlinksFilterTypes"; -import { filterReferringDomainRows } from "./backlinksFiltering"; +import { + EMPTY_BACKLINKS_FILTERS, + EMPTY_REFERRING_DOMAINS_FILTERS, +} from "./backlinksFilterTypes"; +import { + filterBacklinkRows, + filterReferringDomainRows, +} from "./backlinksFiltering"; +type BacklinkRow = BacklinksOverviewData["backlinks"][number]; type ReferringDomainRow = BacklinksOverviewData["referringDomains"][number]; +function makeBacklinkRow(overrides: Partial = {}): BacklinkRow { + return { + urlFrom: "https://example.com/post", + urlTo: "https://target.example/page", + domainFrom: "example.com", + anchor: "Example", + itemType: "anchor", + rank: 10, + domainFromRank: 20, + pageFromRank: 10, + spamScore: 2, + relAttributes: [], + firstSeen: null, + lastSeen: null, + linksCount: 1, + isDofollow: true, + isLost: false, + isBroken: false, + ...overrides, + }; +} + function makeReferringDomainRow( overrides: Partial = {}, ): ReferringDomainRow { @@ -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", () => { it("filters by spam score range", () => { const rows = [ @@ -43,4 +123,53 @@ describe("filterReferringDomainRows", () => { }), ).toEqual([rows[1], rows[2]]); }); + + it("ignores Ahrefs DR range until ratings are loaded for referring domains", () => { + const rows = [ + makeReferringDomainRow({ domain: "low.example" }), + makeReferringDomainRow({ domain: "high.example" }), + ]; + + expect( + filterReferringDomainRows(rows, { + ...EMPTY_REFERRING_DOMAINS_FILTERS, + minAhrefsDr: "50", + }), + ).toEqual(rows); + }); + + it("filters by loaded Ahrefs DR range for referring domains", () => { + const rows = [ + makeReferringDomainRow({ domain: "low.example" }), + makeReferringDomainRow({ domain: "high.example" }), + makeReferringDomainRow({ domain: "unknown.example" }), + ]; + const ratings = { + "low.example": 12, + "high.example": 64, + "unknown.example": null, + }; + + expect( + filterReferringDomainRows( + rows, + { + ...EMPTY_REFERRING_DOMAINS_FILTERS, + minAhrefsDr: "50", + }, + ratings, + ), + ).toEqual([rows[1], rows[2]]); + + expect( + filterReferringDomainRows( + rows, + { + ...EMPTY_REFERRING_DOMAINS_FILTERS, + maxAhrefsDr: "50", + }, + ratings, + ), + ).toEqual([rows[0], rows[2]]); + }); }); diff --git a/src/client/features/backlinks/backlinksFiltering.ts b/src/client/features/backlinks/backlinksFiltering.ts index cdf2737..f128035 100644 --- a/src/client/features/backlinks/backlinksFiltering.ts +++ b/src/client/features/backlinks/backlinksFiltering.ts @@ -5,6 +5,7 @@ import type { ReferringDomainsFilterValues, TopPagesFilterValues, } from "./backlinksFilterTypes"; +import type { DomainRatings } from "./useAhrefsDomainRatings"; function passesNumericFilter( value: number | null | undefined, @@ -40,6 +41,7 @@ function passesTextFilter( export function filterBacklinkRows( rows: BacklinksOverviewData["backlinks"], filters: BacklinksTabFilterValues, + domainRatings?: DomainRatings | null, ): BacklinksOverviewData["backlinks"] { const includeTerms = parseTerms(filters.include); const excludeTerms = parseTerms(filters.exclude); @@ -58,6 +60,17 @@ export function filterBacklinkRows( ) ) return false; + if ( + domainRatings && + !passesNumericFilter( + row.domainFrom + ? domainRatings[row.domainFrom.replace(/^www\./, "")] + : null, + filters.minAhrefsDr, + filters.maxAhrefsDr, + ) + ) + return false; if ( !passesNumericFilter( row.rank, @@ -90,6 +103,7 @@ export function filterBacklinkRows( export function filterReferringDomainRows( rows: BacklinksOverviewData["referringDomains"], filters: ReferringDomainsFilterValues, + domainRatings?: DomainRatings | null, ): BacklinksOverviewData["referringDomains"] { const includeTerms = parseTerms(filters.include); const excludeTerms = parseTerms(filters.exclude); @@ -107,6 +121,15 @@ export function filterReferringDomainRows( 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, diff --git a/src/client/features/rank-tracking/KeywordSuggestionStep.tsx b/src/client/features/rank-tracking/KeywordSuggestionStep.tsx index 051ba0d..0f22d27 100644 --- a/src/client/features/rank-tracking/KeywordSuggestionStep.tsx +++ b/src/client/features/rank-tracking/KeywordSuggestionStep.tsx @@ -5,11 +5,8 @@ import { type RowSelectionState, type SortingState, } 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 { buildCsv, downloadCsv } from "@/client/lib/csv"; -import { exportTableToSheets } from "@/client/lib/exportToSheets"; -import { captureClientEvent } from "@/client/lib/posthog"; import { getDomainKeywordSuggestions } from "@/serverFunctions/domain"; import { addTrackingKeywords } from "@/serverFunctions/rank-tracking"; import { getStandardErrorMessage } from "@/client/lib/error-messages"; @@ -18,11 +15,6 @@ import { makeSelectionColumn, useAppTable, } from "@/client/components/table/AppDataTable"; -import { - TableBulkActionBar, - TableBulkActionButton, - TableBulkExportMenu, -} from "@/client/components/table/TableBulkActionBar"; import { SortableHeader } from "./RankTrackingColumns"; import { applyShiftRangeSelection, @@ -37,12 +29,6 @@ type SuggestedKeyword = { }; const PRE_SELECT_COUNT = 20; -const SUGGESTED_KEYWORD_EXPORT_HEADERS = [ - "Keyword", - "Position", - "Volume", - "Traffic", -]; const baseColumns: ColumnDef[] = [ { @@ -240,32 +226,6 @@ export function KeywordSuggestionStep({ 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) => (
@@ -301,18 +261,12 @@ export function KeywordSuggestionStep({

- You can try again or add keywords manually later. + You can skip this step and add keywords manually later.

- -
@@ -330,7 +284,7 @@ export function KeywordSuggestionStep({ add keywords manually.

@@ -365,44 +319,25 @@ export function KeywordSuggestionStep({ />
- - table.resetRowSelection()} - placement="inline" - actions={ -
- - ) : undefined - } - onClick={handleAdd} - disabled={addMutation.isPending} - > - Add Keyword{selectedCount !== 1 ? "s" : ""} - - , - onClick: handleExportSelectionToSheets, - }, - { - label: "Export CSV", - icon: , - onClick: handleExportSelectionCsv, - }, - ]} - /> -
- } - /> +

+ {selectedCount} of {data.length} selected +

+
+ + +
); diff --git a/src/client/features/rank-tracking/RankTrackingDomainDetail.tsx b/src/client/features/rank-tracking/RankTrackingDomainDetail.tsx index 870f021..16516c9 100644 --- a/src/client/features/rank-tracking/RankTrackingDomainDetail.tsx +++ b/src/client/features/rank-tracking/RankTrackingDomainDetail.tsx @@ -256,7 +256,6 @@ function RankTrackingDomainDetailInner({ {/* Portfolio overview */} {(rows?.length ?? 0) > 0 && (