← back to Open Seo
src/client/features/rank-tracking/RankTrackingDomainDetail.tsx
359 lines
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useCustomer } from "autumn-js/react";
import {
getLatestRankResults,
getRankPositionMatrix,
estimateRankCheckCost,
} from "@/serverFunctions/rank-tracking";
import { AlertTriangle, ArrowLeft } from "lucide-react";
import { useSession } from "@/lib/auth-client";
import { getCustomerPlanStatus } from "@/client/features/billing/plan-detection";
import { captureClientEvent } from "@/client/lib/posthog";
import { FreePlanAlert } from "./FreePlanAlert";
import { RankTrackingDetailHeader } from "./RankTrackingDetailHeader";
import { RankTrackingOverview } from "./RankTrackingOverview";
import { RankTrackingTable } from "./RankTrackingTable";
import {
countMatrixRuns,
RankTrackingHistoryMatrix,
} from "./RankTrackingHistoryMatrix";
import { RankTrackingTableToolbar } from "./RankTrackingTableToolbar";
import {
exportRankTrackingCsv,
exportRankTrackingToSheets,
} from "./RankTrackingTableParts";
import type {
RankTrackingConfig,
ComparePeriod,
} from "@/types/schemas/rank-tracking";
import { AddKeywordsPanel } from "./AddKeywordsPanel";
import {
FilterPanel,
applyFilters,
countActiveFilters,
EMPTY_FILTERS,
type Filters,
} from "./RankTrackingFilters";
import { CheckConfirmModal } from "./CheckConfirmModal";
import { useMetricsRefresh } from "./useMetricsRefresh";
import { useRankCheckTrigger } from "./useRankCheckTrigger";
import { useRankRunPolling } from "./useRankRunPolling";
function deviceVisibility(
devices: RankTrackingConfig["devices"],
activeDevice: "desktop" | "mobile",
): { showDesktop: boolean; showMobile: boolean } {
if (devices === "both") {
return {
showDesktop: activeDevice === "desktop",
showMobile: activeDevice === "mobile",
};
}
return {
showDesktop: devices !== "mobile",
showMobile: devices !== "desktop",
};
}
export function RankTrackingDomainDetail({
config,
projectId,
onBack,
onEdit,
}: {
config: RankTrackingConfig;
projectId: string;
onBack: () => void;
onEdit: () => void;
}) {
const { data: session } = useSession();
const customerQuery = useCustomer({
queryOptions: { enabled: Boolean(session?.user?.id) },
});
const isFreePlan =
!!customerQuery.data &&
getCustomerPlanStatus(customerQuery.data) === "free";
const queryClient = useQueryClient();
const [showAddKeywords, setShowAddKeywords] = useState(false);
const [showFilters, setShowFilters] = useState(false);
const [filters, setFilters] = useState<Filters>(EMPTY_FILTERS);
const [comparePeriod, setComparePeriod] = useState<ComparePeriod>(
config.scheduleInterval === "daily"
? "1d"
: config.scheduleInterval === "monthly"
? "30d"
: "7d",
);
const [activeDevice, setActiveDevice] = useState<"desktop" | "mobile">(
config.devices === "mobile" ? "mobile" : "desktop",
);
const [viewMode, setViewMode] = useState<"table" | "history">("table");
const { data: resultsData, isLoading: resultsLoading } = useQuery({
queryKey: ["rankTrackingResults", projectId, config.id, comparePeriod],
queryFn: () =>
getLatestRankResults({
data: { projectId, configId: config.id, comparePeriod },
}),
});
const latestRun = useRankRunPolling(projectId, config.id);
// Also feeds the History toggle: the matrix view only earns its tab once
// there are two checks to compare.
const { data: matrixCells, isLoading: matrixLoading } = useQuery({
queryKey: ["rankPositionMatrix", projectId, config.id, activeDevice],
queryFn: () =>
getRankPositionMatrix({
data: { projectId, configId: config.id, device: activeDevice },
}),
});
const historyAvailable = countMatrixRuns(matrixCells ?? []) >= 2;
const { data: costEstimate } = useQuery({
queryKey: ["rankTrackingCostEstimate", projectId, config.id],
queryFn: () =>
estimateRankCheckCost({ data: { projectId, configId: config.id } }),
});
const [pendingCheck, setPendingCheck] = useState<{
count: number;
keywordIds?: string[];
} | null>(null);
const handleKeywordsAdded = (result: {
added: number;
checkTriggered: boolean;
}) => {
void queryClient.invalidateQueries({
queryKey: ["rankTrackingCostEstimate", projectId, config.id],
});
void queryClient.invalidateQueries({
queryKey: ["rankTrackingResults", projectId, config.id],
});
void queryClient.invalidateQueries({
queryKey: ["rankTrackingLatestRun", projectId, config.id],
});
setShowAddKeywords(false);
captureClientEvent("rank_tracking:keywords_add");
toast.success(
`${result.added} keyword${result.added !== 1 ? "s" : ""} added`,
);
if (!result.checkTriggered && result.added > 0) {
toast.info("Use 'Check Now' to check these keywords");
}
};
const isRunning =
(latestRun?.status === "pending" || latestRun?.status === "running") &&
!latestRun?.maybeStale;
const { startCheck, isBusy, isPending } = useRankCheckTrigger({
configId: config.id,
isRunning,
projectId,
onSuccess: () => setPendingCheck(null),
});
const { refresh: refreshMetrics, isRefreshing: metricsRefreshing } =
useMetricsRefresh(projectId, config.id);
const requestCheck = (count: number, keywordIds?: string[]) => {
if (count < 50) {
startCheck({ keywordIds });
return;
}
if (isBusy) return;
setPendingCheck({ count, keywordIds });
};
const rows = resultsData?.rows;
const run = resultsData?.run;
const hasBothDevices = config.devices === "both";
const { showDesktop, showMobile } = deviceVisibility(
config.devices,
activeDevice,
);
const filtered = useMemo(
() => applyFilters(rows ?? [], filters),
[rows, filters],
);
const activeFilterCount = countActiveFilters(filters);
const defaultSortId = showDesktop ? "desktopPosition" : "mobilePosition";
// Fall back to the table if history disappears (e.g. device switch).
const effectiveViewMode = historyAvailable ? viewMode : "table";
return (
<div className="space-y-3">
<button
className="btn btn-ghost btn-xs gap-1 -ml-2 text-base-content/60"
onClick={onBack}
>
<ArrowLeft className="size-3" />
Back to domains
</button>
{config.lastSkipReason === "insufficient_credits" && (
<div className="alert alert-warning text-sm py-2">
<AlertTriangle className="size-4" />
<span>
Last scheduled check was skipped due to insufficient credits. Top up
your balance to resume automatic tracking.
</span>
</div>
)}
{latestRun?.maybeStale && (
<div className="alert alert-warning text-sm py-2">
<AlertTriangle className="size-4" />
<span>
This run may be unresponsive and will be cleaned up automatically.
</span>
</div>
)}
<FreePlanAlert visible={isFreePlan} />
{/* Results card */}
<div className="flex-1 flex flex-col min-w-0 border border-base-300 rounded-xl bg-base-100 overflow-hidden">
{/* Domain header */}
<RankTrackingDetailHeader
config={config}
run={run}
costEstimate={costEstimate}
hasBothDevices={hasBothDevices}
activeDevice={activeDevice}
onActiveDeviceChange={setActiveDevice}
comparePeriod={comparePeriod}
onComparePeriodChange={setComparePeriod}
onEdit={onEdit}
onToggleAddKeywords={() => setShowAddKeywords((c) => !c)}
/>
{showAddKeywords && (
<div className="px-4 pb-3">
<AddKeywordsPanel
configId={config.id}
projectId={projectId}
onSuccess={handleKeywordsAdded}
onCancel={() => setShowAddKeywords(false)}
/>
</div>
)}
{/* Portfolio overview */}
{(rows?.length ?? 0) > 0 && (
<RankTrackingOverview
device={activeDevice}
projectId={projectId}
configId={config.id}
/>
)}
{/* Table toolbar */}
<RankTrackingTableToolbar
showFilters={showFilters}
onToggleFilters={() => setShowFilters((c) => !c)}
activeFilterCount={activeFilterCount}
isRunning={isRunning}
latestRun={latestRun}
keywordCount={filtered.length}
viewMode={effectiveViewMode}
onViewModeChange={setViewMode}
historyAvailable={historyAvailable}
onExport={() =>
exportRankTrackingCsv(
filtered,
showDesktop,
showMobile,
config.domain,
config.locationName,
)
}
onExportToSheets={() =>
exportRankTrackingToSheets(
filtered,
showDesktop,
showMobile,
config.locationName,
)
}
onCopyKeywords={() => {
void navigator.clipboard.writeText(
filtered.map((r) => r.keyword).join("\n"),
);
toast.success("Keywords copied to clipboard");
}}
onCheckNow={() => {
const count = costEstimate?.keywordCount ?? rows?.length ?? 0;
if (count > 0) requestCheck(count);
}}
onRefreshMetrics={refreshMetrics}
metricsRefreshing={metricsRefreshing}
checkBusy={isBusy}
checkDisabled={isFreePlan}
hasData={filtered.length > 0}
/>
{/* Filters panel */}
{showFilters && (
<FilterPanel
filters={filters}
setFilters={setFilters}
activeFilterCount={activeFilterCount}
onReset={() => setFilters(EMPTY_FILTERS)}
/>
)}
{/* Table */}
<div className="p-4">
{effectiveViewMode === "history" ? (
<RankTrackingHistoryMatrix
cells={matrixCells ?? []}
isLoading={matrixLoading}
keywords={filtered.map((r) => ({
trackingKeywordId: r.trackingKeywordId,
keyword: r.keyword,
}))}
/>
) : (
<RankTrackingTable
key={defaultSortId}
totalCount={rows?.length ?? 0}
rows={filtered}
resultsLoading={resultsLoading}
showDesktop={showDesktop}
showMobile={showMobile}
defaultSortId={defaultSortId}
domain={config.domain}
configId={config.id}
projectId={projectId}
locationCode={config.locationCode}
locationName={config.locationName}
serpDepth={config.serpDepth}
/>
)}
</div>
</div>
{pendingCheck && (
<CheckConfirmModal
keywordCount={pendingCheck.count}
devices={config.devices}
serpDepth={config.serpDepth}
isPending={isPending}
onRunNow={() =>
startCheck({
keywordIds: pendingCheck.keywordIds,
})
}
onCancel={() => setPendingCheck(null)}
/>
)}
</div>
);
}