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({
|
||||
form,
|
||||
showAhrefsDrFilter,
|
||||
}: {
|
||||
form: BacklinksFiltersState["backlinks"]["form"];
|
||||
showAhrefsDrFilter: boolean;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
@ -133,6 +135,14 @@ function BacklinksTabFilters({
|
||||
minName="minDomainRank"
|
||||
maxName="maxDomainRank"
|
||||
/>
|
||||
{showAhrefsDrFilter ? (
|
||||
<FilterRangeInputs
|
||||
form={form}
|
||||
title="Ahrefs DR"
|
||||
minName="minAhrefsDr"
|
||||
maxName="maxAhrefsDr"
|
||||
/>
|
||||
) : null}
|
||||
<FilterRangeInputs
|
||||
form={form}
|
||||
title="Link Authority"
|
||||
@ -219,8 +229,10 @@ function BacklinksTabFilters({
|
||||
|
||||
function ReferringDomainsFilters({
|
||||
form,
|
||||
showAhrefsDrFilter,
|
||||
}: {
|
||||
form: BacklinksFiltersState["domains"]["form"];
|
||||
showAhrefsDrFilter: boolean;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
@ -252,6 +264,14 @@ function ReferringDomainsFilters({
|
||||
minName="minRank"
|
||||
maxName="maxRank"
|
||||
/>
|
||||
{showAhrefsDrFilter ? (
|
||||
<FilterRangeInputs
|
||||
form={form}
|
||||
title="Ahrefs DR"
|
||||
minName="minAhrefsDr"
|
||||
maxName="maxAhrefsDr"
|
||||
/>
|
||||
) : null}
|
||||
<FilterRangeInputs
|
||||
form={form}
|
||||
title="Spam Score"
|
||||
@ -313,9 +333,13 @@ function TopPagesFilters({
|
||||
export function BacklinksFilterPanel({
|
||||
activeTab,
|
||||
filters,
|
||||
showAhrefsDrFilter,
|
||||
activeFilterCount,
|
||||
}: {
|
||||
activeTab: BacklinksTab;
|
||||
filters: BacklinksFiltersState;
|
||||
showAhrefsDrFilter: boolean;
|
||||
activeFilterCount: number;
|
||||
}) {
|
||||
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 items-center gap-2">
|
||||
<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">
|
||||
{current.activeFilterCount} active
|
||||
{activeFilterCount} active
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
@ -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}
|
||||
>
|
||||
<RotateCcw className="size-3" />
|
||||
Clear all
|
||||
@ -342,10 +366,16 @@ export function BacklinksFilterPanel({
|
||||
</div>
|
||||
|
||||
{activeTab === "backlinks" ? (
|
||||
<BacklinksTabFilters form={filters.backlinks.form} />
|
||||
<BacklinksTabFilters
|
||||
form={filters.backlinks.form}
|
||||
showAhrefsDrFilter={showAhrefsDrFilter}
|
||||
/>
|
||||
) : null}
|
||||
{activeTab === "domains" ? (
|
||||
<ReferringDomainsFilters form={filters.domains.form} />
|
||||
<ReferringDomainsFilters
|
||||
form={filters.domains.form}
|
||||
showAhrefsDrFilter={showAhrefsDrFilter}
|
||||
/>
|
||||
) : null}
|
||||
{activeTab === "pages" ? (
|
||||
<TopPagesFilters form={filters.pages.form} />
|
||||
|
||||
@ -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({
|
||||
<BacklinksExportMenu
|
||||
activeTab={activeTab}
|
||||
exportTarget={exportTarget}
|
||||
filteredData={filteredData}
|
||||
filteredData={visibleFilteredData}
|
||||
headers={exportTable.headers}
|
||||
rows={exportTable.rows}
|
||||
/>
|
||||
@ -183,7 +215,12 @@ export function BacklinksResultsCard({
|
||||
</div>
|
||||
|
||||
{filters.showFilters ? (
|
||||
<BacklinksFilterPanel activeTab={activeTab} filters={filters} />
|
||||
<BacklinksFilterPanel
|
||||
activeTab={activeTab}
|
||||
filters={filters}
|
||||
showAhrefsDrFilter={showAhrefsDrFilter}
|
||||
activeFilterCount={currentFilterCount}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="p-4">
|
||||
@ -194,7 +231,7 @@ export function BacklinksResultsCard({
|
||||
) : null}
|
||||
{activeTab === "backlinks" ? (
|
||||
<BacklinksTable
|
||||
rows={filteredData.backlinks}
|
||||
rows={visibleFilteredData.backlinks}
|
||||
domainRatings={domainRatings}
|
||||
/>
|
||||
) : null}
|
||||
@ -203,7 +240,7 @@ export function BacklinksResultsCard({
|
||||
) : null}
|
||||
{activeTab === "domains" && !isTabLoading && !tabErrorMessage ? (
|
||||
<ReferringDomainsTable
|
||||
rows={filteredData.referringDomains}
|
||||
rows={visibleFilteredData.referringDomains}
|
||||
domainRatings={domainRatings}
|
||||
/>
|
||||
) : 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({
|
||||
data,
|
||||
summaryStats,
|
||||
|
||||
@ -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: "",
|
||||
};
|
||||
|
||||
@ -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> = {}): 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 {
|
||||
@ -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]]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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<SuggestedKeyword>[] = [
|
||||
{
|
||||
@ -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) => (
|
||||
<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">
|
||||
<AlertCircle className="size-8 text-error" />
|
||||
<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>
|
||||
<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
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => suggestionsQuery.refetch()}
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
@ -330,7 +284,7 @@ export function KeywordSuggestionStep({
|
||||
add keywords manually.
|
||||
</p>
|
||||
<button className="btn btn-primary btn-sm mt-2" onClick={onClose}>
|
||||
Continue
|
||||
Skip
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
@ -365,44 +319,25 @@ export function KeywordSuggestionStep({
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 pt-1">
|
||||
<p className="text-xs text-base-content/60">
|
||||
{selectedCount} of {data.length} selected
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="btn btn-ghost btn-sm" onClick={onClose}>
|
||||
Skip
|
||||
</button>
|
||||
<TableBulkActionBar
|
||||
selectedCount={selectedCount}
|
||||
selectedLabel={`of ${data.length} selected`}
|
||||
onClear={() => table.resetRowSelection()}
|
||||
placement="inline"
|
||||
actions={
|
||||
<div className="flex items-center px-1.5">
|
||||
<TableBulkActionButton
|
||||
icon={
|
||||
addMutation.isPending ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : undefined
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={handleAdd}
|
||||
disabled={addMutation.isPending}
|
||||
disabled={addMutation.isPending || selectedCount === 0}
|
||||
>
|
||||
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,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{addMutation.isPending && (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
)}
|
||||
Save Keyword{selectedCount !== 1 ? "s" : ""}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -256,7 +256,6 @@ function RankTrackingDomainDetailInner({
|
||||
{/* Portfolio overview */}
|
||||
{(rows?.length ?? 0) > 0 && (
|
||||
<RankTrackingOverview
|
||||
rows={rows ?? []}
|
||||
device={activeDevice}
|
||||
projectId={projectId}
|
||||
configId={config.id}
|
||||
|
||||
@ -11,7 +11,6 @@ import {
|
||||
} from "recharts";
|
||||
import type { TooltipContentProps } from "recharts";
|
||||
import { getRankConfigTrend } from "@/serverFunctions/rank-tracking";
|
||||
import type { RankTrackingRow } from "@/types/schemas/rank-tracking";
|
||||
import {
|
||||
formatDateTick,
|
||||
TrendRangeToggle,
|
||||
@ -32,12 +31,10 @@ interface PayloadEntry {
|
||||
}
|
||||
|
||||
export function RankTrackingOverview({
|
||||
rows,
|
||||
device,
|
||||
projectId,
|
||||
configId,
|
||||
}: {
|
||||
rows: RankTrackingRow[];
|
||||
device: "desktop" | "mobile";
|
||||
projectId: string;
|
||||
configId: string;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user