feat: polish rank tracking UI (#115)
This commit is contained in:
parent
4595080356
commit
aeafa5649d
1
drizzle/0008_luxuriant_colossus.sql
Normal file
1
drizzle/0008_luxuriant_colossus.sql
Normal file
@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `rank_tracking_configs` ADD `serp_depth` integer NOT NULL;
|
||||||
2003
drizzle/meta/0008_snapshot.json
Normal file
2003
drizzle/meta/0008_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -57,6 +57,13 @@
|
|||||||
"when": 1776208279781,
|
"when": 1776208279781,
|
||||||
"tag": "0007_sour_risque",
|
"tag": "0007_sour_risque",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 8,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1776288697036,
|
||||||
|
"tag": "0008_luxuriant_colossus",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@ -104,6 +104,7 @@ async function main() {
|
|||||||
locationCode: 2840,
|
locationCode: 2840,
|
||||||
languageCode: "en",
|
languageCode: "en",
|
||||||
devices: "mobile",
|
devices: "mobile",
|
||||||
|
serpDepth: 20,
|
||||||
scheduleInterval: "weekly",
|
scheduleInterval: "weekly",
|
||||||
isActive: true,
|
isActive: true,
|
||||||
lastCheckedAt: now,
|
lastCheckedAt: now,
|
||||||
|
|||||||
32
src/client/components/SegmentedToggle.tsx
Normal file
32
src/client/components/SegmentedToggle.tsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
interface SegmentedToggleItem<T extends string> {
|
||||||
|
value: T;
|
||||||
|
icon: ReactNode;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SegmentedToggle<T extends string>({
|
||||||
|
items,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
items: SegmentedToggleItem<T>[];
|
||||||
|
value: T;
|
||||||
|
onChange: (value: T) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="inline-flex rounded-lg bg-base-300 p-0.5">
|
||||||
|
{items.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.value}
|
||||||
|
className={`btn btn-xs px-2 ${value === item.value ? "bg-primary/20 text-primary shadow-sm" : "btn-ghost text-base-content/40"}`}
|
||||||
|
onClick={() => onChange(item.value)}
|
||||||
|
title={item.label}
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -13,7 +13,11 @@ export function AddKeywordsPanel({
|
|||||||
}: {
|
}: {
|
||||||
configId: string;
|
configId: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
onSuccess: (result: { added: number; addedIds?: string[] }) => void;
|
onSuccess: (result: {
|
||||||
|
added: number;
|
||||||
|
addedIds: string[];
|
||||||
|
checkTriggered: boolean;
|
||||||
|
}) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [keywordInput, setKeywordInput] = useState("");
|
const [keywordInput, setKeywordInput] = useState("");
|
||||||
@ -30,8 +34,6 @@ export function AddKeywordsPanel({
|
|||||||
});
|
});
|
||||||
const isPending = mutation.isPending;
|
const isPending = mutation.isPending;
|
||||||
return (
|
return (
|
||||||
<div className="card bg-base-100 border border-base-300">
|
|
||||||
<div className="card-body gap-3 p-4">
|
|
||||||
<div className="flex gap-2 items-end">
|
<div className="flex gap-2 items-end">
|
||||||
<textarea
|
<textarea
|
||||||
className="textarea textarea-bordered textarea-sm flex-1"
|
className="textarea textarea-bordered textarea-sm flex-1"
|
||||||
@ -60,7 +62,5 @@ export function AddKeywordsPanel({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,17 +11,23 @@ import {
|
|||||||
export function CheckConfirmModal({
|
export function CheckConfirmModal({
|
||||||
keywordCount,
|
keywordCount,
|
||||||
devices,
|
devices,
|
||||||
|
serpDepth,
|
||||||
isPending,
|
isPending,
|
||||||
onRunNow,
|
onRunNow,
|
||||||
onCancel,
|
onCancel,
|
||||||
}: {
|
}: {
|
||||||
keywordCount: number;
|
keywordCount: number;
|
||||||
devices: RankTrackingConfig["devices"];
|
devices: RankTrackingConfig["devices"];
|
||||||
|
serpDepth: number;
|
||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
onRunNow: () => void;
|
onRunNow: () => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { costUsd } = estimateRankCheckCredits(keywordCount, devices);
|
const { costUsd } = estimateRankCheckCredits(
|
||||||
|
keywordCount,
|
||||||
|
devices,
|
||||||
|
serpDepth,
|
||||||
|
);
|
||||||
const dc = devicesCount(devices);
|
const dc = devicesCount(devices);
|
||||||
const totalChecks = keywordCount * dc;
|
const totalChecks = keywordCount * dc;
|
||||||
const liveTime =
|
const liveTime =
|
||||||
|
|||||||
375
src/client/features/rank-tracking/KeywordSuggestionStep.tsx
Normal file
375
src/client/features/rank-tracking/KeywordSuggestionStep.tsx
Normal file
@ -0,0 +1,375 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
useReactTable,
|
||||||
|
getCoreRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
flexRender,
|
||||||
|
type ColumnDef,
|
||||||
|
type RowSelectionState,
|
||||||
|
type SortingState,
|
||||||
|
} from "@tanstack/react-table";
|
||||||
|
import { Loader2, AlertCircle, X } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { getDomainKeywordSuggestions } from "@/serverFunctions/domain";
|
||||||
|
import { addTrackingKeywords } from "@/serverFunctions/rank-tracking";
|
||||||
|
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||||
|
import { SortableHeader } from "./RankTrackingColumns";
|
||||||
|
|
||||||
|
type SuggestedKeyword = {
|
||||||
|
keyword: string;
|
||||||
|
position: number | null;
|
||||||
|
searchVolume: number | null;
|
||||||
|
traffic: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PRE_SELECT_COUNT = 20;
|
||||||
|
|
||||||
|
const columns: ColumnDef<SuggestedKeyword>[] = [
|
||||||
|
{
|
||||||
|
id: "select",
|
||||||
|
size: 32,
|
||||||
|
enableSorting: false,
|
||||||
|
header: ({ table }) => (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="checkbox checkbox-xs"
|
||||||
|
checked={table.getIsAllRowsSelected()}
|
||||||
|
onChange={table.getToggleAllRowsSelectedHandler()}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="checkbox checkbox-xs"
|
||||||
|
checked={row.getIsSelected()}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onChange={row.getToggleSelectedHandler()}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "keyword",
|
||||||
|
accessorKey: "keyword",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<SortableHeader
|
||||||
|
column={column}
|
||||||
|
label="Keyword"
|
||||||
|
id="keyword"
|
||||||
|
tooltip="The search term this domain ranks for"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ getValue }) => (
|
||||||
|
<span className="font-medium">{getValue<string>()}</span>
|
||||||
|
),
|
||||||
|
sortingFn: "alphanumeric",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "position",
|
||||||
|
accessorKey: "position",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<SortableHeader
|
||||||
|
column={column}
|
||||||
|
label="Position"
|
||||||
|
id="position"
|
||||||
|
tooltip="Current Google ranking position"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const pos = getValue<number | null>();
|
||||||
|
return pos != null ? (
|
||||||
|
pos
|
||||||
|
) : (
|
||||||
|
<span className="text-base-content/40">—</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
sortingFn: (rowA, rowB) => {
|
||||||
|
const a = rowA.original.position ?? 999;
|
||||||
|
const b = rowB.original.position ?? 999;
|
||||||
|
return a - b;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "searchVolume",
|
||||||
|
accessorKey: "searchVolume",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<SortableHeader
|
||||||
|
column={column}
|
||||||
|
label="Volume"
|
||||||
|
id="searchVolume"
|
||||||
|
tooltip="Monthly search volume"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const vol = getValue<number | null>();
|
||||||
|
return vol != null ? (
|
||||||
|
vol.toLocaleString()
|
||||||
|
) : (
|
||||||
|
<span className="text-base-content/40">—</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
sortingFn: (rowA, rowB) => {
|
||||||
|
const a = rowA.original.searchVolume ?? 0;
|
||||||
|
const b = rowB.original.searchVolume ?? 0;
|
||||||
|
return a - b;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "traffic",
|
||||||
|
accessorKey: "traffic",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<SortableHeader
|
||||||
|
column={column}
|
||||||
|
label="Traffic"
|
||||||
|
id="traffic"
|
||||||
|
tooltip="Estimated monthly organic traffic"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const traffic = getValue<number | null>();
|
||||||
|
return traffic != null ? (
|
||||||
|
Math.round(traffic).toLocaleString()
|
||||||
|
) : (
|
||||||
|
<span className="text-base-content/40">—</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
sortingFn: (rowA, rowB) => {
|
||||||
|
const a = rowA.original.traffic ?? 0;
|
||||||
|
const b = rowB.original.traffic ?? 0;
|
||||||
|
return a - b;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
configId: string;
|
||||||
|
projectId: string;
|
||||||
|
domain: string;
|
||||||
|
locationCode: number;
|
||||||
|
languageCode: string;
|
||||||
|
onDone: (configId: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function KeywordSuggestionStep({
|
||||||
|
configId,
|
||||||
|
projectId,
|
||||||
|
domain,
|
||||||
|
locationCode,
|
||||||
|
languageCode,
|
||||||
|
onDone,
|
||||||
|
onClose,
|
||||||
|
}: Props) {
|
||||||
|
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||||
|
const [hasInitialized, setHasInitialized] = useState(false);
|
||||||
|
const [sorting, setSorting] = useState<SortingState>([
|
||||||
|
{ id: "position", desc: false },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const suggestionsQuery = useQuery({
|
||||||
|
queryKey: [
|
||||||
|
"domainKeywordSuggestions",
|
||||||
|
projectId,
|
||||||
|
domain,
|
||||||
|
locationCode,
|
||||||
|
languageCode,
|
||||||
|
],
|
||||||
|
queryFn: () =>
|
||||||
|
getDomainKeywordSuggestions({
|
||||||
|
data: { projectId, domain, locationCode, languageCode },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = suggestionsQuery.data ?? [];
|
||||||
|
|
||||||
|
// Pre-select top 20 by position once data loads
|
||||||
|
useEffect(() => {
|
||||||
|
const items = suggestionsQuery.data;
|
||||||
|
if (items && items.length > 0 && !hasInitialized) {
|
||||||
|
// Data comes sorted by search volume from the API, but we display sorted
|
||||||
|
// by position. Pre-select the 20 with the best (lowest) positions.
|
||||||
|
const indexed = items.map((item, i) => ({
|
||||||
|
index: i,
|
||||||
|
position: item.position ?? 999,
|
||||||
|
}));
|
||||||
|
indexed.sort((a, b) => a.position - b.position);
|
||||||
|
const initial: RowSelectionState = {};
|
||||||
|
for (let i = 0; i < Math.min(PRE_SELECT_COUNT, indexed.length); i++) {
|
||||||
|
initial[indexed[i].index] = true;
|
||||||
|
}
|
||||||
|
setRowSelection(initial);
|
||||||
|
setHasInitialized(true);
|
||||||
|
}
|
||||||
|
}, [suggestionsQuery.data, hasInitialized]);
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data,
|
||||||
|
columns,
|
||||||
|
state: { rowSelection, sorting },
|
||||||
|
onRowSelectionChange: setRowSelection,
|
||||||
|
onSortingChange: setSorting,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
enableRowSelection: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedCount = Object.keys(rowSelection).filter(
|
||||||
|
(k) => rowSelection[k],
|
||||||
|
).length;
|
||||||
|
|
||||||
|
const addMutation = useMutation({
|
||||||
|
mutationFn: (keywords: string[]) =>
|
||||||
|
addTrackingKeywords({ data: { projectId, configId, keywords } }),
|
||||||
|
onSuccess: (result) => {
|
||||||
|
toast.success(`Added ${result.added} keywords for tracking`);
|
||||||
|
onDone(configId);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(getStandardErrorMessage(error, "Failed to add keywords"));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
const selectedKeywords = table
|
||||||
|
.getSelectedRowModel()
|
||||||
|
.rows.map((row) => row.original.keyword);
|
||||||
|
if (selectedKeywords.length > 0) {
|
||||||
|
addMutation.mutate(selectedKeywords);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sectionHeader = (title: string) => (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold">{title}</h2>
|
||||||
|
<button className="btn btn-ghost btn-sm btn-square" onClick={onClose}>
|
||||||
|
<X className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
if (suggestionsQuery.isLoading) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{sectionHeader("Finding your top keywords...")}
|
||||||
|
<div className="flex flex-col items-center justify-center gap-3 py-16">
|
||||||
|
<Loader2 className="size-8 animate-spin text-primary" />
|
||||||
|
<p className="text-xs text-base-content/50">
|
||||||
|
This usually takes a few seconds
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error state
|
||||||
|
if (suggestionsQuery.isError) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{sectionHeader("Couldn't fetch keywords")}
|
||||||
|
<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.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2 mt-2">
|
||||||
|
<button className="btn btn-ghost btn-sm" onClick={onClose}>
|
||||||
|
Skip
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
onClick={() => suggestionsQuery.refetch()}
|
||||||
|
>
|
||||||
|
Try Again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty state
|
||||||
|
if (data.length === 0) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{sectionHeader("No rankings found")}
|
||||||
|
<div className="flex flex-col items-center justify-center gap-3 py-16">
|
||||||
|
<p className="text-xs text-base-content/50">
|
||||||
|
We couldn't find any keywords {domain} currently ranks for. You can
|
||||||
|
add keywords manually.
|
||||||
|
</p>
|
||||||
|
<button className="btn btn-primary btn-sm mt-2" onClick={onClose}>
|
||||||
|
Continue
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Data loaded
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{sectionHeader("Choose keywords to track")}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm text-base-content/60">
|
||||||
|
We found {data.length} keywords {domain} ranks for.
|
||||||
|
</p>
|
||||||
|
<span className="text-xs text-base-content/50">
|
||||||
|
{selectedCount} of {data.length} selected
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-y-auto max-h-[400px] border border-base-300 rounded-lg">
|
||||||
|
<table className="table table-xs table-pin-rows w-full">
|
||||||
|
<thead>
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<tr key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => (
|
||||||
|
<th key={header.id} className="bg-base-200">
|
||||||
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext(),
|
||||||
|
)}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{table.getRowModel().rows.map((row) => (
|
||||||
|
<tr
|
||||||
|
key={row.id}
|
||||||
|
className="hover:bg-base-200/50 cursor-pointer"
|
||||||
|
onClick={row.getToggleSelectedHandler()}
|
||||||
|
>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<td key={cell.id}>
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-3 pt-1">
|
||||||
|
<button className="btn btn-ghost btn-sm" onClick={onClose}>
|
||||||
|
Skip
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
disabled={selectedCount === 0 || addMutation.isPending}
|
||||||
|
onClick={handleAdd}
|
||||||
|
>
|
||||||
|
{addMutation.isPending && (
|
||||||
|
<Loader2 className="size-3.5 animate-spin" />
|
||||||
|
)}
|
||||||
|
Add {selectedCount} Keyword{selectedCount !== 1 ? "s" : ""}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -2,18 +2,28 @@ import { useMemo } from "react";
|
|||||||
import { ArrowUp, ArrowDown } from "lucide-react";
|
import { ArrowUp, ArrowDown } from "lucide-react";
|
||||||
import type { ColumnDef, SortingFn } from "@tanstack/react-table";
|
import type { ColumnDef, SortingFn } from "@tanstack/react-table";
|
||||||
import type { RankTrackingRow } from "@/types/schemas/rank-tracking";
|
import type { RankTrackingRow } from "@/types/schemas/rank-tracking";
|
||||||
import { comparePositions, DeviceRankCell } from "./RankTrackingTableParts";
|
import {
|
||||||
|
comparePositions,
|
||||||
|
DeviceRankCell,
|
||||||
|
DeviceUrlCell,
|
||||||
|
SerpFeatureTags,
|
||||||
|
} from "./RankTrackingTableParts";
|
||||||
|
|
||||||
const HEADER_TOOLTIPS: Record<string, string> = {
|
const HEADER_TOOLTIPS: Record<string, string> = {
|
||||||
keyword: "The search term being tracked",
|
keyword: "The search term being tracked in Google",
|
||||||
desktopPosition: "Google ranking details on desktop devices",
|
desktopPosition:
|
||||||
mobilePosition: "Google ranking details on mobile devices",
|
"Current Google ranking position, showing change from the comparison period",
|
||||||
|
mobilePosition:
|
||||||
|
"Current Google ranking position, showing change from the comparison period",
|
||||||
|
url: "The page on your site that ranks for this keyword",
|
||||||
|
serp: "Special result features appearing on the search results page (e.g. AI Overview, People Also Ask)",
|
||||||
};
|
};
|
||||||
|
|
||||||
function SortableHeader({
|
export function SortableHeader({
|
||||||
column,
|
column,
|
||||||
label,
|
label,
|
||||||
id,
|
id,
|
||||||
|
tooltip,
|
||||||
}: {
|
}: {
|
||||||
column: {
|
column: {
|
||||||
getIsSorted: () => false | "asc" | "desc";
|
getIsSorted: () => false | "asc" | "desc";
|
||||||
@ -21,6 +31,7 @@ function SortableHeader({
|
|||||||
};
|
};
|
||||||
label: string;
|
label: string;
|
||||||
id: string;
|
id: string;
|
||||||
|
tooltip?: string;
|
||||||
}) {
|
}) {
|
||||||
const sorted = column.getIsSorted();
|
const sorted = column.getIsSorted();
|
||||||
return (
|
return (
|
||||||
@ -28,7 +39,7 @@ function SortableHeader({
|
|||||||
type="button"
|
type="button"
|
||||||
className="inline-flex items-center gap-1 text-xs uppercase tracking-wide font-medium text-base-content/60 transition-colors hover:text-base-content"
|
className="inline-flex items-center gap-1 text-xs uppercase tracking-wide font-medium text-base-content/60 transition-colors hover:text-base-content"
|
||||||
onClick={column.getToggleSortingHandler()}
|
onClick={column.getToggleSortingHandler()}
|
||||||
title={HEADER_TOOLTIPS[id]}
|
title={tooltip ?? HEADER_TOOLTIPS[id]}
|
||||||
aria-label={`Sort by ${label}`}
|
aria-label={`Sort by ${label}`}
|
||||||
aria-pressed={!!sorted}
|
aria-pressed={!!sorted}
|
||||||
>
|
>
|
||||||
@ -86,25 +97,65 @@ const keywordColumn: ColumnDef<RankTrackingRow> = {
|
|||||||
|
|
||||||
function makeDeviceColumn(
|
function makeDeviceColumn(
|
||||||
device: "desktop" | "mobile",
|
device: "desktop" | "mobile",
|
||||||
domain: string,
|
|
||||||
): ColumnDef<RankTrackingRow> {
|
): ColumnDef<RankTrackingRow> {
|
||||||
const id = device === "desktop" ? "desktopPosition" : "mobilePosition";
|
const id = device === "desktop" ? "desktopPosition" : "mobilePosition";
|
||||||
const label = device === "desktop" ? "Desktop" : "Mobile";
|
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
accessorFn: (row) => row[device].position,
|
accessorFn: (row) => row[device].position,
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<SortableHeader column={column} label={label} id={id} />
|
<SortableHeader column={column} label="Position" id={id} />
|
||||||
),
|
|
||||||
size: 176,
|
|
||||||
minSize: 176,
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<DeviceRankCell result={row.original[device]} domain={domain} />
|
|
||||||
),
|
),
|
||||||
|
size: 120,
|
||||||
|
maxSize: 140,
|
||||||
|
cell: ({ row }) => <DeviceRankCell result={row.original[device]} />,
|
||||||
sortingFn: positionSort,
|
sortingFn: positionSort,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function makeUrlColumn(
|
||||||
|
device: "desktop" | "mobile",
|
||||||
|
domain: string,
|
||||||
|
): ColumnDef<RankTrackingRow> {
|
||||||
|
return {
|
||||||
|
id: device === "desktop" ? "desktopUrl" : "mobileUrl",
|
||||||
|
enableSorting: false,
|
||||||
|
header: () => (
|
||||||
|
<span
|
||||||
|
className="text-xs uppercase tracking-wide font-medium text-base-content/60 cursor-help"
|
||||||
|
title={HEADER_TOOLTIPS.url}
|
||||||
|
>
|
||||||
|
URL
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
size: 240,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<DeviceUrlCell result={row.original[device]} domain={domain} />
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeSerpColumn(
|
||||||
|
device: "desktop" | "mobile",
|
||||||
|
): ColumnDef<RankTrackingRow> {
|
||||||
|
return {
|
||||||
|
id: device === "desktop" ? "desktopSerp" : "mobileSerp",
|
||||||
|
enableSorting: false,
|
||||||
|
header: () => (
|
||||||
|
<span
|
||||||
|
className="text-xs uppercase tracking-wide font-medium text-base-content/60 cursor-help"
|
||||||
|
title={HEADER_TOOLTIPS.serp}
|
||||||
|
>
|
||||||
|
SERP Features
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const features = row.original[device].serpFeatures;
|
||||||
|
if (features.length === 0) return null;
|
||||||
|
return <SerpFeatureTags features={features} />;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function useRankTrackingColumns(
|
export function useRankTrackingColumns(
|
||||||
showDesktop: boolean,
|
showDesktop: boolean,
|
||||||
showMobile: boolean,
|
showMobile: boolean,
|
||||||
@ -112,8 +163,16 @@ export function useRankTrackingColumns(
|
|||||||
): ColumnDef<RankTrackingRow>[] {
|
): ColumnDef<RankTrackingRow>[] {
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
const cols: ColumnDef<RankTrackingRow>[] = [selectColumn, keywordColumn];
|
const cols: ColumnDef<RankTrackingRow>[] = [selectColumn, keywordColumn];
|
||||||
if (showDesktop) cols.push(makeDeviceColumn("desktop", domain));
|
if (showDesktop) {
|
||||||
if (showMobile) cols.push(makeDeviceColumn("mobile", domain));
|
cols.push(makeDeviceColumn("desktop"));
|
||||||
|
cols.push(makeUrlColumn("desktop", domain));
|
||||||
|
cols.push(makeSerpColumn("desktop"));
|
||||||
|
}
|
||||||
|
if (showMobile) {
|
||||||
|
cols.push(makeDeviceColumn("mobile"));
|
||||||
|
cols.push(makeUrlColumn("mobile", domain));
|
||||||
|
cols.push(makeSerpColumn("mobile"));
|
||||||
|
}
|
||||||
return cols;
|
return cols;
|
||||||
}, [showDesktop, showMobile, domain]);
|
}, [showDesktop, showMobile, domain]);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,22 +5,29 @@ import {
|
|||||||
createRankTrackingConfig,
|
createRankTrackingConfig,
|
||||||
updateRankTrackingConfig,
|
updateRankTrackingConfig,
|
||||||
} from "@/serverFunctions/rank-tracking";
|
} from "@/serverFunctions/rank-tracking";
|
||||||
import { Loader2, X } from "lucide-react";
|
import { Info, Loader2, X } from "lucide-react";
|
||||||
import { Modal } from "@/client/components/Modal";
|
import { Modal } from "@/client/components/Modal";
|
||||||
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||||
import { captureClientEvent } from "@/client/lib/posthog";
|
import { captureClientEvent } from "@/client/lib/posthog";
|
||||||
import type { RankTrackingConfig } from "@/types/schemas/rank-tracking";
|
import type { RankTrackingConfig } from "@/types/schemas/rank-tracking";
|
||||||
|
import {
|
||||||
|
depthToPages,
|
||||||
|
pagesToDepth,
|
||||||
|
estimateRankCheckCredits,
|
||||||
|
} from "@/shared/rank-tracking";
|
||||||
import {
|
import {
|
||||||
LOCATION_OPTIONS,
|
LOCATION_OPTIONS,
|
||||||
DEFAULT_LOCATION_CODE,
|
DEFAULT_LOCATION_CODE,
|
||||||
getLanguageCode,
|
getLanguageCode,
|
||||||
} from "@/client/features/keywords/locations";
|
} from "@/client/features/keywords/locations";
|
||||||
|
import { KeywordSuggestionStep } from "./KeywordSuggestionStep";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
existingConfig?: RankTrackingConfig | null;
|
existingConfig?: RankTrackingConfig | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSaved: () => void;
|
onSaved: (createdConfigId?: string) => void;
|
||||||
|
onConfigCreated?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function RankTrackingConfigModal({
|
export function RankTrackingConfigModal({
|
||||||
@ -28,18 +35,22 @@ export function RankTrackingConfigModal({
|
|||||||
existingConfig,
|
existingConfig,
|
||||||
onClose,
|
onClose,
|
||||||
onSaved,
|
onSaved,
|
||||||
|
onConfigCreated,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const isEdit = !!existingConfig;
|
const isEdit = !!existingConfig;
|
||||||
|
const [step, setStep] = useState<"config" | "keywords">("config");
|
||||||
const [domain, setDomain] = useState(existingConfig?.domain ?? "");
|
const [domain, setDomain] = useState(existingConfig?.domain ?? "");
|
||||||
const [devices, setDevices] = useState<"both" | "desktop" | "mobile">(
|
const [devices, setDevices] = useState<"both" | "desktop" | "mobile">(
|
||||||
existingConfig?.devices ?? "mobile",
|
existingConfig?.devices ?? "both",
|
||||||
);
|
);
|
||||||
const [locationCode, setLocationCode] = useState(
|
const [locationCode, setLocationCode] = useState(
|
||||||
existingConfig?.locationCode ?? DEFAULT_LOCATION_CODE,
|
existingConfig?.locationCode ?? DEFAULT_LOCATION_CODE,
|
||||||
);
|
);
|
||||||
|
const [serpDepth, setSerpDepth] = useState(existingConfig?.serpDepth ?? 20);
|
||||||
const [schedule, setSchedule] = useState<"daily" | "weekly" | "manual">(
|
const [schedule, setSchedule] = useState<"daily" | "weekly" | "manual">(
|
||||||
existingConfig?.scheduleInterval ?? "weekly",
|
existingConfig?.scheduleInterval ?? "weekly",
|
||||||
);
|
);
|
||||||
|
const [createdConfigId, setCreatedConfigId] = useState<string | null>(null);
|
||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
@ -48,15 +59,18 @@ export function RankTrackingConfigModal({
|
|||||||
projectId,
|
projectId,
|
||||||
domain,
|
domain,
|
||||||
devices,
|
devices,
|
||||||
|
serpDepth,
|
||||||
locationCode,
|
locationCode,
|
||||||
languageCode: getLanguageCode(locationCode),
|
languageCode: getLanguageCode(locationCode),
|
||||||
scheduleInterval: schedule,
|
scheduleInterval: schedule,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: (result) => {
|
||||||
captureClientEvent("rank_tracking:config_create");
|
captureClientEvent("rank_tracking:config_create");
|
||||||
toast.success("Domain added for rank tracking");
|
toast.success("Domain added for rank tracking");
|
||||||
onSaved();
|
setCreatedConfigId(result.configId);
|
||||||
|
onConfigCreated?.();
|
||||||
|
setStep("keywords");
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast.error(getStandardErrorMessage(error, "Failed to save config"));
|
toast.error(getStandardErrorMessage(error, "Failed to save config"));
|
||||||
@ -71,6 +85,7 @@ export function RankTrackingConfigModal({
|
|||||||
configId: existingConfig!.id,
|
configId: existingConfig!.id,
|
||||||
domain,
|
domain,
|
||||||
devices,
|
devices,
|
||||||
|
serpDepth,
|
||||||
locationCode,
|
locationCode,
|
||||||
languageCode: getLanguageCode(locationCode),
|
languageCode: getLanguageCode(locationCode),
|
||||||
scheduleInterval: schedule,
|
scheduleInterval: schedule,
|
||||||
@ -102,8 +117,24 @@ export function RankTrackingConfigModal({
|
|||||||
|
|
||||||
const isPending = createMutation.isPending || updateMutation.isPending;
|
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||||
|
|
||||||
|
if (step === "keywords" && createdConfigId) {
|
||||||
return (
|
return (
|
||||||
<Modal>
|
<Modal maxWidth="max-w-3xl">
|
||||||
|
<KeywordSuggestionStep
|
||||||
|
configId={createdConfigId}
|
||||||
|
projectId={projectId}
|
||||||
|
domain={domain}
|
||||||
|
locationCode={locationCode}
|
||||||
|
languageCode={getLanguageCode(locationCode)}
|
||||||
|
onDone={(id) => onSaved(id)}
|
||||||
|
onClose={() => onSaved(createdConfigId ?? undefined)}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal maxWidth="max-w-lg">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-lg font-semibold">
|
<h2 className="text-lg font-semibold">
|
||||||
{isEdit ? "Edit Domain Config" : "Add Domain"}
|
{isEdit ? "Edit Domain Config" : "Add Domain"}
|
||||||
@ -166,6 +197,18 @@ export function RankTrackingConfigModal({
|
|||||||
<option value="desktop">Desktop only</option>
|
<option value="desktop">Desktop only</option>
|
||||||
<option value="mobile">Mobile only</option>
|
<option value="mobile">Mobile only</option>
|
||||||
</select>
|
</select>
|
||||||
|
<div className="mt-1.5 text-xs text-base-content/50">
|
||||||
|
Most Google searches come from mobile, but select this based on your
|
||||||
|
customer.
|
||||||
|
</div>
|
||||||
|
{devices === "both" && (
|
||||||
|
<div className="mt-1.5 flex items-start gap-1.5 text-xs text-info">
|
||||||
|
<Info className="size-3.5 shrink-0 mt-0.5" />
|
||||||
|
<span>
|
||||||
|
Tracking both devices uses 2x credits per keyword check
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-control">
|
<div className="form-control">
|
||||||
@ -190,12 +233,62 @@ export function RankTrackingConfigModal({
|
|||||||
<option value="weekly">Weekly</option>
|
<option value="weekly">Weekly</option>
|
||||||
<option value="manual">Manual only</option>
|
<option value="manual">Manual only</option>
|
||||||
</select>
|
</select>
|
||||||
|
{schedule === "daily" && (
|
||||||
|
<div className="mt-1.5 flex items-start gap-1.5 text-xs text-warning">
|
||||||
|
<Info className="size-3.5 shrink-0 mt-0.5" />
|
||||||
|
<span>Daily checks use 7x more credits than weekly</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-xs text-base-content/60">
|
<div className="form-control">
|
||||||
After adding a domain, manage tracked keywords from the domain detail
|
<label className="label">
|
||||||
view.
|
<span className="label-text font-medium">Search Depth</span>
|
||||||
</p>
|
</label>
|
||||||
|
<select
|
||||||
|
className="select select-bordered w-full"
|
||||||
|
value={depthToPages(serpDepth)}
|
||||||
|
onChange={(e) => setSerpDepth(pagesToDepth(Number(e.target.value)))}
|
||||||
|
>
|
||||||
|
{Array.from({ length: 10 }, (_, i) => i + 1).map((pages) => (
|
||||||
|
<option key={pages} value={pages}>
|
||||||
|
{pages} {pages === 1 ? "page" : "pages"} (top {pages * 10}{" "}
|
||||||
|
results)
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<div className="mt-1.5 text-xs text-base-content/50">
|
||||||
|
10 pages is ~8x more expensive than 1 page
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(() => {
|
||||||
|
const { costUsd: costPerKeyword } = estimateRankCheckCredits(
|
||||||
|
1,
|
||||||
|
devices,
|
||||||
|
serpDepth,
|
||||||
|
);
|
||||||
|
const checksPerMonth = schedule === "daily" ? 30 : 4;
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg bg-base-200/50 px-3 py-2.5 text-xs text-base-content/70 space-y-0.5">
|
||||||
|
<div>
|
||||||
|
<span className="font-mono font-semibold text-base-content">
|
||||||
|
~${costPerKeyword.toFixed(4)}
|
||||||
|
</span>{" "}
|
||||||
|
per keyword per check
|
||||||
|
</div>
|
||||||
|
{schedule !== "manual" && (
|
||||||
|
<div>
|
||||||
|
50 keywords would cost{" "}
|
||||||
|
<span className="font-mono font-semibold text-base-content">
|
||||||
|
~${(costPerKeyword * 50 * checksPerMonth).toFixed(2)}
|
||||||
|
</span>
|
||||||
|
/month
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 pt-2">
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@ -9,9 +9,11 @@ import {
|
|||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
Monitor,
|
||||||
Plus,
|
Plus,
|
||||||
Settings,
|
Settings,
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
|
Smartphone,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { captureClientEvent } from "@/client/lib/posthog";
|
import { captureClientEvent } from "@/client/lib/posthog";
|
||||||
import { RankTrackingTable } from "./RankTrackingTable";
|
import { RankTrackingTable } from "./RankTrackingTable";
|
||||||
@ -32,11 +34,12 @@ import {
|
|||||||
type Filters,
|
type Filters,
|
||||||
} from "./RankTrackingFilters";
|
} from "./RankTrackingFilters";
|
||||||
import { CheckConfirmModal } from "./CheckConfirmModal";
|
import { CheckConfirmModal } from "./CheckConfirmModal";
|
||||||
|
import { SegmentedToggle } from "@/client/components/SegmentedToggle";
|
||||||
import { useRankCheckTrigger } from "./useRankCheckTrigger";
|
import { useRankCheckTrigger } from "./useRankCheckTrigger";
|
||||||
import { useRankRunPolling } from "./useRankRunPolling";
|
import { useRankRunPolling } from "./useRankRunPolling";
|
||||||
|
|
||||||
const COMPARE_PERIODS: ReadonlySet<string> = new Set([
|
const COMPARE_PERIODS: ReadonlySet<string> = new Set([
|
||||||
"previous",
|
"1d",
|
||||||
"7d",
|
"7d",
|
||||||
"30d",
|
"30d",
|
||||||
"90d",
|
"90d",
|
||||||
@ -60,7 +63,12 @@ export function RankTrackingDomainDetail({
|
|||||||
const [showAddKeywords, setShowAddKeywords] = useState(false);
|
const [showAddKeywords, setShowAddKeywords] = useState(false);
|
||||||
const [showFilters, setShowFilters] = useState(false);
|
const [showFilters, setShowFilters] = useState(false);
|
||||||
const [filters, setFilters] = useState<Filters>(EMPTY_FILTERS);
|
const [filters, setFilters] = useState<Filters>(EMPTY_FILTERS);
|
||||||
const [comparePeriod, setComparePeriod] = useState<ComparePeriod>("previous");
|
const [comparePeriod, setComparePeriod] = useState<ComparePeriod>(
|
||||||
|
config.scheduleInterval === "daily" ? "1d" : "7d",
|
||||||
|
);
|
||||||
|
const [activeDevice, setActiveDevice] = useState<"desktop" | "mobile">(
|
||||||
|
config.devices === "mobile" ? "mobile" : "desktop",
|
||||||
|
);
|
||||||
|
|
||||||
const { data: resultsData, isLoading: resultsLoading } = useQuery({
|
const { data: resultsData, isLoading: resultsLoading } = useQuery({
|
||||||
queryKey: ["rankTrackingResults", projectId, config.id, comparePeriod],
|
queryKey: ["rankTrackingResults", projectId, config.id, comparePeriod],
|
||||||
@ -85,7 +93,8 @@ export function RankTrackingDomainDetail({
|
|||||||
|
|
||||||
const handleKeywordsAdded = (result: {
|
const handleKeywordsAdded = (result: {
|
||||||
added: number;
|
added: number;
|
||||||
addedIds?: string[];
|
addedIds: string[];
|
||||||
|
checkTriggered: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
void queryClient.invalidateQueries({
|
void queryClient.invalidateQueries({
|
||||||
queryKey: ["rankTrackingCostEstimate", projectId, config.id],
|
queryKey: ["rankTrackingCostEstimate", projectId, config.id],
|
||||||
@ -93,13 +102,17 @@ export function RankTrackingDomainDetail({
|
|||||||
void queryClient.invalidateQueries({
|
void queryClient.invalidateQueries({
|
||||||
queryKey: ["rankTrackingResults", projectId, config.id],
|
queryKey: ["rankTrackingResults", projectId, config.id],
|
||||||
});
|
});
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: ["rankTrackingLatestRun", projectId, config.id],
|
||||||
|
});
|
||||||
setShowAddKeywords(false);
|
setShowAddKeywords(false);
|
||||||
captureClientEvent("rank_tracking:keywords_add");
|
captureClientEvent("rank_tracking:keywords_add");
|
||||||
toast.success(
|
toast.success(
|
||||||
`${result.added} keyword${result.added !== 1 ? "s" : ""} added`,
|
`${result.added} keyword${result.added !== 1 ? "s" : ""} added`,
|
||||||
);
|
);
|
||||||
if (result.addedIds && result.addedIds.length > 0)
|
if (!result.checkTriggered && result.added > 0) {
|
||||||
requestCheck(result.addedIds.length, result.addedIds);
|
toast.info("Use 'Check Now' to check these keywords");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isRunning =
|
const isRunning =
|
||||||
@ -124,15 +137,19 @@ export function RankTrackingDomainDetail({
|
|||||||
|
|
||||||
const rows = resultsData?.rows;
|
const rows = resultsData?.rows;
|
||||||
const run = resultsData?.run;
|
const run = resultsData?.run;
|
||||||
const showDesktop = config.devices !== "mobile";
|
const hasBothDevices = config.devices === "both";
|
||||||
const showMobile = config.devices !== "desktop";
|
const showDesktop = hasBothDevices
|
||||||
|
? activeDevice === "desktop"
|
||||||
|
: config.devices !== "mobile";
|
||||||
|
const showMobile = hasBothDevices
|
||||||
|
? activeDevice === "mobile"
|
||||||
|
: config.devices !== "desktop";
|
||||||
const filtered = useMemo(
|
const filtered = useMemo(
|
||||||
() => applyFilters(rows ?? [], filters),
|
() => applyFilters(rows ?? [], filters),
|
||||||
[rows, filters],
|
[rows, filters],
|
||||||
);
|
);
|
||||||
const activeFilterCount = countActiveFilters(filters);
|
const activeFilterCount = countActiveFilters(filters);
|
||||||
const defaultSortId =
|
const defaultSortId = showDesktop ? "desktopPosition" : "mobilePosition";
|
||||||
config.devices === "desktop" ? "desktopPosition" : "mobilePosition";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@ -144,8 +161,29 @@ export function RankTrackingDomainDetail({
|
|||||||
Back to domains
|
Back to domains
|
||||||
</button>
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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 */}
|
{/* Domain header */}
|
||||||
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-2">
|
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-2 px-4 pt-4 pb-3">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold">{config.domain}</h2>
|
<h2 className="text-lg font-semibold">{config.domain}</h2>
|
||||||
<p className="text-xs text-base-content/60">
|
<p className="text-xs text-base-content/60">
|
||||||
@ -155,7 +193,8 @@ export function RankTrackingDomainDetail({
|
|||||||
{run && (
|
{run && (
|
||||||
<>
|
<>
|
||||||
{" "}
|
{" "}
|
||||||
· Last: {new Date(run.startedAt).toLocaleDateString()}
|
· Last:{" "}
|
||||||
|
{new Date(run.lastCheckedAt).toLocaleDateString()}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{costEstimate && costEstimate.keywordCount > 0 && (
|
{costEstimate && costEstimate.keywordCount > 0 && (
|
||||||
@ -178,38 +217,19 @@ export function RankTrackingDomainDetail({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showAddKeywords && (
|
{showAddKeywords && (
|
||||||
|
<div className="px-4 pb-3">
|
||||||
<AddKeywordsPanel
|
<AddKeywordsPanel
|
||||||
configId={config.id}
|
configId={config.id}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
onSuccess={handleKeywordsAdded}
|
onSuccess={handleKeywordsAdded}
|
||||||
onCancel={() => setShowAddKeywords(false)}
|
onCancel={() => setShowAddKeywords(false)}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Results card */}
|
|
||||||
<div className="flex-1 flex flex-col min-w-0 border border-base-300 rounded-xl bg-base-100 overflow-hidden">
|
|
||||||
{/* Table toolbar */}
|
{/* Table toolbar */}
|
||||||
<div className="shrink-0 flex items-center gap-2 px-4 py-2 border-b border-base-300">
|
<div className="shrink-0 flex items-center gap-2 px-4 py-2 border-y border-base-300">
|
||||||
<button
|
<button
|
||||||
className={`btn btn-ghost btn-sm gap-1.5 ${showFilters ? "btn-active" : ""}`}
|
className={`btn btn-ghost btn-sm gap-1.5 ${showFilters ? "btn-active" : ""}`}
|
||||||
onClick={() => setShowFilters((c) => !c)}
|
onClick={() => setShowFilters((c) => !c)}
|
||||||
@ -223,19 +243,6 @@ export function RankTrackingDomainDetail({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<select
|
|
||||||
className="select select-bordered select-sm text-xs"
|
|
||||||
value={comparePeriod}
|
|
||||||
onChange={(e) => {
|
|
||||||
if (isComparePeriod(e.target.value))
|
|
||||||
setComparePeriod(e.target.value);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="previous">vs previous check</option>
|
|
||||||
<option value="7d">vs 7 days ago</option>
|
|
||||||
<option value="30d">vs 30 days ago</option>
|
|
||||||
<option value="90d">vs 90 days ago</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
{isRunning && latestRun ? (
|
{isRunning && latestRun ? (
|
||||||
<div className="flex items-center gap-2 text-sm text-base-content/70">
|
<div className="flex items-center gap-2 text-sm text-base-content/70">
|
||||||
@ -243,7 +250,7 @@ export function RankTrackingDomainDetail({
|
|||||||
<span>
|
<span>
|
||||||
{latestRun.status === "pending"
|
{latestRun.status === "pending"
|
||||||
? "Preparing..."
|
? "Preparing..."
|
||||||
: "Checking keywords..."}{" "}
|
: `Getting rankings for ${latestRun.keywordsTotal || "?"} keyword${latestRun.keywordsTotal !== 1 ? "s" : ""}...`}{" "}
|
||||||
{latestRun.keywordsChecked}/{latestRun.keywordsTotal || "?"}
|
{latestRun.keywordsChecked}/{latestRun.keywordsTotal || "?"}
|
||||||
</span>
|
</span>
|
||||||
{latestRun.keywordsTotal > 0 && (
|
{latestRun.keywordsTotal > 0 && (
|
||||||
@ -262,6 +269,39 @@ export function RankTrackingDomainDetail({
|
|||||||
|
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
<select
|
||||||
|
className="select select-bordered select-sm text-xs w-auto"
|
||||||
|
value={comparePeriod}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (isComparePeriod(e.target.value))
|
||||||
|
setComparePeriod(e.target.value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="1d">Since yesterday</option>
|
||||||
|
<option value="7d">Since last week</option>
|
||||||
|
<option value="30d">Since last month</option>
|
||||||
|
<option value="90d">Since 90 days ago</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{hasBothDevices && (
|
||||||
|
<SegmentedToggle
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
value: "desktop" as const,
|
||||||
|
icon: <Monitor className="size-3.5" />,
|
||||||
|
label: "Desktop",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "mobile" as const,
|
||||||
|
icon: <Smartphone className="size-3.5" />,
|
||||||
|
label: "Mobile",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
value={activeDevice}
|
||||||
|
onChange={setActiveDevice}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<ActionsMenu
|
<ActionsMenu
|
||||||
onCheckNow={() => {
|
onCheckNow={() => {
|
||||||
const count = costEstimate?.keywordCount ?? rows?.length ?? 0;
|
const count = costEstimate?.keywordCount ?? rows?.length ?? 0;
|
||||||
@ -298,6 +338,7 @@ export function RankTrackingDomainDetail({
|
|||||||
{/* Table */}
|
{/* Table */}
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<RankTrackingTable
|
<RankTrackingTable
|
||||||
|
key={defaultSortId}
|
||||||
totalCount={rows?.length ?? 0}
|
totalCount={rows?.length ?? 0}
|
||||||
rows={filtered}
|
rows={filtered}
|
||||||
resultsLoading={resultsLoading}
|
resultsLoading={resultsLoading}
|
||||||
@ -315,6 +356,7 @@ export function RankTrackingDomainDetail({
|
|||||||
<CheckConfirmModal
|
<CheckConfirmModal
|
||||||
keywordCount={pendingCheck.count}
|
keywordCount={pendingCheck.count}
|
||||||
devices={config.devices}
|
devices={config.devices}
|
||||||
|
serpDepth={config.serpDepth}
|
||||||
isPending={isPending}
|
isPending={isPending}
|
||||||
onRunNow={() =>
|
onRunNow={() =>
|
||||||
startCheck({
|
startCheck({
|
||||||
|
|||||||
@ -1,12 +1,24 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useState } from "react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { LOCATIONS } from "@/client/features/keywords/locations";
|
import { LOCATIONS } from "@/client/features/keywords/locations";
|
||||||
import { AlertTriangle, Globe, Plus, ChevronRight } from "lucide-react";
|
import {
|
||||||
import { getRankTrackingConfigSummaries } from "@/serverFunctions/rank-tracking";
|
AlertTriangle,
|
||||||
|
Archive,
|
||||||
|
Globe,
|
||||||
|
Plus,
|
||||||
|
ChevronRight,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
getRankTrackingConfigSummaries,
|
||||||
|
updateRankTrackingConfig,
|
||||||
|
} from "@/serverFunctions/rank-tracking";
|
||||||
import {
|
import {
|
||||||
devicesLabel as getDevicesLabel,
|
devicesLabel as getDevicesLabel,
|
||||||
scheduleLabel as getScheduleLabel,
|
scheduleLabel as getScheduleLabel,
|
||||||
} from "@/shared/rank-tracking";
|
} from "@/shared/rank-tracking";
|
||||||
|
import { Modal } from "@/client/components/Modal";
|
||||||
|
|
||||||
type ConfigSummary = Awaited<
|
type ConfigSummary = Awaited<
|
||||||
ReturnType<typeof getRankTrackingConfigSummaries>
|
ReturnType<typeof getRankTrackingConfigSummaries>
|
||||||
@ -20,11 +32,32 @@ export function RankTrackingDomainList({
|
|||||||
onAddDomain: () => void;
|
onAddDomain: () => void;
|
||||||
}) {
|
}) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [archiveTarget, setArchiveTarget] = useState<ConfigSummary | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
const { data: summaries } = useQuery({
|
const { data: summaries } = useQuery({
|
||||||
queryKey: ["rankTrackingConfigSummaries", projectId],
|
queryKey: ["rankTrackingConfigSummaries", projectId],
|
||||||
queryFn: () => getRankTrackingConfigSummaries({ data: { projectId } }),
|
queryFn: () => getRankTrackingConfigSummaries({ data: { projectId } }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const archiveMutation = useMutation({
|
||||||
|
mutationFn: (configId: string) =>
|
||||||
|
updateRankTrackingConfig({
|
||||||
|
data: { projectId, configId, isActive: false },
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
setArchiveTarget(null);
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: ["rankTrackingConfigSummaries", projectId],
|
||||||
|
});
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: ["rankTrackingConfigs", projectId],
|
||||||
|
});
|
||||||
|
toast.success("Domain archived");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card bg-base-100 border border-base-300">
|
<div className="card bg-base-100 border border-base-300">
|
||||||
<div className="card-body gap-0 p-0">
|
<div className="card-body gap-0 p-0">
|
||||||
@ -62,11 +95,40 @@ export function RankTrackingDomainList({
|
|||||||
params: { projectId, configId: summary.id },
|
params: { projectId, configId: summary.id },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
onArchive={() => setArchiveTarget(summary)}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{archiveTarget && (
|
||||||
|
<Modal>
|
||||||
|
<h3 className="text-lg font-semibold">
|
||||||
|
Archive {archiveTarget.domain}?
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-base-content/70">
|
||||||
|
Scheduled checks will stop and this domain will be hidden from the
|
||||||
|
list. Ranking history is preserved.
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost btn-sm"
|
||||||
|
onClick={() => setArchiveTarget(null)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-error btn-sm gap-1"
|
||||||
|
onClick={() => archiveMutation.mutate(archiveTarget.id)}
|
||||||
|
disabled={archiveMutation.isPending}
|
||||||
|
>
|
||||||
|
<Archive className="size-3.5" />
|
||||||
|
Archive
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -74,22 +136,28 @@ export function RankTrackingDomainList({
|
|||||||
function DomainRow({
|
function DomainRow({
|
||||||
summary,
|
summary,
|
||||||
onClick,
|
onClick,
|
||||||
|
onArchive,
|
||||||
}: {
|
}: {
|
||||||
summary: ConfigSummary;
|
summary: ConfigSummary;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
|
onArchive: () => void;
|
||||||
}) {
|
}) {
|
||||||
const dl = getDevicesLabel(summary.devices);
|
const dl = getDevicesLabel(summary.devices);
|
||||||
const sl = getScheduleLabel(summary.scheduleInterval);
|
const sl = getScheduleLabel(summary.scheduleInterval);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<div
|
||||||
type="button"
|
role="button"
|
||||||
className="flex w-full items-center gap-4 px-5 py-3.5 text-left transition-colors hover:bg-base-200/50"
|
tabIndex={0}
|
||||||
|
className="flex w-full items-center gap-4 px-5 py-3.5 text-left transition-colors hover:bg-base-200/50 cursor-pointer"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
onClick();
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-base-200">
|
|
||||||
<Globe className="size-4 text-base-content/60" />
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="font-medium truncate">{summary.domain}</p>
|
<p className="font-medium truncate">{summary.domain}</p>
|
||||||
<p className="text-xs text-base-content/60">
|
<p className="text-xs text-base-content/60">
|
||||||
@ -119,7 +187,18 @@ function DomainRow({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<ChevronRight className="size-4 shrink-0 text-base-content/40" />
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-xs text-base-content/40 hover:text-error"
|
||||||
|
title="Archive domain"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onArchive();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Archive className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
|
<ChevronRight className="size-4 shrink-0 text-base-content/40" />
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { ArrowUp, ArrowDown, Minus, Sparkles } from "lucide-react";
|
import { Sparkles } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { buildCsv, downloadCsv } from "@/client/lib/csv";
|
import { buildCsv, downloadCsv } from "@/client/lib/csv";
|
||||||
import { captureClientEvent } from "@/client/lib/posthog";
|
import { captureClientEvent } from "@/client/lib/posthog";
|
||||||
@ -7,50 +7,6 @@ import type {
|
|||||||
RankTrackingRow,
|
RankTrackingRow,
|
||||||
} from "@/types/schemas/rank-tracking";
|
} from "@/types/schemas/rank-tracking";
|
||||||
|
|
||||||
function PositionBadge({ position }: { position: number | null }) {
|
|
||||||
if (position === null) {
|
|
||||||
return <span className="text-base-content/40">-</span>;
|
|
||||||
}
|
|
||||||
return <span className="font-mono">{position}</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ChangeIndicator({
|
|
||||||
current,
|
|
||||||
previous,
|
|
||||||
}: {
|
|
||||||
current: number | null;
|
|
||||||
previous: number | null;
|
|
||||||
}) {
|
|
||||||
if (previous === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (current === null) {
|
|
||||||
return <span className="badge badge-xs badge-error">lost</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const change = previous - current; // positive = improved (lower position number is better)
|
|
||||||
if (change > 0) {
|
|
||||||
return (
|
|
||||||
<span className="inline-flex items-center gap-0.5 text-xs text-success">
|
|
||||||
<ArrowUp className="size-3" />+{change}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (change < 0) {
|
|
||||||
return (
|
|
||||||
<span className="inline-flex items-center gap-0.5 text-xs text-error">
|
|
||||||
<ArrowDown className="size-3" />
|
|
||||||
{change}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<span className="text-base-content/40">
|
|
||||||
<Minus className="size-3 inline" />
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const FEATURE_SHORT_LABELS: Record<string, string> = {
|
const FEATURE_SHORT_LABELS: Record<string, string> = {
|
||||||
featured_snippet: "FS",
|
featured_snippet: "FS",
|
||||||
people_also_ask: "PAA",
|
people_also_ask: "PAA",
|
||||||
@ -76,7 +32,7 @@ const FEATURE_TOOLTIPS: Record<string, string> = {
|
|||||||
top_stories: "Top Stories — news articles carousel",
|
top_stories: "Top Stories — news articles carousel",
|
||||||
};
|
};
|
||||||
|
|
||||||
function SerpFeatureTags({ features }: { features: string[] }) {
|
export function SerpFeatureTags({ features }: { features: string[] }) {
|
||||||
const notable = features.filter((f) => f in FEATURE_SHORT_LABELS);
|
const notable = features.filter((f) => f in FEATURE_SHORT_LABELS);
|
||||||
if (notable.length === 0) return null;
|
if (notable.length === 0) return null;
|
||||||
return (
|
return (
|
||||||
@ -84,7 +40,7 @@ function SerpFeatureTags({ features }: { features: string[] }) {
|
|||||||
{notable.map((f) => (
|
{notable.map((f) => (
|
||||||
<span
|
<span
|
||||||
key={f}
|
key={f}
|
||||||
className="badge badge-outline badge-xs gap-0.5 cursor-help"
|
className="badge badge-xs gap-0.5 cursor-help bg-base-300 border-0 text-base-content/70"
|
||||||
title={FEATURE_TOOLTIPS[f] ?? f}
|
title={FEATURE_TOOLTIPS[f] ?? f}
|
||||||
>
|
>
|
||||||
{f === "ai_overview" && <Sparkles className="size-2.5" />}
|
{f === "ai_overview" && <Sparkles className="size-2.5" />}
|
||||||
@ -95,51 +51,79 @@ function SerpFeatureTags({ features }: { features: string[] }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PositionWithChange({
|
export function DeviceRankCell({
|
||||||
position,
|
result,
|
||||||
previous,
|
|
||||||
}: {
|
}: {
|
||||||
position: number | null;
|
result: RankTrackingDeviceResult;
|
||||||
previous: number | null;
|
|
||||||
}) {
|
}) {
|
||||||
|
const { position, previousPosition } = result;
|
||||||
|
|
||||||
|
// Nothing at all
|
||||||
|
if (position === null && previousPosition === null) {
|
||||||
|
return <span className="text-base-content/40">-</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Was ranking, now lost
|
||||||
|
if (position === null && previousPosition !== null) {
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex w-full items-center justify-between px-3">
|
<span className="inline-flex items-center gap-1.5">
|
||||||
<PositionBadge position={position} />
|
<span className="font-mono text-xs text-base-content/40 w-6 text-right">
|
||||||
<ChangeIndicator current={position} previous={previous} />
|
{previousPosition}
|
||||||
|
</span>
|
||||||
|
<span className="text-base-content/30">→</span>
|
||||||
|
<span className="font-mono rounded px-1.5 py-0.5 text-xs font-semibold bg-error/20 text-error">
|
||||||
|
lost
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// First check — no previous data
|
||||||
|
if (previousPosition === null) {
|
||||||
|
return <span className="font-mono">{position}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both exist — show old → new with colored badge
|
||||||
|
const change = previousPosition - position!;
|
||||||
|
let badgeClass = "bg-base-200 text-base-content";
|
||||||
|
if (change > 0) badgeClass = "bg-success/20 text-success";
|
||||||
|
if (change < 0) badgeClass = "bg-warning/20 text-warning";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<span className="font-mono text-xs text-base-content/40 w-6 text-right">
|
||||||
|
{previousPosition}
|
||||||
|
</span>
|
||||||
|
<span className="text-base-content/30">→</span>
|
||||||
|
<span
|
||||||
|
className={`font-mono rounded px-1.5 py-0.5 text-xs font-semibold ${badgeClass}`}
|
||||||
|
>
|
||||||
|
{position}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DeviceRankCell({
|
export function DeviceUrlCell({
|
||||||
result,
|
result,
|
||||||
domain,
|
domain,
|
||||||
}: {
|
}: {
|
||||||
result: RankTrackingDeviceResult;
|
result: RankTrackingDeviceResult;
|
||||||
domain: string;
|
domain: string;
|
||||||
}) {
|
}) {
|
||||||
|
if (!result.rankingUrl) {
|
||||||
|
return <span className="text-base-content/40 text-xs">-</span>;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div className="min-w-44 space-y-1.5">
|
|
||||||
<PositionWithChange
|
|
||||||
position={result.position}
|
|
||||||
previous={result.previousPosition}
|
|
||||||
/>
|
|
||||||
{result.rankingUrl ? (
|
|
||||||
<a
|
<a
|
||||||
href={toFullUrl(result.rankingUrl, domain)}
|
href={toFullUrl(result.rankingUrl, domain)}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="link link-hover block truncate px-3 text-xs"
|
className="link link-hover block truncate text-xs"
|
||||||
title={result.rankingUrl}
|
title={result.rankingUrl}
|
||||||
>
|
>
|
||||||
{toPath(result.rankingUrl)}
|
{toPath(result.rankingUrl)}
|
||||||
</a>
|
</a>
|
||||||
) : null}
|
|
||||||
{result.serpFeatures.length > 0 ? (
|
|
||||||
<div className="px-3">
|
|
||||||
<SerpFeatureTags features={result.serpFeatures} />
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -120,6 +120,7 @@ export const rankTrackingConfigs = sqliteTable(
|
|||||||
})
|
})
|
||||||
.notNull()
|
.notNull()
|
||||||
.default("both"),
|
.default("both"),
|
||||||
|
serpDepth: integer("serp_depth").notNull(),
|
||||||
scheduleInterval: text("schedule_interval", {
|
scheduleInterval: text("schedule_interval", {
|
||||||
enum: ["daily", "weekly", "manual"],
|
enum: ["daily", "weekly", "manual"],
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { RankTrackingDomainList } from "@/client/features/rank-tracking/RankTrackingDomainList";
|
import { RankTrackingDomainList } from "@/client/features/rank-tracking/RankTrackingDomainList";
|
||||||
import { RankTrackingConfigModal } from "@/client/features/rank-tracking/RankTrackingConfigModal";
|
import { RankTrackingConfigModal } from "@/client/features/rank-tracking/RankTrackingConfigModal";
|
||||||
@ -10,6 +10,7 @@ export const Route = createFileRoute("/_project/p/$projectId/rank-tracking/")({
|
|||||||
|
|
||||||
function RankTrackingIndex() {
|
function RankTrackingIndex() {
|
||||||
const { projectId } = Route.useParams();
|
const { projectId } = Route.useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [showConfigModal, setShowConfigModal] = useState(false);
|
const [showConfigModal, setShowConfigModal] = useState(false);
|
||||||
|
|
||||||
@ -34,9 +35,16 @@ function RankTrackingIndex() {
|
|||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
existingConfig={null}
|
existingConfig={null}
|
||||||
onClose={() => setShowConfigModal(false)}
|
onClose={() => setShowConfigModal(false)}
|
||||||
onSaved={() => {
|
onConfigCreated={invalidateConfigs}
|
||||||
|
onSaved={(createdConfigId) => {
|
||||||
setShowConfigModal(false);
|
setShowConfigModal(false);
|
||||||
invalidateConfigs();
|
invalidateConfigs();
|
||||||
|
if (createdConfigId) {
|
||||||
|
void navigate({
|
||||||
|
to: "/p/$projectId/rank-tracking/$configId",
|
||||||
|
params: { projectId, configId: createdConfigId },
|
||||||
|
});
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -248,6 +248,89 @@ function derivePages(
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getSuggestedKeywords(
|
||||||
|
input: {
|
||||||
|
domain: string;
|
||||||
|
locationCode: number;
|
||||||
|
languageCode: string;
|
||||||
|
organizationId: string;
|
||||||
|
projectId: string;
|
||||||
|
},
|
||||||
|
billingCustomer: BillingCustomerContext,
|
||||||
|
): Promise<
|
||||||
|
Array<{
|
||||||
|
keyword: string;
|
||||||
|
position: number | null;
|
||||||
|
searchVolume: number | null;
|
||||||
|
traffic: number | null;
|
||||||
|
cpc: number | null;
|
||||||
|
keywordDifficulty: number | null;
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
const domain = input.domain.toLowerCase().trim();
|
||||||
|
|
||||||
|
const cacheKey = await buildCacheKey("domain:keyword-suggestions", {
|
||||||
|
organizationId: billingCustomer.organizationId,
|
||||||
|
projectId: input.projectId,
|
||||||
|
domain,
|
||||||
|
locationCode: input.locationCode,
|
||||||
|
languageCode: input.languageCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
const cachedRaw = await getCached(cacheKey);
|
||||||
|
const cached = z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
keyword: z.string(),
|
||||||
|
position: z.number().nullable(),
|
||||||
|
searchVolume: z.number().nullable(),
|
||||||
|
traffic: z.number().nullable(),
|
||||||
|
cpc: z.number().nullable(),
|
||||||
|
keywordDifficulty: z.number().nullable(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.safeParse(cachedRaw);
|
||||||
|
if (cached.success && cached.data.length > 0) {
|
||||||
|
return cached.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataforseo = createDataforseoClient(billingCustomer);
|
||||||
|
|
||||||
|
const rankedItems = await dataforseo.domain.rankedKeywords({
|
||||||
|
target: domain,
|
||||||
|
locationCode: input.locationCode,
|
||||||
|
languageCode: input.languageCode,
|
||||||
|
limit: 100,
|
||||||
|
orderBy: ["keyword_data.keyword_info.search_volume,desc"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const keywords = rankedItems
|
||||||
|
.map((item) => mapKeywordItem(item))
|
||||||
|
.filter(
|
||||||
|
(item): item is NonNullable<ReturnType<typeof mapKeywordItem>> =>
|
||||||
|
item != null,
|
||||||
|
)
|
||||||
|
.map((item) => ({
|
||||||
|
keyword: item.keyword,
|
||||||
|
position: item.position,
|
||||||
|
searchVolume: item.searchVolume,
|
||||||
|
traffic: item.traffic,
|
||||||
|
cpc: item.cpc,
|
||||||
|
keywordDifficulty: item.keywordDifficulty,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (keywords.length > 0) {
|
||||||
|
void setCached(cacheKey, keywords, DOMAIN_OVERVIEW_TTL_SECONDS).catch(
|
||||||
|
(error) => {
|
||||||
|
console.error("domain.keyword-suggestions.cache-write failed:", error);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return keywords;
|
||||||
|
}
|
||||||
|
|
||||||
export const DomainService = {
|
export const DomainService = {
|
||||||
getOverview,
|
getOverview,
|
||||||
|
getSuggestedKeywords,
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { and, asc, count, desc, eq, gte, inArray, lte, max } from "drizzle-orm";
|
import { and, count, desc, eq, inArray, lte, max } from "drizzle-orm";
|
||||||
import type { InferInsertModel } from "drizzle-orm";
|
import type { InferInsertModel } from "drizzle-orm";
|
||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import {
|
import {
|
||||||
@ -9,6 +9,11 @@ import {
|
|||||||
rankTrackingKeywords,
|
rankTrackingKeywords,
|
||||||
projects,
|
projects,
|
||||||
} from "@/db/schema";
|
} from "@/db/schema";
|
||||||
|
import {
|
||||||
|
getLatestSnapshotsForKeywords,
|
||||||
|
getSnapshotsBeforeDate,
|
||||||
|
getEarliestSnapshotsForKeywords,
|
||||||
|
} from "./snapshotQueries";
|
||||||
|
|
||||||
const DB_BATCH_SIZE = 100;
|
const DB_BATCH_SIZE = 100;
|
||||||
type BatchStatement = Parameters<typeof db.batch>[0][number];
|
type BatchStatement = Parameters<typeof db.batch>[0][number];
|
||||||
@ -33,7 +38,12 @@ async function getConfigsForProject(projectId: string) {
|
|||||||
return db
|
return db
|
||||||
.select()
|
.select()
|
||||||
.from(rankTrackingConfigs)
|
.from(rankTrackingConfigs)
|
||||||
.where(eq(rankTrackingConfigs.projectId, projectId))
|
.where(
|
||||||
|
and(
|
||||||
|
eq(rankTrackingConfigs.projectId, projectId),
|
||||||
|
eq(rankTrackingConfigs.isActive, true),
|
||||||
|
),
|
||||||
|
)
|
||||||
.orderBy(rankTrackingConfigs.createdAt);
|
.orderBy(rankTrackingConfigs.createdAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -107,6 +117,7 @@ async function getDueConfigsWithOrganization(nowIso: string) {
|
|||||||
locationCode: rankTrackingConfigs.locationCode,
|
locationCode: rankTrackingConfigs.locationCode,
|
||||||
languageCode: rankTrackingConfigs.languageCode,
|
languageCode: rankTrackingConfigs.languageCode,
|
||||||
devices: rankTrackingConfigs.devices,
|
devices: rankTrackingConfigs.devices,
|
||||||
|
serpDepth: rankTrackingConfigs.serpDepth,
|
||||||
scheduleInterval: rankTrackingConfigs.scheduleInterval,
|
scheduleInterval: rankTrackingConfigs.scheduleInterval,
|
||||||
nextCheckAt: rankTrackingConfigs.nextCheckAt,
|
nextCheckAt: rankTrackingConfigs.nextCheckAt,
|
||||||
organizationId: projects.organizationId,
|
organizationId: projects.organizationId,
|
||||||
@ -215,62 +226,6 @@ async function getSnapshotsForRun(runId: string) {
|
|||||||
return db.select().from(rankSnapshots).where(eq(rankSnapshots.runId, runId));
|
return db.select().from(rankSnapshots).where(eq(rankSnapshots.runId, runId));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getRecentCompletedRuns(configId: string, limit: number) {
|
|
||||||
return db
|
|
||||||
.select()
|
|
||||||
.from(rankCheckRuns)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(rankCheckRuns.configId, configId),
|
|
||||||
eq(rankCheckRuns.status, "completed"),
|
|
||||||
eq(rankCheckRuns.isSubsetRun, false),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.orderBy(desc(rankCheckRuns.startedAt))
|
|
||||||
.limit(limit);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getClosestCompletedRun(configId: string, targetDate: string) {
|
|
||||||
const [beforeRows, afterRows] = await Promise.all([
|
|
||||||
db
|
|
||||||
.select()
|
|
||||||
.from(rankCheckRuns)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(rankCheckRuns.configId, configId),
|
|
||||||
eq(rankCheckRuns.status, "completed"),
|
|
||||||
eq(rankCheckRuns.isSubsetRun, false),
|
|
||||||
lte(rankCheckRuns.startedAt, targetDate),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.orderBy(desc(rankCheckRuns.startedAt))
|
|
||||||
.limit(1),
|
|
||||||
db
|
|
||||||
.select()
|
|
||||||
.from(rankCheckRuns)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(rankCheckRuns.configId, configId),
|
|
||||||
eq(rankCheckRuns.status, "completed"),
|
|
||||||
eq(rankCheckRuns.isSubsetRun, false),
|
|
||||||
gte(rankCheckRuns.startedAt, targetDate),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.orderBy(asc(rankCheckRuns.startedAt))
|
|
||||||
.limit(1),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const before = beforeRows[0] ?? null;
|
|
||||||
const after = afterRows[0] ?? null;
|
|
||||||
if (!before) return after;
|
|
||||||
if (!after) return before;
|
|
||||||
|
|
||||||
const targetMs = new Date(targetDate).getTime();
|
|
||||||
const beforeDiff = Math.abs(targetMs - new Date(before.startedAt).getTime());
|
|
||||||
const afterDiff = Math.abs(new Date(after.startedAt).getTime() - targetMs);
|
|
||||||
return beforeDiff <= afterDiff ? before : after;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Tracking keywords per config
|
// Tracking keywords per config
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -401,11 +356,12 @@ export const RankTrackingRepository = {
|
|||||||
deleteRunLock,
|
deleteRunLock,
|
||||||
insertSnapshots,
|
insertSnapshots,
|
||||||
getSnapshotsForRun,
|
getSnapshotsForRun,
|
||||||
getRecentCompletedRuns,
|
|
||||||
getClosestCompletedRun,
|
|
||||||
getKeywordsForConfig,
|
getKeywordsForConfig,
|
||||||
addKeywordsToConfig,
|
addKeywordsToConfig,
|
||||||
removeKeywordsFromConfig,
|
removeKeywordsFromConfig,
|
||||||
getKeywordCountForConfig,
|
getKeywordCountForConfig,
|
||||||
getConfigSummaries,
|
getConfigSummaries,
|
||||||
|
getLatestSnapshotsForKeywords,
|
||||||
|
getSnapshotsBeforeDate,
|
||||||
|
getEarliestSnapshotsForKeywords,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -0,0 +1,149 @@
|
|||||||
|
import { and, eq, inArray, lte, max, min } from "drizzle-orm";
|
||||||
|
import { db } from "@/db";
|
||||||
|
import { rankCheckRuns, rankSnapshots } from "@/db/schema";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick one snapshot per keyword+device from completed runs, using SQL GROUP BY
|
||||||
|
* + self-join instead of loading all snapshots into JS memory.
|
||||||
|
*
|
||||||
|
* No keywordIds needed — scoped to the config via a completed-runs subquery,
|
||||||
|
* so subset runs are included automatically.
|
||||||
|
*/
|
||||||
|
export async function getSnapshotsForConfig(
|
||||||
|
configId: string,
|
||||||
|
opts: { beforeDate?: string; order: "latest" | "earliest" },
|
||||||
|
) {
|
||||||
|
const completedRunIds = db
|
||||||
|
.select({ id: rankCheckRuns.id })
|
||||||
|
.from(rankCheckRuns)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(rankCheckRuns.configId, configId),
|
||||||
|
eq(rankCheckRuns.status, "completed"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const aggFn = opts.order === "latest" ? max : min;
|
||||||
|
|
||||||
|
const conditions = [inArray(rankSnapshots.runId, completedRunIds)];
|
||||||
|
if (opts.beforeDate) {
|
||||||
|
conditions.push(lte(rankSnapshots.checkedAt, opts.beforeDate));
|
||||||
|
}
|
||||||
|
|
||||||
|
const grouped = db
|
||||||
|
.select({
|
||||||
|
trackingKeywordId: rankSnapshots.trackingKeywordId,
|
||||||
|
device: rankSnapshots.device,
|
||||||
|
targetCheckedAt: aggFn(rankSnapshots.checkedAt).as("target_checked_at"),
|
||||||
|
})
|
||||||
|
.from(rankSnapshots)
|
||||||
|
.where(and(...conditions))
|
||||||
|
.groupBy(rankSnapshots.trackingKeywordId, rankSnapshots.device)
|
||||||
|
.as("grouped");
|
||||||
|
|
||||||
|
return db
|
||||||
|
.select({
|
||||||
|
id: rankSnapshots.id,
|
||||||
|
runId: rankSnapshots.runId,
|
||||||
|
trackingKeywordId: rankSnapshots.trackingKeywordId,
|
||||||
|
keyword: rankSnapshots.keyword,
|
||||||
|
device: rankSnapshots.device,
|
||||||
|
position: rankSnapshots.position,
|
||||||
|
url: rankSnapshots.url,
|
||||||
|
serpFeatures: rankSnapshots.serpFeatures,
|
||||||
|
checkedAt: rankSnapshots.checkedAt,
|
||||||
|
})
|
||||||
|
.from(rankSnapshots)
|
||||||
|
.innerJoin(
|
||||||
|
grouped,
|
||||||
|
and(
|
||||||
|
eq(rankSnapshots.trackingKeywordId, grouped.trackingKeywordId),
|
||||||
|
eq(rankSnapshots.device, grouped.device),
|
||||||
|
eq(rankSnapshots.checkedAt, grouped.targetCheckedAt),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.where(inArray(rankSnapshots.runId, completedRunIds));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLatestSnapshotsForKeywords(configId: string) {
|
||||||
|
return getSnapshotsForConfig(configId, { order: "latest" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSnapshotsBeforeDate(
|
||||||
|
configId: string,
|
||||||
|
beforeDate: string,
|
||||||
|
) {
|
||||||
|
return getSnapshotsForConfig(configId, { beforeDate, order: "latest" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getEarliestSnapshotsForKeywords(
|
||||||
|
configId: string,
|
||||||
|
keywordIds: string[],
|
||||||
|
) {
|
||||||
|
if (keywordIds.length === 0) return [];
|
||||||
|
|
||||||
|
const completedRunIds = db
|
||||||
|
.select({ id: rankCheckRuns.id })
|
||||||
|
.from(rankCheckRuns)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(rankCheckRuns.configId, configId),
|
||||||
|
eq(rankCheckRuns.status, "completed"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const CHUNK_SIZE = 900;
|
||||||
|
const allResults: Awaited<ReturnType<typeof getSnapshotsForConfig>> = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < keywordIds.length; i += CHUNK_SIZE) {
|
||||||
|
const chunk = keywordIds.slice(i, i + CHUNK_SIZE);
|
||||||
|
|
||||||
|
const grouped = db
|
||||||
|
.select({
|
||||||
|
trackingKeywordId: rankSnapshots.trackingKeywordId,
|
||||||
|
device: rankSnapshots.device,
|
||||||
|
targetCheckedAt: min(rankSnapshots.checkedAt).as("target_checked_at"),
|
||||||
|
})
|
||||||
|
.from(rankSnapshots)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(rankSnapshots.runId, completedRunIds),
|
||||||
|
inArray(rankSnapshots.trackingKeywordId, chunk),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.groupBy(rankSnapshots.trackingKeywordId, rankSnapshots.device)
|
||||||
|
.as("grouped");
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: rankSnapshots.id,
|
||||||
|
runId: rankSnapshots.runId,
|
||||||
|
trackingKeywordId: rankSnapshots.trackingKeywordId,
|
||||||
|
keyword: rankSnapshots.keyword,
|
||||||
|
device: rankSnapshots.device,
|
||||||
|
position: rankSnapshots.position,
|
||||||
|
url: rankSnapshots.url,
|
||||||
|
serpFeatures: rankSnapshots.serpFeatures,
|
||||||
|
checkedAt: rankSnapshots.checkedAt,
|
||||||
|
})
|
||||||
|
.from(rankSnapshots)
|
||||||
|
.innerJoin(
|
||||||
|
grouped,
|
||||||
|
and(
|
||||||
|
eq(rankSnapshots.trackingKeywordId, grouped.trackingKeywordId),
|
||||||
|
eq(rankSnapshots.device, grouped.device),
|
||||||
|
eq(rankSnapshots.checkedAt, grouped.targetCheckedAt),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(rankSnapshots.runId, completedRunIds),
|
||||||
|
inArray(rankSnapshots.trackingKeywordId, chunk),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
allResults.push(...rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
return allResults;
|
||||||
|
}
|
||||||
@ -28,6 +28,7 @@ async function createConfig(input: {
|
|||||||
locationCode?: number;
|
locationCode?: number;
|
||||||
languageCode?: string;
|
languageCode?: string;
|
||||||
devices?: RankTrackingConfig["devices"];
|
devices?: RankTrackingConfig["devices"];
|
||||||
|
serpDepth: number;
|
||||||
scheduleInterval?: RankTrackingConfig["scheduleInterval"];
|
scheduleInterval?: RankTrackingConfig["scheduleInterval"];
|
||||||
}) {
|
}) {
|
||||||
const normalizedDomain = normalizeDomain(input.domain);
|
const normalizedDomain = normalizeDomain(input.domain);
|
||||||
@ -69,7 +70,8 @@ async function createConfig(input: {
|
|||||||
domain: normalizedDomain,
|
domain: normalizedDomain,
|
||||||
locationCode: input.locationCode ?? 2840,
|
locationCode: input.locationCode ?? 2840,
|
||||||
languageCode: input.languageCode ?? "en",
|
languageCode: input.languageCode ?? "en",
|
||||||
devices: input.devices ?? "mobile",
|
devices: input.devices ?? "both",
|
||||||
|
serpDepth: input.serpDepth,
|
||||||
scheduleInterval,
|
scheduleInterval,
|
||||||
nextCheckAt,
|
nextCheckAt,
|
||||||
});
|
});
|
||||||
@ -85,6 +87,7 @@ async function updateConfig(
|
|||||||
locationCode?: number;
|
locationCode?: number;
|
||||||
languageCode?: string;
|
languageCode?: string;
|
||||||
devices?: RankTrackingConfig["devices"];
|
devices?: RankTrackingConfig["devices"];
|
||||||
|
serpDepth?: number;
|
||||||
scheduleInterval?: RankTrackingConfig["scheduleInterval"];
|
scheduleInterval?: RankTrackingConfig["scheduleInterval"];
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
},
|
},
|
||||||
@ -98,6 +101,7 @@ async function updateConfig(
|
|||||||
if (input.languageCode !== undefined)
|
if (input.languageCode !== undefined)
|
||||||
updates.languageCode = input.languageCode;
|
updates.languageCode = input.languageCode;
|
||||||
if (input.devices !== undefined) updates.devices = input.devices;
|
if (input.devices !== undefined) updates.devices = input.devices;
|
||||||
|
if (input.serpDepth !== undefined) updates.serpDepth = input.serpDepth;
|
||||||
if (input.isActive !== undefined) updates.isActive = input.isActive;
|
if (input.isActive !== undefined) updates.isActive = input.isActive;
|
||||||
|
|
||||||
if (input.scheduleInterval !== undefined) {
|
if (input.scheduleInterval !== undefined) {
|
||||||
@ -200,7 +204,7 @@ async function triggerCheck(input: {
|
|||||||
organizationId: input.billingCustomer.organizationId,
|
organizationId: input.billingCustomer.organizationId,
|
||||||
projectId: input.billingCustomer.projectId,
|
projectId: input.billingCustomer.projectId,
|
||||||
},
|
},
|
||||||
keywordsTotal: keywords.length,
|
keywordsTotal: input.keywordIds ? input.keywordIds.length : keywords.length,
|
||||||
keywordIds: input.keywordIds,
|
keywordIds: input.keywordIds,
|
||||||
trigger: "manual",
|
trigger: "manual",
|
||||||
workflowStartErrorMessage: "Failed to start rank check workflow",
|
workflowStartErrorMessage: "Failed to start rank check workflow",
|
||||||
@ -239,6 +243,7 @@ async function estimateCost(configId: string, projectId: string) {
|
|||||||
const { costUsd, costCredits } = estimateRankCheckCredits(
|
const { costUsd, costCredits } = estimateRankCheckCredits(
|
||||||
keywordCount,
|
keywordCount,
|
||||||
config.devices,
|
config.devices,
|
||||||
|
config.serpDepth,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
costUsd,
|
costUsd,
|
||||||
@ -292,6 +297,7 @@ function formatRun(
|
|||||||
status: run.status,
|
status: run.status,
|
||||||
keywordsTotal: run.keywordsTotal,
|
keywordsTotal: run.keywordsTotal,
|
||||||
keywordsChecked: run.keywordsChecked,
|
keywordsChecked: run.keywordsChecked,
|
||||||
|
isSubsetRun: run.isSubsetRun,
|
||||||
errorMessage: run.errorMessage,
|
errorMessage: run.errorMessage,
|
||||||
startedAt: run.startedAt,
|
startedAt: run.startedAt,
|
||||||
completedAt: run.completedAt,
|
completedAt: run.completedAt,
|
||||||
|
|||||||
@ -36,7 +36,7 @@ type RankCheckWorkflowStatus = {
|
|||||||
|
|
||||||
type RankCheckConfigForStart = Pick<
|
type RankCheckConfigForStart = Pick<
|
||||||
RankTrackingConfig,
|
RankTrackingConfig,
|
||||||
"id" | "domain" | "locationCode" | "languageCode" | "devices"
|
"id" | "domain" | "locationCode" | "languageCode" | "devices" | "serpDepth"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
const ACTIVE_WORKFLOW_STATUSES = new Set<RankCheckWorkflowStatus["status"]>([
|
const ACTIVE_WORKFLOW_STATUSES = new Set<RankCheckWorkflowStatus["status"]>([
|
||||||
@ -122,6 +122,7 @@ export async function beginRankCheckRun(input: {
|
|||||||
locationCode: input.config.locationCode,
|
locationCode: input.config.locationCode,
|
||||||
languageCode: input.config.languageCode,
|
languageCode: input.config.languageCode,
|
||||||
devices: input.config.devices,
|
devices: input.config.devices,
|
||||||
|
serpDepth: input.config.serpDepth,
|
||||||
trigger: input.trigger,
|
trigger: input.trigger,
|
||||||
keywordIds: input.keywordIds,
|
keywordIds: input.keywordIds,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -7,10 +7,11 @@ import type {
|
|||||||
} from "@/types/schemas/rank-tracking";
|
} from "@/types/schemas/rank-tracking";
|
||||||
|
|
||||||
type SnapshotRow = Awaited<
|
type SnapshotRow = Awaited<
|
||||||
ReturnType<typeof RankTrackingRepository.getSnapshotsForRun>
|
ReturnType<typeof RankTrackingRepository.getLatestSnapshotsForKeywords>
|
||||||
>[0];
|
>[0];
|
||||||
|
|
||||||
const PERIOD_DAYS: Record<Exclude<ComparePeriod, "previous">, number> = {
|
const PERIOD_DAYS: Record<ComparePeriod, number> = {
|
||||||
|
"1d": 1,
|
||||||
"7d": 7,
|
"7d": 7,
|
||||||
"30d": 30,
|
"30d": 30,
|
||||||
"90d": 90,
|
"90d": 90,
|
||||||
@ -19,10 +20,10 @@ const PERIOD_DAYS: Record<Exclude<ComparePeriod, "previous">, number> = {
|
|||||||
export async function getLatestResults(
|
export async function getLatestResults(
|
||||||
configId: string,
|
configId: string,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
comparePeriod: ComparePeriod = "previous",
|
comparePeriod: ComparePeriod = "7d",
|
||||||
): Promise<{
|
): Promise<{
|
||||||
rows: RankTrackingRow[];
|
rows: RankTrackingRow[];
|
||||||
run: { id: string; startedAt: string } | null;
|
run: { id: string; lastCheckedAt: string } | null;
|
||||||
}> {
|
}> {
|
||||||
const config = await RankTrackingRepository.getConfigById({
|
const config = await RankTrackingRepository.getConfigById({
|
||||||
configId,
|
configId,
|
||||||
@ -32,54 +33,56 @@ export async function getLatestResults(
|
|||||||
throw new AppError("INTERNAL_ERROR", "Rank tracking config not found");
|
throw new AppError("INTERNAL_ERROR", "Rank tracking config not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
const recentRuns = await RankTrackingRepository.getRecentCompletedRuns(
|
const activeKeywords =
|
||||||
configId,
|
await RankTrackingRepository.getKeywordsForConfig(configId);
|
||||||
2,
|
|
||||||
);
|
|
||||||
const currentRun = recentRuns[0];
|
|
||||||
if (!currentRun) {
|
|
||||||
return { rows: [], run: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentSnapshots = await RankTrackingRepository.getSnapshotsForRun(
|
// Get the latest snapshot per keyword per device (across all completed runs)
|
||||||
currentRun.id,
|
const currentSnapshots =
|
||||||
);
|
await RankTrackingRepository.getLatestSnapshotsForKeywords(configId);
|
||||||
|
|
||||||
// Load comparison run's snapshots for delta computation
|
// Get comparison snapshots from before the target date
|
||||||
const previousPositions = new Map<string, number | null>();
|
|
||||||
let comparisonRun: typeof currentRun | null = null;
|
|
||||||
|
|
||||||
if (comparePeriod === "previous") {
|
|
||||||
comparisonRun = recentRuns[1] ?? null;
|
|
||||||
} else {
|
|
||||||
const days = PERIOD_DAYS[comparePeriod];
|
const days = PERIOD_DAYS[comparePeriod];
|
||||||
const targetDate = new Date(
|
const targetDate = new Date(
|
||||||
Date.now() - days * 24 * 60 * 60 * 1000,
|
Date.now() - days * 24 * 60 * 60 * 1000,
|
||||||
).toISOString();
|
).toISOString();
|
||||||
const closest = await RankTrackingRepository.getClosestCompletedRun(
|
|
||||||
configId,
|
|
||||||
targetDate,
|
|
||||||
);
|
|
||||||
// Don't compare a run against itself
|
|
||||||
if (closest && closest.id !== currentRun.id) {
|
|
||||||
comparisonRun = closest;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (comparisonRun) {
|
const comparisonSnapshots =
|
||||||
const prevSnapshots = await RankTrackingRepository.getSnapshotsForRun(
|
await RankTrackingRepository.getSnapshotsBeforeDate(configId, targetDate);
|
||||||
comparisonRun.id,
|
|
||||||
);
|
const previousPositions = new Map<string, number | null>();
|
||||||
for (const snap of prevSnapshots) {
|
for (const snap of comparisonSnapshots) {
|
||||||
previousPositions.set(
|
previousPositions.set(
|
||||||
`${snap.trackingKeywordId}:${snap.device}`,
|
`${snap.trackingKeywordId}:${snap.device}`,
|
||||||
snap.position,
|
snap.position,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fallback: for keyword+device combos with no comparison snapshot before
|
||||||
|
// the target date, use the earliest available snapshot as a baseline.
|
||||||
|
const missingKeywordIds: string[] = [];
|
||||||
|
for (const snap of currentSnapshots) {
|
||||||
|
const key = `${snap.trackingKeywordId}:${snap.device}`;
|
||||||
|
if (!previousPositions.has(key)) {
|
||||||
|
missingKeywordIds.push(snap.trackingKeywordId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeKeywords =
|
if (missingKeywordIds.length > 0) {
|
||||||
await RankTrackingRepository.getKeywordsForConfig(configId);
|
const uniqueMissingIds = [...new Set(missingKeywordIds)];
|
||||||
|
const earliestSnapshots =
|
||||||
|
await RankTrackingRepository.getEarliestSnapshotsForKeywords(
|
||||||
|
configId,
|
||||||
|
uniqueMissingIds,
|
||||||
|
);
|
||||||
|
for (const snap of earliestSnapshots) {
|
||||||
|
const key = `${snap.trackingKeywordId}:${snap.device}`;
|
||||||
|
if (!previousPositions.has(key)) {
|
||||||
|
previousPositions.set(key, snap.position);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build result rows
|
||||||
const rows = new Map<string, RankTrackingRow>(
|
const rows = new Map<string, RankTrackingRow>(
|
||||||
activeKeywords.map((keyword) => [
|
activeKeywords.map((keyword) => [
|
||||||
keyword.id,
|
keyword.id,
|
||||||
@ -96,6 +99,10 @@ export async function getLatestResults(
|
|||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Determine the most recent snapshot time for the run info
|
||||||
|
let latestRunId: string | null = null;
|
||||||
|
let latestStartedAt: string | null = null;
|
||||||
|
|
||||||
for (const snapshot of currentSnapshots) {
|
for (const snapshot of currentSnapshots) {
|
||||||
const row = rows.get(snapshot.trackingKeywordId);
|
const row = rows.get(snapshot.trackingKeywordId);
|
||||||
if (!row) continue;
|
if (!row) continue;
|
||||||
@ -105,16 +112,22 @@ export async function getLatestResults(
|
|||||||
`${snapshot.trackingKeywordId}:${snapshot.device}`,
|
`${snapshot.trackingKeywordId}:${snapshot.device}`,
|
||||||
) ?? null,
|
) ?? null,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Track the most recent run for the header display
|
||||||
|
if (!latestStartedAt || snapshot.checkedAt > latestStartedAt) {
|
||||||
|
latestRunId = snapshot.runId;
|
||||||
|
latestStartedAt = snapshot.checkedAt;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rows: activeKeywords
|
rows: activeKeywords
|
||||||
.map((keyword) => rows.get(keyword.id))
|
.map((keyword) => rows.get(keyword.id))
|
||||||
.filter((row): row is RankTrackingRow => row != null),
|
.filter((row): row is RankTrackingRow => row != null),
|
||||||
run: {
|
run:
|
||||||
id: currentRun.id,
|
latestRunId && latestStartedAt
|
||||||
startedAt: currentRun.startedAt,
|
? { id: latestRunId, lastCheckedAt: latestStartedAt }
|
||||||
},
|
: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -402,7 +402,9 @@ export async function fetchRankCheckSerpRaw(input: {
|
|||||||
languageCode: string;
|
languageCode: string;
|
||||||
device: "desktop" | "mobile";
|
device: "desktop" | "mobile";
|
||||||
targetDomain: string;
|
targetDomain: string;
|
||||||
|
depth: number;
|
||||||
}): Promise<DataforseoApiResponse<RankCheckResult>> {
|
}): Promise<DataforseoApiResponse<RankCheckResult>> {
|
||||||
|
const depth = Math.min(100, Math.max(10, input.depth));
|
||||||
const responseRaw = await postDataforseo(
|
const responseRaw = await postDataforseo(
|
||||||
"/v3/serp/google/organic/live/advanced",
|
"/v3/serp/google/organic/live/advanced",
|
||||||
[
|
[
|
||||||
@ -412,8 +414,7 @@ export async function fetchRankCheckSerpRaw(input: {
|
|||||||
language_code: input.languageCode,
|
language_code: input.languageCode,
|
||||||
device: input.device,
|
device: input.device,
|
||||||
os: input.device === "desktop" ? "windows" : "android",
|
os: input.device === "desktop" ? "windows" : "android",
|
||||||
depth: 20,
|
depth,
|
||||||
target: input.targetDomain,
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@ -207,6 +207,7 @@ export function createDataforseoClient(customer: BillingCustomerContext) {
|
|||||||
languageCode: string;
|
languageCode: string;
|
||||||
device: "desktop" | "mobile";
|
device: "desktop" | "mobile";
|
||||||
targetDomain: string;
|
targetDomain: string;
|
||||||
|
depth: number;
|
||||||
}) {
|
}) {
|
||||||
return meterDataforseoCall(
|
return meterDataforseoCall(
|
||||||
customer,
|
customer,
|
||||||
|
|||||||
@ -37,6 +37,7 @@ interface RankCheckParams {
|
|||||||
locationCode: number;
|
locationCode: number;
|
||||||
languageCode: string;
|
languageCode: string;
|
||||||
devices: "both" | "desktop" | "mobile";
|
devices: "both" | "desktop" | "mobile";
|
||||||
|
serpDepth: number;
|
||||||
trigger: "manual" | "scheduled";
|
trigger: "manual" | "scheduled";
|
||||||
keywordIds?: string[];
|
keywordIds?: string[];
|
||||||
}
|
}
|
||||||
@ -46,6 +47,7 @@ async function prepareRankCheckKeywords(input: {
|
|||||||
configId: string;
|
configId: string;
|
||||||
billingCustomer: BillingCustomerContext;
|
billingCustomer: BillingCustomerContext;
|
||||||
devices: RankCheckParams["devices"];
|
devices: RankCheckParams["devices"];
|
||||||
|
serpDepth: number;
|
||||||
keywordIds?: string[];
|
keywordIds?: string[];
|
||||||
}) {
|
}) {
|
||||||
const ownsLock = await runOwnsRankCheckLock(input.configId, input.runId);
|
const ownsLock = await runOwnsRankCheckLock(input.configId, input.runId);
|
||||||
@ -77,6 +79,7 @@ async function prepareRankCheckKeywords(input: {
|
|||||||
const { costCredits } = estimateRankCheckCredits(
|
const { costCredits } = estimateRankCheckCredits(
|
||||||
trackingKeywords.length,
|
trackingKeywords.length,
|
||||||
input.devices,
|
input.devices,
|
||||||
|
input.serpDepth,
|
||||||
);
|
);
|
||||||
const [monthlyCheck, topupCheck] = await Promise.all([
|
const [monthlyCheck, topupCheck] = await Promise.all([
|
||||||
autumn.check({
|
autumn.check({
|
||||||
@ -233,12 +236,34 @@ export class RankCheckWorkflow extends WorkflowEntrypoint<
|
|||||||
locationCode,
|
locationCode,
|
||||||
languageCode,
|
languageCode,
|
||||||
devices,
|
devices,
|
||||||
|
serpDepth,
|
||||||
trigger,
|
trigger,
|
||||||
keywordIds,
|
keywordIds,
|
||||||
} = event.payload;
|
} = event.payload;
|
||||||
|
|
||||||
const client = createDataforseoClient(billingCustomer);
|
const client = createDataforseoClient(billingCustomer);
|
||||||
|
|
||||||
|
// Guard: skip if config was archived after the workflow was triggered
|
||||||
|
const configCheck = await step.do(
|
||||||
|
"check-active",
|
||||||
|
{ retries: { limit: 0, delay: "1 second" } },
|
||||||
|
async () => {
|
||||||
|
const cfg = await RankTrackingRepository.getConfigById({
|
||||||
|
configId,
|
||||||
|
projectId,
|
||||||
|
});
|
||||||
|
return { isActive: cfg?.isActive ?? false };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!configCheck.isActive) {
|
||||||
|
await failRunAndReleaseRankCheckLock(
|
||||||
|
configId,
|
||||||
|
runId,
|
||||||
|
"Config has been archived",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log(
|
console.log(
|
||||||
`[rank-check] ${runId} starting (trigger=${trigger}, devices=${devices})`,
|
`[rank-check] ${runId} starting (trigger=${trigger}, devices=${devices})`,
|
||||||
@ -253,6 +278,7 @@ export class RankCheckWorkflow extends WorkflowEntrypoint<
|
|||||||
configId,
|
configId,
|
||||||
billingCustomer,
|
billingCustomer,
|
||||||
devices,
|
devices,
|
||||||
|
serpDepth,
|
||||||
keywordIds,
|
keywordIds,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@ -268,6 +294,7 @@ export class RankCheckWorkflow extends WorkflowEntrypoint<
|
|||||||
client,
|
client,
|
||||||
keywords,
|
keywords,
|
||||||
devices,
|
devices,
|
||||||
|
serpDepth,
|
||||||
domain,
|
domain,
|
||||||
locationCode,
|
locationCode,
|
||||||
languageCode,
|
languageCode,
|
||||||
|
|||||||
@ -35,6 +35,7 @@ interface CheckContext {
|
|||||||
client: ReturnType<typeof createDataforseoClient>;
|
client: ReturnType<typeof createDataforseoClient>;
|
||||||
keywords: KeywordEntry[];
|
keywords: KeywordEntry[];
|
||||||
devices: RankTrackingConfig["devices"];
|
devices: RankTrackingConfig["devices"];
|
||||||
|
serpDepth: number;
|
||||||
domain: string;
|
domain: string;
|
||||||
locationCode: number;
|
locationCode: number;
|
||||||
languageCode: string;
|
languageCode: string;
|
||||||
@ -74,6 +75,7 @@ export async function runLiveCheck(
|
|||||||
languageCode: ctx.languageCode,
|
languageCode: ctx.languageCode,
|
||||||
device,
|
device,
|
||||||
targetDomain: ctx.domain,
|
targetDomain: ctx.domain,
|
||||||
|
depth: ctx.serpDepth,
|
||||||
})
|
})
|
||||||
.then((r) => ({ ...r, device })),
|
.then((r) => ({ ...r, device })),
|
||||||
),
|
),
|
||||||
@ -93,6 +95,7 @@ export async function runLiveCheck(
|
|||||||
await RankTrackingRepository.updateRun(ctx.runId, {
|
await RankTrackingRepository.updateRun(ctx.runId, {
|
||||||
keywordsChecked: checked,
|
keywordsChecked: checked,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (results.length > 0) {
|
if (results.length > 0) {
|
||||||
await RankTrackingRepository.insertSnapshots(
|
await RankTrackingRepository.insertSnapshots(
|
||||||
mapResultsToSnapshotRows(ctx.runId, results),
|
mapResultsToSnapshotRows(ctx.runId, results),
|
||||||
|
|||||||
@ -1,6 +1,9 @@
|
|||||||
import { createServerFn } from "@tanstack/react-start";
|
import { createServerFn } from "@tanstack/react-start";
|
||||||
import { requireProjectContext } from "@/serverFunctions/middleware";
|
import { requireProjectContext } from "@/serverFunctions/middleware";
|
||||||
import { domainOverviewSchema } from "@/types/schemas/domain";
|
import {
|
||||||
|
domainOverviewSchema,
|
||||||
|
domainKeywordSuggestionsSchema,
|
||||||
|
} from "@/types/schemas/domain";
|
||||||
import { DomainService } from "@/server/features/domain/services/DomainService";
|
import { DomainService } from "@/server/features/domain/services/DomainService";
|
||||||
|
|
||||||
export const getDomainOverview = createServerFn({ method: "POST" })
|
export const getDomainOverview = createServerFn({ method: "POST" })
|
||||||
@ -15,3 +18,17 @@ export const getDomainOverview = createServerFn({ method: "POST" })
|
|||||||
context,
|
context,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const getDomainKeywordSuggestions = createServerFn({ method: "POST" })
|
||||||
|
.middleware(requireProjectContext)
|
||||||
|
.inputValidator((data: unknown) => domainKeywordSuggestionsSchema.parse(data))
|
||||||
|
.handler(async ({ data, context }) =>
|
||||||
|
DomainService.getSuggestedKeywords(
|
||||||
|
{
|
||||||
|
...data,
|
||||||
|
organizationId: context.organizationId,
|
||||||
|
projectId: context.projectId,
|
||||||
|
},
|
||||||
|
context,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { waitUntil } from "cloudflare:workers";
|
|||||||
import { RankTrackingRepository } from "@/server/features/rank-tracking/repositories/RankTrackingRepository";
|
import { RankTrackingRepository } from "@/server/features/rank-tracking/repositories/RankTrackingRepository";
|
||||||
import { RankTrackingService } from "@/server/features/rank-tracking/services/RankTrackingService";
|
import { RankTrackingService } from "@/server/features/rank-tracking/services/RankTrackingService";
|
||||||
import { getLatestResults } from "@/server/features/rank-tracking/services/rankTrackingResults";
|
import { getLatestResults } from "@/server/features/rank-tracking/services/rankTrackingResults";
|
||||||
|
import { asAppError } from "@/server/lib/errors";
|
||||||
import { captureServerEvent } from "@/server/lib/posthog";
|
import { captureServerEvent } from "@/server/lib/posthog";
|
||||||
import { requireProjectContext } from "@/serverFunctions/middleware";
|
import { requireProjectContext } from "@/serverFunctions/middleware";
|
||||||
import {
|
import {
|
||||||
@ -41,6 +42,7 @@ export const createRankTrackingConfig = createServerFn({ method: "POST" })
|
|||||||
locationCode: data.locationCode,
|
locationCode: data.locationCode,
|
||||||
languageCode: data.languageCode,
|
languageCode: data.languageCode,
|
||||||
devices: data.devices,
|
devices: data.devices,
|
||||||
|
serpDepth: data.serpDepth,
|
||||||
scheduleInterval: data.scheduleInterval,
|
scheduleInterval: data.scheduleInterval,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -70,6 +72,7 @@ export const updateRankTrackingConfig = createServerFn({ method: "POST" })
|
|||||||
locationCode: data.locationCode,
|
locationCode: data.locationCode,
|
||||||
languageCode: data.languageCode,
|
languageCode: data.languageCode,
|
||||||
devices: data.devices,
|
devices: data.devices,
|
||||||
|
serpDepth: data.serpDepth,
|
||||||
scheduleInterval: data.scheduleInterval,
|
scheduleInterval: data.scheduleInterval,
|
||||||
isActive: data.isActive,
|
isActive: data.isActive,
|
||||||
});
|
});
|
||||||
@ -134,11 +137,44 @@ export const addTrackingKeywords = createServerFn({ method: "POST" })
|
|||||||
.middleware(requireProjectContext)
|
.middleware(requireProjectContext)
|
||||||
.inputValidator((data: unknown) => addKeywordsSchema.parse(data))
|
.inputValidator((data: unknown) => addKeywordsSchema.parse(data))
|
||||||
.handler(async ({ data, context }) => {
|
.handler(async ({ data, context }) => {
|
||||||
return RankTrackingService.addKeywords(
|
const result = await RankTrackingService.addKeywords(
|
||||||
data.configId,
|
data.configId,
|
||||||
context.projectId,
|
context.projectId,
|
||||||
data.keywords,
|
data.keywords,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let checkTriggered = false;
|
||||||
|
if (result.addedIds.length > 0) {
|
||||||
|
try {
|
||||||
|
const triggerResult = await RankTrackingService.triggerCheck({
|
||||||
|
configId: data.configId,
|
||||||
|
projectId: context.projectId,
|
||||||
|
billingCustomer: context,
|
||||||
|
keywordIds: result.addedIds,
|
||||||
|
});
|
||||||
|
checkTriggered = triggerResult.ok;
|
||||||
|
if (!triggerResult.ok) {
|
||||||
|
console.info(
|
||||||
|
"[rank-tracking] auto-check skipped: %s",
|
||||||
|
triggerResult.reason,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const appErr = asAppError(err);
|
||||||
|
if (appErr?.code === "INSUFFICIENT_CREDITS") {
|
||||||
|
console.info(
|
||||||
|
"[rank-tracking] auto-check skipped: insufficient credits",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.error(
|
||||||
|
"[rank-tracking] auto-check after keyword add failed:",
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...result, checkTriggered };
|
||||||
});
|
});
|
||||||
|
|
||||||
export const removeTrackingKeywords = createServerFn({ method: "POST" })
|
export const removeTrackingKeywords = createServerFn({ method: "POST" })
|
||||||
|
|||||||
@ -9,8 +9,11 @@ import type { RankTrackingConfig } from "@/types/schemas/rank-tracking";
|
|||||||
// Cost constants
|
// Cost constants
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/** Per-SERP cost from DataForSEO Live API */
|
/** DataForSEO Live API: cost of first page (10 results) */
|
||||||
const COST_PER_SERP_USD = 0.002;
|
const BASE_PAGE_COST_USD = 0.002;
|
||||||
|
|
||||||
|
/** DataForSEO Live API: cost of each additional page (75% of base) */
|
||||||
|
const EXTRA_PAGE_COST_USD = 0.0015;
|
||||||
|
|
||||||
/** How many keywords are checked per batch */
|
/** How many keywords are checked per batch */
|
||||||
export const KEYWORDS_PER_BATCH = 10;
|
export const KEYWORDS_PER_BATCH = 10;
|
||||||
@ -28,13 +31,28 @@ export const MAX_CONFIGS_PER_PROJECT = 20;
|
|||||||
// Cost estimation
|
// Cost estimation
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** DataForSEO cost for a single SERP request at the given depth. */
|
||||||
|
function costPerSerpAtDepth(depth: number): number {
|
||||||
|
const pages = depth / 10;
|
||||||
|
return BASE_PAGE_COST_USD + (pages - 1) * EXTRA_PAGE_COST_USD;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function depthToPages(depth: number): number {
|
||||||
|
return depth / 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pagesToDepth(pages: number): number {
|
||||||
|
return pages * 10;
|
||||||
|
}
|
||||||
|
|
||||||
export function estimateRankCheckCredits(
|
export function estimateRankCheckCredits(
|
||||||
keywordCount: number,
|
keywordCount: number,
|
||||||
devices: RankTrackingConfig["devices"],
|
devices: RankTrackingConfig["devices"],
|
||||||
|
depth: number,
|
||||||
) {
|
) {
|
||||||
const totalChecks = keywordCount * devicesCount(devices);
|
const totalChecks = keywordCount * devicesCount(devices);
|
||||||
const costUsd = roundUsdForBilling(
|
const costUsd = roundUsdForBilling(
|
||||||
totalChecks * COST_PER_SERP_USD * SEO_DATA_COST_MARKUP,
|
totalChecks * costPerSerpAtDepth(depth) * SEO_DATA_COST_MARKUP,
|
||||||
);
|
);
|
||||||
const costCredits = Math.ceil(costUsd * AUTUMN_SEO_DATA_CREDITS_PER_USD);
|
const costCredits = Math.ceil(costUsd * AUTUMN_SEO_DATA_CREDITS_PER_USD);
|
||||||
return { costUsd, costCredits };
|
return { costUsd, costCredits };
|
||||||
|
|||||||
@ -1,11 +1,14 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const DOMAIN_REGEX =
|
||||||
|
/^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*\.[a-zA-Z]{2,}$/;
|
||||||
|
|
||||||
const booleanSearchParamSchema = z
|
const booleanSearchParamSchema = z
|
||||||
.union([z.boolean(), z.enum(["true", "false"])])
|
.union([z.boolean(), z.enum(["true", "false"])])
|
||||||
.transform((value) => value === true || value === "true");
|
.transform((value) => value === true || value === "true");
|
||||||
|
|
||||||
export const domainOverviewSchema = z.object({
|
export const domainOverviewSchema = z.object({
|
||||||
projectId: z.string().min(1),
|
projectId: z.string().uuid(),
|
||||||
domain: z.string().min(1, "Domain is required").max(255),
|
domain: z.string().min(1, "Domain is required").max(255),
|
||||||
includeSubdomains: z.boolean().default(true),
|
includeSubdomains: z.boolean().default(true),
|
||||||
locationCode: z.number().int().positive().default(2840),
|
locationCode: z.number().int().positive().default(2840),
|
||||||
@ -20,6 +23,17 @@ const domainSortModes = ["rank", "traffic", "volume", "score", "cpc"] as const;
|
|||||||
const domainSortOrders = ["asc", "desc"] as const;
|
const domainSortOrders = ["asc", "desc"] as const;
|
||||||
const domainTabs = ["keywords", "pages"] as const;
|
const domainTabs = ["keywords", "pages"] as const;
|
||||||
|
|
||||||
|
export const domainKeywordSuggestionsSchema = z.object({
|
||||||
|
projectId: z.string().uuid(),
|
||||||
|
domain: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(253)
|
||||||
|
.regex(DOMAIN_REGEX, "Invalid domain format"),
|
||||||
|
locationCode: z.number().int().positive(),
|
||||||
|
languageCode: z.string().min(2).max(8),
|
||||||
|
});
|
||||||
|
|
||||||
export const domainSearchSchema = z.object({
|
export const domainSearchSchema = z.object({
|
||||||
domain: z.string().optional(),
|
domain: z.string().optional(),
|
||||||
subdomains: booleanSearchParamSchema.optional(),
|
subdomains: booleanSearchParamSchema.optional(),
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import type { InferSelectModel } from "drizzle-orm";
|
import type { InferSelectModel } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { rankTrackingConfigs } from "@/db/app.schema";
|
import { rankTrackingConfigs } from "@/db/app.schema";
|
||||||
|
import { DOMAIN_REGEX } from "@/types/schemas/domain";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// DB-derived types
|
// DB-derived types
|
||||||
@ -53,13 +54,11 @@ export const createConfigSchema = z.object({
|
|||||||
.string()
|
.string()
|
||||||
.min(1)
|
.min(1)
|
||||||
.max(253)
|
.max(253)
|
||||||
.regex(
|
.regex(DOMAIN_REGEX, "Invalid domain format"),
|
||||||
/^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*\.[a-zA-Z]{2,}$/,
|
|
||||||
"Invalid domain format",
|
|
||||||
),
|
|
||||||
locationCode: z.number().int().positive().optional(),
|
locationCode: z.number().int().positive().optional(),
|
||||||
languageCode: z.string().max(10).optional(),
|
languageCode: z.string().max(10).optional(),
|
||||||
devices: devicesEnum.optional(),
|
devices: devicesEnum.optional(),
|
||||||
|
serpDepth: z.number().int().min(10).max(100).multipleOf(10),
|
||||||
scheduleInterval: scheduleEnum.optional(),
|
scheduleInterval: scheduleEnum.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -70,14 +69,12 @@ export const updateConfigSchema = z.object({
|
|||||||
.string()
|
.string()
|
||||||
.min(1)
|
.min(1)
|
||||||
.max(253)
|
.max(253)
|
||||||
.regex(
|
.regex(DOMAIN_REGEX, "Invalid domain format")
|
||||||
/^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*\.[a-zA-Z]{2,}$/,
|
|
||||||
"Invalid domain format",
|
|
||||||
)
|
|
||||||
.optional(),
|
.optional(),
|
||||||
locationCode: z.number().int().positive().optional(),
|
locationCode: z.number().int().positive().optional(),
|
||||||
languageCode: z.string().max(10).optional(),
|
languageCode: z.string().max(10).optional(),
|
||||||
devices: devicesEnum.optional(),
|
devices: devicesEnum.optional(),
|
||||||
|
serpDepth: z.number().int().min(10).max(100).multipleOf(10).optional(),
|
||||||
scheduleInterval: scheduleEnum.optional(),
|
scheduleInterval: scheduleEnum.optional(),
|
||||||
isActive: z.boolean().optional(),
|
isActive: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
@ -88,7 +85,7 @@ export const triggerCheckSchema = z.object({
|
|||||||
keywordIds: z.array(z.string().uuid()).max(2000).optional(),
|
keywordIds: z.array(z.string().uuid()).max(2000).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const comparePeriodSchema = z.enum(["previous", "7d", "30d", "90d"]);
|
export const comparePeriodSchema = z.enum(["1d", "7d", "30d", "90d"]);
|
||||||
export type ComparePeriod = z.infer<typeof comparePeriodSchema>;
|
export type ComparePeriod = z.infer<typeof comparePeriodSchema>;
|
||||||
|
|
||||||
export const getLatestResultsSchema = z.object({
|
export const getLatestResultsSchema = z.object({
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user