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,
|
||||
"tag": "0007_sour_risque",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "6",
|
||||
"when": 1776288697036,
|
||||
"tag": "0008_luxuriant_colossus",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -104,6 +104,7 @@ async function main() {
|
||||
locationCode: 2840,
|
||||
languageCode: "en",
|
||||
devices: "mobile",
|
||||
serpDepth: 20,
|
||||
scheduleInterval: "weekly",
|
||||
isActive: true,
|
||||
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;
|
||||
projectId: string;
|
||||
onSuccess: (result: { added: number; addedIds?: string[] }) => void;
|
||||
onSuccess: (result: {
|
||||
added: number;
|
||||
addedIds: string[];
|
||||
checkTriggered: boolean;
|
||||
}) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [keywordInput, setKeywordInput] = useState("");
|
||||
@ -30,8 +34,6 @@ export function AddKeywordsPanel({
|
||||
});
|
||||
const isPending = mutation.isPending;
|
||||
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">
|
||||
<textarea
|
||||
className="textarea textarea-bordered textarea-sm flex-1"
|
||||
@ -60,7 +62,5 @@ export function AddKeywordsPanel({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -11,17 +11,23 @@ import {
|
||||
export function CheckConfirmModal({
|
||||
keywordCount,
|
||||
devices,
|
||||
serpDepth,
|
||||
isPending,
|
||||
onRunNow,
|
||||
onCancel,
|
||||
}: {
|
||||
keywordCount: number;
|
||||
devices: RankTrackingConfig["devices"];
|
||||
serpDepth: number;
|
||||
isPending: boolean;
|
||||
onRunNow: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const { costUsd } = estimateRankCheckCredits(keywordCount, devices);
|
||||
const { costUsd } = estimateRankCheckCredits(
|
||||
keywordCount,
|
||||
devices,
|
||||
serpDepth,
|
||||
);
|
||||
const dc = devicesCount(devices);
|
||||
const totalChecks = keywordCount * dc;
|
||||
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 type { ColumnDef, SortingFn } from "@tanstack/react-table";
|
||||
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> = {
|
||||
keyword: "The search term being tracked",
|
||||
desktopPosition: "Google ranking details on desktop devices",
|
||||
mobilePosition: "Google ranking details on mobile devices",
|
||||
keyword: "The search term being tracked in Google",
|
||||
desktopPosition:
|
||||
"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,
|
||||
label,
|
||||
id,
|
||||
tooltip,
|
||||
}: {
|
||||
column: {
|
||||
getIsSorted: () => false | "asc" | "desc";
|
||||
@ -21,6 +31,7 @@ function SortableHeader({
|
||||
};
|
||||
label: string;
|
||||
id: string;
|
||||
tooltip?: string;
|
||||
}) {
|
||||
const sorted = column.getIsSorted();
|
||||
return (
|
||||
@ -28,7 +39,7 @@ function SortableHeader({
|
||||
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"
|
||||
onClick={column.getToggleSortingHandler()}
|
||||
title={HEADER_TOOLTIPS[id]}
|
||||
title={tooltip ?? HEADER_TOOLTIPS[id]}
|
||||
aria-label={`Sort by ${label}`}
|
||||
aria-pressed={!!sorted}
|
||||
>
|
||||
@ -86,25 +97,65 @@ const keywordColumn: ColumnDef<RankTrackingRow> = {
|
||||
|
||||
function makeDeviceColumn(
|
||||
device: "desktop" | "mobile",
|
||||
domain: string,
|
||||
): ColumnDef<RankTrackingRow> {
|
||||
const id = device === "desktop" ? "desktopPosition" : "mobilePosition";
|
||||
const label = device === "desktop" ? "Desktop" : "Mobile";
|
||||
return {
|
||||
id,
|
||||
accessorFn: (row) => row[device].position,
|
||||
header: ({ column }) => (
|
||||
<SortableHeader column={column} label={label} id={id} />
|
||||
),
|
||||
size: 176,
|
||||
minSize: 176,
|
||||
cell: ({ row }) => (
|
||||
<DeviceRankCell result={row.original[device]} domain={domain} />
|
||||
<SortableHeader column={column} label="Position" id={id} />
|
||||
),
|
||||
size: 120,
|
||||
maxSize: 140,
|
||||
cell: ({ row }) => <DeviceRankCell result={row.original[device]} />,
|
||||
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(
|
||||
showDesktop: boolean,
|
||||
showMobile: boolean,
|
||||
@ -112,8 +163,16 @@ export function useRankTrackingColumns(
|
||||
): ColumnDef<RankTrackingRow>[] {
|
||||
return useMemo(() => {
|
||||
const cols: ColumnDef<RankTrackingRow>[] = [selectColumn, keywordColumn];
|
||||
if (showDesktop) cols.push(makeDeviceColumn("desktop", domain));
|
||||
if (showMobile) cols.push(makeDeviceColumn("mobile", domain));
|
||||
if (showDesktop) {
|
||||
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;
|
||||
}, [showDesktop, showMobile, domain]);
|
||||
}
|
||||
|
||||
@ -5,22 +5,29 @@ import {
|
||||
createRankTrackingConfig,
|
||||
updateRankTrackingConfig,
|
||||
} from "@/serverFunctions/rank-tracking";
|
||||
import { Loader2, X } from "lucide-react";
|
||||
import { Info, Loader2, X } from "lucide-react";
|
||||
import { Modal } from "@/client/components/Modal";
|
||||
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||
import { captureClientEvent } from "@/client/lib/posthog";
|
||||
import type { RankTrackingConfig } from "@/types/schemas/rank-tracking";
|
||||
import {
|
||||
depthToPages,
|
||||
pagesToDepth,
|
||||
estimateRankCheckCredits,
|
||||
} from "@/shared/rank-tracking";
|
||||
import {
|
||||
LOCATION_OPTIONS,
|
||||
DEFAULT_LOCATION_CODE,
|
||||
getLanguageCode,
|
||||
} from "@/client/features/keywords/locations";
|
||||
import { KeywordSuggestionStep } from "./KeywordSuggestionStep";
|
||||
|
||||
type Props = {
|
||||
projectId: string;
|
||||
existingConfig?: RankTrackingConfig | null;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
onSaved: (createdConfigId?: string) => void;
|
||||
onConfigCreated?: () => void;
|
||||
};
|
||||
|
||||
export function RankTrackingConfigModal({
|
||||
@ -28,18 +35,22 @@ export function RankTrackingConfigModal({
|
||||
existingConfig,
|
||||
onClose,
|
||||
onSaved,
|
||||
onConfigCreated,
|
||||
}: Props) {
|
||||
const isEdit = !!existingConfig;
|
||||
const [step, setStep] = useState<"config" | "keywords">("config");
|
||||
const [domain, setDomain] = useState(existingConfig?.domain ?? "");
|
||||
const [devices, setDevices] = useState<"both" | "desktop" | "mobile">(
|
||||
existingConfig?.devices ?? "mobile",
|
||||
existingConfig?.devices ?? "both",
|
||||
);
|
||||
const [locationCode, setLocationCode] = useState(
|
||||
existingConfig?.locationCode ?? DEFAULT_LOCATION_CODE,
|
||||
);
|
||||
const [serpDepth, setSerpDepth] = useState(existingConfig?.serpDepth ?? 20);
|
||||
const [schedule, setSchedule] = useState<"daily" | "weekly" | "manual">(
|
||||
existingConfig?.scheduleInterval ?? "weekly",
|
||||
);
|
||||
const [createdConfigId, setCreatedConfigId] = useState<string | null>(null);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
@ -48,15 +59,18 @@ export function RankTrackingConfigModal({
|
||||
projectId,
|
||||
domain,
|
||||
devices,
|
||||
serpDepth,
|
||||
locationCode,
|
||||
languageCode: getLanguageCode(locationCode),
|
||||
scheduleInterval: schedule,
|
||||
},
|
||||
}),
|
||||
onSuccess: () => {
|
||||
onSuccess: (result) => {
|
||||
captureClientEvent("rank_tracking:config_create");
|
||||
toast.success("Domain added for rank tracking");
|
||||
onSaved();
|
||||
setCreatedConfigId(result.configId);
|
||||
onConfigCreated?.();
|
||||
setStep("keywords");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(getStandardErrorMessage(error, "Failed to save config"));
|
||||
@ -71,6 +85,7 @@ export function RankTrackingConfigModal({
|
||||
configId: existingConfig!.id,
|
||||
domain,
|
||||
devices,
|
||||
serpDepth,
|
||||
locationCode,
|
||||
languageCode: getLanguageCode(locationCode),
|
||||
scheduleInterval: schedule,
|
||||
@ -102,8 +117,24 @@ export function RankTrackingConfigModal({
|
||||
|
||||
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
if (step === "keywords" && createdConfigId) {
|
||||
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">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{isEdit ? "Edit Domain Config" : "Add Domain"}
|
||||
@ -166,6 +197,18 @@ export function RankTrackingConfigModal({
|
||||
<option value="desktop">Desktop only</option>
|
||||
<option value="mobile">Mobile only</option>
|
||||
</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 className="form-control">
|
||||
@ -190,12 +233,62 @@ export function RankTrackingConfigModal({
|
||||
<option value="weekly">Weekly</option>
|
||||
<option value="manual">Manual only</option>
|
||||
</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>
|
||||
|
||||
<p className="text-xs text-base-content/60">
|
||||
After adding a domain, manage tracked keywords from the domain detail
|
||||
view.
|
||||
</p>
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-medium">Search Depth</span>
|
||||
</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">
|
||||
<button
|
||||
|
||||
@ -9,9 +9,11 @@ import {
|
||||
AlertTriangle,
|
||||
ArrowLeft,
|
||||
Loader2,
|
||||
Monitor,
|
||||
Plus,
|
||||
Settings,
|
||||
SlidersHorizontal,
|
||||
Smartphone,
|
||||
} from "lucide-react";
|
||||
import { captureClientEvent } from "@/client/lib/posthog";
|
||||
import { RankTrackingTable } from "./RankTrackingTable";
|
||||
@ -32,11 +34,12 @@ import {
|
||||
type Filters,
|
||||
} from "./RankTrackingFilters";
|
||||
import { CheckConfirmModal } from "./CheckConfirmModal";
|
||||
import { SegmentedToggle } from "@/client/components/SegmentedToggle";
|
||||
import { useRankCheckTrigger } from "./useRankCheckTrigger";
|
||||
import { useRankRunPolling } from "./useRankRunPolling";
|
||||
|
||||
const COMPARE_PERIODS: ReadonlySet<string> = new Set([
|
||||
"previous",
|
||||
"1d",
|
||||
"7d",
|
||||
"30d",
|
||||
"90d",
|
||||
@ -60,7 +63,12 @@ export function RankTrackingDomainDetail({
|
||||
const [showAddKeywords, setShowAddKeywords] = useState(false);
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
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({
|
||||
queryKey: ["rankTrackingResults", projectId, config.id, comparePeriod],
|
||||
@ -85,7 +93,8 @@ export function RankTrackingDomainDetail({
|
||||
|
||||
const handleKeywordsAdded = (result: {
|
||||
added: number;
|
||||
addedIds?: string[];
|
||||
addedIds: string[];
|
||||
checkTriggered: boolean;
|
||||
}) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["rankTrackingCostEstimate", projectId, config.id],
|
||||
@ -93,13 +102,17 @@ export function RankTrackingDomainDetail({
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["rankTrackingResults", projectId, config.id],
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["rankTrackingLatestRun", projectId, config.id],
|
||||
});
|
||||
setShowAddKeywords(false);
|
||||
captureClientEvent("rank_tracking:keywords_add");
|
||||
toast.success(
|
||||
`${result.added} keyword${result.added !== 1 ? "s" : ""} added`,
|
||||
);
|
||||
if (result.addedIds && result.addedIds.length > 0)
|
||||
requestCheck(result.addedIds.length, result.addedIds);
|
||||
if (!result.checkTriggered && result.added > 0) {
|
||||
toast.info("Use 'Check Now' to check these keywords");
|
||||
}
|
||||
};
|
||||
|
||||
const isRunning =
|
||||
@ -124,15 +137,19 @@ export function RankTrackingDomainDetail({
|
||||
|
||||
const rows = resultsData?.rows;
|
||||
const run = resultsData?.run;
|
||||
const showDesktop = config.devices !== "mobile";
|
||||
const showMobile = config.devices !== "desktop";
|
||||
const hasBothDevices = config.devices === "both";
|
||||
const showDesktop = hasBothDevices
|
||||
? activeDevice === "desktop"
|
||||
: config.devices !== "mobile";
|
||||
const showMobile = hasBothDevices
|
||||
? activeDevice === "mobile"
|
||||
: config.devices !== "desktop";
|
||||
const filtered = useMemo(
|
||||
() => applyFilters(rows ?? [], filters),
|
||||
[rows, filters],
|
||||
);
|
||||
const activeFilterCount = countActiveFilters(filters);
|
||||
const defaultSortId =
|
||||
config.devices === "desktop" ? "desktopPosition" : "mobilePosition";
|
||||
const defaultSortId = showDesktop ? "desktopPosition" : "mobilePosition";
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
@ -144,8 +161,29 @@ export function RankTrackingDomainDetail({
|
||||
Back to domains
|
||||
</button>
|
||||
|
||||
{config.lastSkipReason === "insufficient_credits" && (
|
||||
<div className="alert alert-warning text-sm py-2">
|
||||
<AlertTriangle className="size-4" />
|
||||
<span>
|
||||
Last scheduled check was skipped due to insufficient credits. Top up
|
||||
your balance to resume automatic tracking.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{latestRun?.maybeStale && (
|
||||
<div className="alert alert-warning text-sm py-2">
|
||||
<AlertTriangle className="size-4" />
|
||||
<span>
|
||||
This run may be unresponsive and will be cleaned up automatically.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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 */}
|
||||
<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>
|
||||
<h2 className="text-lg font-semibold">{config.domain}</h2>
|
||||
<p className="text-xs text-base-content/60">
|
||||
@ -155,7 +193,8 @@ export function RankTrackingDomainDetail({
|
||||
{run && (
|
||||
<>
|
||||
{" "}
|
||||
· Last: {new Date(run.startedAt).toLocaleDateString()}
|
||||
· Last:{" "}
|
||||
{new Date(run.lastCheckedAt).toLocaleDateString()}
|
||||
</>
|
||||
)}
|
||||
{costEstimate && costEstimate.keywordCount > 0 && (
|
||||
@ -178,38 +217,19 @@ export function RankTrackingDomainDetail({
|
||||
</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 && (
|
||||
<div className="px-4 pb-3">
|
||||
<AddKeywordsPanel
|
||||
configId={config.id}
|
||||
projectId={projectId}
|
||||
onSuccess={handleKeywordsAdded}
|
||||
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 */}
|
||||
<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
|
||||
className={`btn btn-ghost btn-sm gap-1.5 ${showFilters ? "btn-active" : ""}`}
|
||||
onClick={() => setShowFilters((c) => !c)}
|
||||
@ -223,19 +243,6 @@ export function RankTrackingDomainDetail({
|
||||
</span>
|
||||
)}
|
||||
</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 ? (
|
||||
<div className="flex items-center gap-2 text-sm text-base-content/70">
|
||||
@ -243,7 +250,7 @@ export function RankTrackingDomainDetail({
|
||||
<span>
|
||||
{latestRun.status === "pending"
|
||||
? "Preparing..."
|
||||
: "Checking keywords..."}{" "}
|
||||
: `Getting rankings for ${latestRun.keywordsTotal || "?"} keyword${latestRun.keywordsTotal !== 1 ? "s" : ""}...`}{" "}
|
||||
{latestRun.keywordsChecked}/{latestRun.keywordsTotal || "?"}
|
||||
</span>
|
||||
{latestRun.keywordsTotal > 0 && (
|
||||
@ -262,6 +269,39 @@ export function RankTrackingDomainDetail({
|
||||
|
||||
<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
|
||||
onCheckNow={() => {
|
||||
const count = costEstimate?.keywordCount ?? rows?.length ?? 0;
|
||||
@ -298,6 +338,7 @@ export function RankTrackingDomainDetail({
|
||||
{/* Table */}
|
||||
<div className="p-4">
|
||||
<RankTrackingTable
|
||||
key={defaultSortId}
|
||||
totalCount={rows?.length ?? 0}
|
||||
rows={filtered}
|
||||
resultsLoading={resultsLoading}
|
||||
@ -315,6 +356,7 @@ export function RankTrackingDomainDetail({
|
||||
<CheckConfirmModal
|
||||
keywordCount={pendingCheck.count}
|
||||
devices={config.devices}
|
||||
serpDepth={config.serpDepth}
|
||||
isPending={isPending}
|
||||
onRunNow={() =>
|
||||
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 { toast } from "sonner";
|
||||
import { LOCATIONS } from "@/client/features/keywords/locations";
|
||||
import { AlertTriangle, Globe, Plus, ChevronRight } from "lucide-react";
|
||||
import { getRankTrackingConfigSummaries } from "@/serverFunctions/rank-tracking";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Archive,
|
||||
Globe,
|
||||
Plus,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
getRankTrackingConfigSummaries,
|
||||
updateRankTrackingConfig,
|
||||
} from "@/serverFunctions/rank-tracking";
|
||||
import {
|
||||
devicesLabel as getDevicesLabel,
|
||||
scheduleLabel as getScheduleLabel,
|
||||
} from "@/shared/rank-tracking";
|
||||
import { Modal } from "@/client/components/Modal";
|
||||
|
||||
type ConfigSummary = Awaited<
|
||||
ReturnType<typeof getRankTrackingConfigSummaries>
|
||||
@ -20,11 +32,32 @@ export function RankTrackingDomainList({
|
||||
onAddDomain: () => void;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [archiveTarget, setArchiveTarget] = useState<ConfigSummary | null>(
|
||||
null,
|
||||
);
|
||||
const { data: summaries } = useQuery({
|
||||
queryKey: ["rankTrackingConfigSummaries", 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 (
|
||||
<div className="card bg-base-100 border border-base-300">
|
||||
<div className="card-body gap-0 p-0">
|
||||
@ -62,11 +95,40 @@ export function RankTrackingDomainList({
|
||||
params: { projectId, configId: summary.id },
|
||||
})
|
||||
}
|
||||
onArchive={() => setArchiveTarget(summary)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@ -74,22 +136,28 @@ export function RankTrackingDomainList({
|
||||
function DomainRow({
|
||||
summary,
|
||||
onClick,
|
||||
onArchive,
|
||||
}: {
|
||||
summary: ConfigSummary;
|
||||
onClick: () => void;
|
||||
onArchive: () => void;
|
||||
}) {
|
||||
const dl = getDevicesLabel(summary.devices);
|
||||
const sl = getScheduleLabel(summary.scheduleInterval);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-4 px-5 py-3.5 text-left transition-colors hover:bg-base-200/50"
|
||||
<div
|
||||
role="button"
|
||||
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}
|
||||
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">
|
||||
<p className="font-medium truncate">{summary.domain}</p>
|
||||
<p className="text-xs text-base-content/60">
|
||||
@ -119,7 +187,18 @@ function DomainRow({
|
||||
</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>
|
||||
<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 { buildCsv, downloadCsv } from "@/client/lib/csv";
|
||||
import { captureClientEvent } from "@/client/lib/posthog";
|
||||
@ -7,50 +7,6 @@ import type {
|
||||
RankTrackingRow,
|
||||
} 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> = {
|
||||
featured_snippet: "FS",
|
||||
people_also_ask: "PAA",
|
||||
@ -76,7 +32,7 @@ const FEATURE_TOOLTIPS: Record<string, string> = {
|
||||
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);
|
||||
if (notable.length === 0) return null;
|
||||
return (
|
||||
@ -84,7 +40,7 @@ function SerpFeatureTags({ features }: { features: string[] }) {
|
||||
{notable.map((f) => (
|
||||
<span
|
||||
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}
|
||||
>
|
||||
{f === "ai_overview" && <Sparkles className="size-2.5" />}
|
||||
@ -95,51 +51,79 @@ function SerpFeatureTags({ features }: { features: string[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
function PositionWithChange({
|
||||
position,
|
||||
previous,
|
||||
export function DeviceRankCell({
|
||||
result,
|
||||
}: {
|
||||
position: number | null;
|
||||
previous: number | null;
|
||||
result: RankTrackingDeviceResult;
|
||||
}) {
|
||||
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 (
|
||||
<span className="inline-flex w-full items-center justify-between px-3">
|
||||
<PositionBadge position={position} />
|
||||
<ChangeIndicator current={position} previous={previous} />
|
||||
<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 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>
|
||||
);
|
||||
}
|
||||
|
||||
export function DeviceRankCell({
|
||||
export function DeviceUrlCell({
|
||||
result,
|
||||
domain,
|
||||
}: {
|
||||
result: RankTrackingDeviceResult;
|
||||
domain: string;
|
||||
}) {
|
||||
if (!result.rankingUrl) {
|
||||
return <span className="text-base-content/40 text-xs">-</span>;
|
||||
}
|
||||
return (
|
||||
<div className="min-w-44 space-y-1.5">
|
||||
<PositionWithChange
|
||||
position={result.position}
|
||||
previous={result.previousPosition}
|
||||
/>
|
||||
{result.rankingUrl ? (
|
||||
<a
|
||||
href={toFullUrl(result.rankingUrl, domain)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="link link-hover block truncate px-3 text-xs"
|
||||
className="link link-hover block truncate text-xs"
|
||||
title={result.rankingUrl}
|
||||
>
|
||||
{toPath(result.rankingUrl)}
|
||||
</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()
|
||||
.default("both"),
|
||||
serpDepth: integer("serp_depth").notNull(),
|
||||
scheduleInterval: text("schedule_interval", {
|
||||
enum: ["daily", "weekly", "manual"],
|
||||
})
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { RankTrackingDomainList } from "@/client/features/rank-tracking/RankTrackingDomainList";
|
||||
import { RankTrackingConfigModal } from "@/client/features/rank-tracking/RankTrackingConfigModal";
|
||||
@ -10,6 +10,7 @@ export const Route = createFileRoute("/_project/p/$projectId/rank-tracking/")({
|
||||
|
||||
function RankTrackingIndex() {
|
||||
const { projectId } = Route.useParams();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [showConfigModal, setShowConfigModal] = useState(false);
|
||||
|
||||
@ -34,9 +35,16 @@ function RankTrackingIndex() {
|
||||
projectId={projectId}
|
||||
existingConfig={null}
|
||||
onClose={() => setShowConfigModal(false)}
|
||||
onSaved={() => {
|
||||
onConfigCreated={invalidateConfigs}
|
||||
onSaved={(createdConfigId) => {
|
||||
setShowConfigModal(false);
|
||||
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 = {
|
||||
getOverview,
|
||||
getSuggestedKeywords,
|
||||
} 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 { db } from "@/db";
|
||||
import {
|
||||
@ -9,6 +9,11 @@ import {
|
||||
rankTrackingKeywords,
|
||||
projects,
|
||||
} from "@/db/schema";
|
||||
import {
|
||||
getLatestSnapshotsForKeywords,
|
||||
getSnapshotsBeforeDate,
|
||||
getEarliestSnapshotsForKeywords,
|
||||
} from "./snapshotQueries";
|
||||
|
||||
const DB_BATCH_SIZE = 100;
|
||||
type BatchStatement = Parameters<typeof db.batch>[0][number];
|
||||
@ -33,7 +38,12 @@ async function getConfigsForProject(projectId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(rankTrackingConfigs)
|
||||
.where(eq(rankTrackingConfigs.projectId, projectId))
|
||||
.where(
|
||||
and(
|
||||
eq(rankTrackingConfigs.projectId, projectId),
|
||||
eq(rankTrackingConfigs.isActive, true),
|
||||
),
|
||||
)
|
||||
.orderBy(rankTrackingConfigs.createdAt);
|
||||
}
|
||||
|
||||
@ -107,6 +117,7 @@ async function getDueConfigsWithOrganization(nowIso: string) {
|
||||
locationCode: rankTrackingConfigs.locationCode,
|
||||
languageCode: rankTrackingConfigs.languageCode,
|
||||
devices: rankTrackingConfigs.devices,
|
||||
serpDepth: rankTrackingConfigs.serpDepth,
|
||||
scheduleInterval: rankTrackingConfigs.scheduleInterval,
|
||||
nextCheckAt: rankTrackingConfigs.nextCheckAt,
|
||||
organizationId: projects.organizationId,
|
||||
@ -215,62 +226,6 @@ async function getSnapshotsForRun(runId: string) {
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -401,11 +356,12 @@ export const RankTrackingRepository = {
|
||||
deleteRunLock,
|
||||
insertSnapshots,
|
||||
getSnapshotsForRun,
|
||||
getRecentCompletedRuns,
|
||||
getClosestCompletedRun,
|
||||
getKeywordsForConfig,
|
||||
addKeywordsToConfig,
|
||||
removeKeywordsFromConfig,
|
||||
getKeywordCountForConfig,
|
||||
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;
|
||||
languageCode?: string;
|
||||
devices?: RankTrackingConfig["devices"];
|
||||
serpDepth: number;
|
||||
scheduleInterval?: RankTrackingConfig["scheduleInterval"];
|
||||
}) {
|
||||
const normalizedDomain = normalizeDomain(input.domain);
|
||||
@ -69,7 +70,8 @@ async function createConfig(input: {
|
||||
domain: normalizedDomain,
|
||||
locationCode: input.locationCode ?? 2840,
|
||||
languageCode: input.languageCode ?? "en",
|
||||
devices: input.devices ?? "mobile",
|
||||
devices: input.devices ?? "both",
|
||||
serpDepth: input.serpDepth,
|
||||
scheduleInterval,
|
||||
nextCheckAt,
|
||||
});
|
||||
@ -85,6 +87,7 @@ async function updateConfig(
|
||||
locationCode?: number;
|
||||
languageCode?: string;
|
||||
devices?: RankTrackingConfig["devices"];
|
||||
serpDepth?: number;
|
||||
scheduleInterval?: RankTrackingConfig["scheduleInterval"];
|
||||
isActive?: boolean;
|
||||
},
|
||||
@ -98,6 +101,7 @@ async function updateConfig(
|
||||
if (input.languageCode !== undefined)
|
||||
updates.languageCode = input.languageCode;
|
||||
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.scheduleInterval !== undefined) {
|
||||
@ -200,7 +204,7 @@ async function triggerCheck(input: {
|
||||
organizationId: input.billingCustomer.organizationId,
|
||||
projectId: input.billingCustomer.projectId,
|
||||
},
|
||||
keywordsTotal: keywords.length,
|
||||
keywordsTotal: input.keywordIds ? input.keywordIds.length : keywords.length,
|
||||
keywordIds: input.keywordIds,
|
||||
trigger: "manual",
|
||||
workflowStartErrorMessage: "Failed to start rank check workflow",
|
||||
@ -239,6 +243,7 @@ async function estimateCost(configId: string, projectId: string) {
|
||||
const { costUsd, costCredits } = estimateRankCheckCredits(
|
||||
keywordCount,
|
||||
config.devices,
|
||||
config.serpDepth,
|
||||
);
|
||||
return {
|
||||
costUsd,
|
||||
@ -292,6 +297,7 @@ function formatRun(
|
||||
status: run.status,
|
||||
keywordsTotal: run.keywordsTotal,
|
||||
keywordsChecked: run.keywordsChecked,
|
||||
isSubsetRun: run.isSubsetRun,
|
||||
errorMessage: run.errorMessage,
|
||||
startedAt: run.startedAt,
|
||||
completedAt: run.completedAt,
|
||||
|
||||
@ -36,7 +36,7 @@ type RankCheckWorkflowStatus = {
|
||||
|
||||
type RankCheckConfigForStart = Pick<
|
||||
RankTrackingConfig,
|
||||
"id" | "domain" | "locationCode" | "languageCode" | "devices"
|
||||
"id" | "domain" | "locationCode" | "languageCode" | "devices" | "serpDepth"
|
||||
>;
|
||||
|
||||
const ACTIVE_WORKFLOW_STATUSES = new Set<RankCheckWorkflowStatus["status"]>([
|
||||
@ -122,6 +122,7 @@ export async function beginRankCheckRun(input: {
|
||||
locationCode: input.config.locationCode,
|
||||
languageCode: input.config.languageCode,
|
||||
devices: input.config.devices,
|
||||
serpDepth: input.config.serpDepth,
|
||||
trigger: input.trigger,
|
||||
keywordIds: input.keywordIds,
|
||||
},
|
||||
|
||||
@ -7,10 +7,11 @@ import type {
|
||||
} from "@/types/schemas/rank-tracking";
|
||||
|
||||
type SnapshotRow = Awaited<
|
||||
ReturnType<typeof RankTrackingRepository.getSnapshotsForRun>
|
||||
ReturnType<typeof RankTrackingRepository.getLatestSnapshotsForKeywords>
|
||||
>[0];
|
||||
|
||||
const PERIOD_DAYS: Record<Exclude<ComparePeriod, "previous">, number> = {
|
||||
const PERIOD_DAYS: Record<ComparePeriod, number> = {
|
||||
"1d": 1,
|
||||
"7d": 7,
|
||||
"30d": 30,
|
||||
"90d": 90,
|
||||
@ -19,10 +20,10 @@ const PERIOD_DAYS: Record<Exclude<ComparePeriod, "previous">, number> = {
|
||||
export async function getLatestResults(
|
||||
configId: string,
|
||||
projectId: string,
|
||||
comparePeriod: ComparePeriod = "previous",
|
||||
comparePeriod: ComparePeriod = "7d",
|
||||
): Promise<{
|
||||
rows: RankTrackingRow[];
|
||||
run: { id: string; startedAt: string } | null;
|
||||
run: { id: string; lastCheckedAt: string } | null;
|
||||
}> {
|
||||
const config = await RankTrackingRepository.getConfigById({
|
||||
configId,
|
||||
@ -32,54 +33,56 @@ export async function getLatestResults(
|
||||
throw new AppError("INTERNAL_ERROR", "Rank tracking config not found");
|
||||
}
|
||||
|
||||
const recentRuns = await RankTrackingRepository.getRecentCompletedRuns(
|
||||
configId,
|
||||
2,
|
||||
);
|
||||
const currentRun = recentRuns[0];
|
||||
if (!currentRun) {
|
||||
return { rows: [], run: null };
|
||||
}
|
||||
const activeKeywords =
|
||||
await RankTrackingRepository.getKeywordsForConfig(configId);
|
||||
|
||||
const currentSnapshots = await RankTrackingRepository.getSnapshotsForRun(
|
||||
currentRun.id,
|
||||
);
|
||||
// Get the latest snapshot per keyword per device (across all completed runs)
|
||||
const currentSnapshots =
|
||||
await RankTrackingRepository.getLatestSnapshotsForKeywords(configId);
|
||||
|
||||
// Load comparison run's snapshots for delta computation
|
||||
const previousPositions = new Map<string, number | null>();
|
||||
let comparisonRun: typeof currentRun | null = null;
|
||||
|
||||
if (comparePeriod === "previous") {
|
||||
comparisonRun = recentRuns[1] ?? null;
|
||||
} else {
|
||||
// Get comparison snapshots from before the target date
|
||||
const days = PERIOD_DAYS[comparePeriod];
|
||||
const targetDate = new Date(
|
||||
Date.now() - days * 24 * 60 * 60 * 1000,
|
||||
).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 prevSnapshots = await RankTrackingRepository.getSnapshotsForRun(
|
||||
comparisonRun.id,
|
||||
);
|
||||
for (const snap of prevSnapshots) {
|
||||
const comparisonSnapshots =
|
||||
await RankTrackingRepository.getSnapshotsBeforeDate(configId, targetDate);
|
||||
|
||||
const previousPositions = new Map<string, number | null>();
|
||||
for (const snap of comparisonSnapshots) {
|
||||
previousPositions.set(
|
||||
`${snap.trackingKeywordId}:${snap.device}`,
|
||||
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 =
|
||||
await RankTrackingRepository.getKeywordsForConfig(configId);
|
||||
if (missingKeywordIds.length > 0) {
|
||||
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>(
|
||||
activeKeywords.map((keyword) => [
|
||||
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) {
|
||||
const row = rows.get(snapshot.trackingKeywordId);
|
||||
if (!row) continue;
|
||||
@ -105,16 +112,22 @@ export async function getLatestResults(
|
||||
`${snapshot.trackingKeywordId}:${snapshot.device}`,
|
||||
) ?? null,
|
||||
);
|
||||
|
||||
// Track the most recent run for the header display
|
||||
if (!latestStartedAt || snapshot.checkedAt > latestStartedAt) {
|
||||
latestRunId = snapshot.runId;
|
||||
latestStartedAt = snapshot.checkedAt;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
rows: activeKeywords
|
||||
.map((keyword) => rows.get(keyword.id))
|
||||
.filter((row): row is RankTrackingRow => row != null),
|
||||
run: {
|
||||
id: currentRun.id,
|
||||
startedAt: currentRun.startedAt,
|
||||
},
|
||||
run:
|
||||
latestRunId && latestStartedAt
|
||||
? { id: latestRunId, lastCheckedAt: latestStartedAt }
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -402,7 +402,9 @@ export async function fetchRankCheckSerpRaw(input: {
|
||||
languageCode: string;
|
||||
device: "desktop" | "mobile";
|
||||
targetDomain: string;
|
||||
depth: number;
|
||||
}): Promise<DataforseoApiResponse<RankCheckResult>> {
|
||||
const depth = Math.min(100, Math.max(10, input.depth));
|
||||
const responseRaw = await postDataforseo(
|
||||
"/v3/serp/google/organic/live/advanced",
|
||||
[
|
||||
@ -412,8 +414,7 @@ export async function fetchRankCheckSerpRaw(input: {
|
||||
language_code: input.languageCode,
|
||||
device: input.device,
|
||||
os: input.device === "desktop" ? "windows" : "android",
|
||||
depth: 20,
|
||||
target: input.targetDomain,
|
||||
depth,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
@ -207,6 +207,7 @@ export function createDataforseoClient(customer: BillingCustomerContext) {
|
||||
languageCode: string;
|
||||
device: "desktop" | "mobile";
|
||||
targetDomain: string;
|
||||
depth: number;
|
||||
}) {
|
||||
return meterDataforseoCall(
|
||||
customer,
|
||||
|
||||
@ -37,6 +37,7 @@ interface RankCheckParams {
|
||||
locationCode: number;
|
||||
languageCode: string;
|
||||
devices: "both" | "desktop" | "mobile";
|
||||
serpDepth: number;
|
||||
trigger: "manual" | "scheduled";
|
||||
keywordIds?: string[];
|
||||
}
|
||||
@ -46,6 +47,7 @@ async function prepareRankCheckKeywords(input: {
|
||||
configId: string;
|
||||
billingCustomer: BillingCustomerContext;
|
||||
devices: RankCheckParams["devices"];
|
||||
serpDepth: number;
|
||||
keywordIds?: string[];
|
||||
}) {
|
||||
const ownsLock = await runOwnsRankCheckLock(input.configId, input.runId);
|
||||
@ -77,6 +79,7 @@ async function prepareRankCheckKeywords(input: {
|
||||
const { costCredits } = estimateRankCheckCredits(
|
||||
trackingKeywords.length,
|
||||
input.devices,
|
||||
input.serpDepth,
|
||||
);
|
||||
const [monthlyCheck, topupCheck] = await Promise.all([
|
||||
autumn.check({
|
||||
@ -233,12 +236,34 @@ export class RankCheckWorkflow extends WorkflowEntrypoint<
|
||||
locationCode,
|
||||
languageCode,
|
||||
devices,
|
||||
serpDepth,
|
||||
trigger,
|
||||
keywordIds,
|
||||
} = event.payload;
|
||||
|
||||
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 {
|
||||
console.log(
|
||||
`[rank-check] ${runId} starting (trigger=${trigger}, devices=${devices})`,
|
||||
@ -253,6 +278,7 @@ export class RankCheckWorkflow extends WorkflowEntrypoint<
|
||||
configId,
|
||||
billingCustomer,
|
||||
devices,
|
||||
serpDepth,
|
||||
keywordIds,
|
||||
}),
|
||||
);
|
||||
@ -268,6 +294,7 @@ export class RankCheckWorkflow extends WorkflowEntrypoint<
|
||||
client,
|
||||
keywords,
|
||||
devices,
|
||||
serpDepth,
|
||||
domain,
|
||||
locationCode,
|
||||
languageCode,
|
||||
|
||||
@ -35,6 +35,7 @@ interface CheckContext {
|
||||
client: ReturnType<typeof createDataforseoClient>;
|
||||
keywords: KeywordEntry[];
|
||||
devices: RankTrackingConfig["devices"];
|
||||
serpDepth: number;
|
||||
domain: string;
|
||||
locationCode: number;
|
||||
languageCode: string;
|
||||
@ -74,6 +75,7 @@ export async function runLiveCheck(
|
||||
languageCode: ctx.languageCode,
|
||||
device,
|
||||
targetDomain: ctx.domain,
|
||||
depth: ctx.serpDepth,
|
||||
})
|
||||
.then((r) => ({ ...r, device })),
|
||||
),
|
||||
@ -93,6 +95,7 @@ export async function runLiveCheck(
|
||||
await RankTrackingRepository.updateRun(ctx.runId, {
|
||||
keywordsChecked: checked,
|
||||
});
|
||||
|
||||
if (results.length > 0) {
|
||||
await RankTrackingRepository.insertSnapshots(
|
||||
mapResultsToSnapshotRows(ctx.runId, results),
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
import { createServerFn } from "@tanstack/react-start";
|
||||
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";
|
||||
|
||||
export const getDomainOverview = createServerFn({ method: "POST" })
|
||||
@ -15,3 +18,17 @@ export const getDomainOverview = createServerFn({ method: "POST" })
|
||||
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 { RankTrackingService } from "@/server/features/rank-tracking/services/RankTrackingService";
|
||||
import { getLatestResults } from "@/server/features/rank-tracking/services/rankTrackingResults";
|
||||
import { asAppError } from "@/server/lib/errors";
|
||||
import { captureServerEvent } from "@/server/lib/posthog";
|
||||
import { requireProjectContext } from "@/serverFunctions/middleware";
|
||||
import {
|
||||
@ -41,6 +42,7 @@ export const createRankTrackingConfig = createServerFn({ method: "POST" })
|
||||
locationCode: data.locationCode,
|
||||
languageCode: data.languageCode,
|
||||
devices: data.devices,
|
||||
serpDepth: data.serpDepth,
|
||||
scheduleInterval: data.scheduleInterval,
|
||||
});
|
||||
|
||||
@ -70,6 +72,7 @@ export const updateRankTrackingConfig = createServerFn({ method: "POST" })
|
||||
locationCode: data.locationCode,
|
||||
languageCode: data.languageCode,
|
||||
devices: data.devices,
|
||||
serpDepth: data.serpDepth,
|
||||
scheduleInterval: data.scheduleInterval,
|
||||
isActive: data.isActive,
|
||||
});
|
||||
@ -134,11 +137,44 @@ export const addTrackingKeywords = createServerFn({ method: "POST" })
|
||||
.middleware(requireProjectContext)
|
||||
.inputValidator((data: unknown) => addKeywordsSchema.parse(data))
|
||||
.handler(async ({ data, context }) => {
|
||||
return RankTrackingService.addKeywords(
|
||||
const result = await RankTrackingService.addKeywords(
|
||||
data.configId,
|
||||
context.projectId,
|
||||
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" })
|
||||
|
||||
@ -9,8 +9,11 @@ import type { RankTrackingConfig } from "@/types/schemas/rank-tracking";
|
||||
// Cost constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Per-SERP cost from DataForSEO Live API */
|
||||
const COST_PER_SERP_USD = 0.002;
|
||||
/** DataForSEO Live API: cost of first page (10 results) */
|
||||
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 */
|
||||
export const KEYWORDS_PER_BATCH = 10;
|
||||
@ -28,13 +31,28 @@ export const MAX_CONFIGS_PER_PROJECT = 20;
|
||||
// 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(
|
||||
keywordCount: number,
|
||||
devices: RankTrackingConfig["devices"],
|
||||
depth: number,
|
||||
) {
|
||||
const totalChecks = keywordCount * devicesCount(devices);
|
||||
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);
|
||||
return { costUsd, costCredits };
|
||||
|
||||
@ -1,11 +1,14 @@
|
||||
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
|
||||
.union([z.boolean(), z.enum(["true", "false"])])
|
||||
.transform((value) => value === true || value === "true");
|
||||
|
||||
export const domainOverviewSchema = z.object({
|
||||
projectId: z.string().min(1),
|
||||
projectId: z.string().uuid(),
|
||||
domain: z.string().min(1, "Domain is required").max(255),
|
||||
includeSubdomains: z.boolean().default(true),
|
||||
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 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({
|
||||
domain: z.string().optional(),
|
||||
subdomains: booleanSearchParamSchema.optional(),
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import type { InferSelectModel } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { rankTrackingConfigs } from "@/db/app.schema";
|
||||
import { DOMAIN_REGEX } from "@/types/schemas/domain";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DB-derived types
|
||||
@ -53,13 +54,11 @@ export const createConfigSchema = z.object({
|
||||
.string()
|
||||
.min(1)
|
||||
.max(253)
|
||||
.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,}$/,
|
||||
"Invalid domain format",
|
||||
),
|
||||
.regex(DOMAIN_REGEX, "Invalid domain format"),
|
||||
locationCode: z.number().int().positive().optional(),
|
||||
languageCode: z.string().max(10).optional(),
|
||||
devices: devicesEnum.optional(),
|
||||
serpDepth: z.number().int().min(10).max(100).multipleOf(10),
|
||||
scheduleInterval: scheduleEnum.optional(),
|
||||
});
|
||||
|
||||
@ -70,14 +69,12 @@ export const updateConfigSchema = z.object({
|
||||
.string()
|
||||
.min(1)
|
||||
.max(253)
|
||||
.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,}$/,
|
||||
"Invalid domain format",
|
||||
)
|
||||
.regex(DOMAIN_REGEX, "Invalid domain format")
|
||||
.optional(),
|
||||
locationCode: z.number().int().positive().optional(),
|
||||
languageCode: z.string().max(10).optional(),
|
||||
devices: devicesEnum.optional(),
|
||||
serpDepth: z.number().int().min(10).max(100).multipleOf(10).optional(),
|
||||
scheduleInterval: scheduleEnum.optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
});
|
||||
@ -88,7 +85,7 @@ export const triggerCheckSchema = z.object({
|
||||
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 const getLatestResultsSchema = z.object({
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user