import { useState } from "react"; import { toast } from "sonner"; import { useMutation } from "@tanstack/react-query"; import { createRankTrackingConfig, updateRankTrackingConfig, } from "@/serverFunctions/rank-tracking"; 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 { domainField, normalizeDomain } from "@/types/schemas/domain"; import { depthToPages, pagesToDepth, estimateRankCheckCredits, } from "@/shared/rank-tracking"; import { DEFAULT_LOCATION_CODE, getLanguageCode, } from "@/client/features/keywords/locations"; import { LocationSelect } from "@/client/components/LocationSelect"; import { KeywordSuggestionStep } from "./KeywordSuggestionStep"; type Props = { projectId: string; existingConfig?: RankTrackingConfig | null; onClose: () => void; onSaved: (createdConfigId?: string) => void; onConfigCreated?: () => void; }; export function RankTrackingConfigModal({ projectId, 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", ); const [locationCode, setLocationCode] = useState( existingConfig?.locationCode ?? DEFAULT_LOCATION_CODE, ); const [serpDepth, setSerpDepth] = useState(existingConfig?.serpDepth ?? 40); const [schedule, setSchedule] = useState< RankTrackingConfig["scheduleInterval"] >(existingConfig?.scheduleInterval ?? "weekly"); const [createdConfigId, setCreatedConfigId] = useState(null); const createMutation = useMutation({ mutationFn: (normalizedDomain: string) => createRankTrackingConfig({ data: { projectId, domain: normalizedDomain, devices, serpDepth, locationCode, languageCode: getLanguageCode(locationCode), scheduleInterval: schedule, }, }), onSuccess: (result) => { captureClientEvent("rank_tracking:config_create"); toast.success("Domain added for rank tracking"); setCreatedConfigId(result.configId); onConfigCreated?.(); setStep("keywords"); }, onError: (error) => { toast.error(getStandardErrorMessage(error, "Failed to save config")); }, }); const updateMutation = useMutation({ mutationFn: (normalizedDomain: string) => updateRankTrackingConfig({ data: { projectId, configId: existingConfig!.id, domain: normalizedDomain, devices, serpDepth, locationCode, languageCode: getLanguageCode(locationCode), scheduleInterval: schedule, }, }), onSuccess: () => { captureClientEvent("rank_tracking:config_update"); toast.success("Configuration updated"); onSaved(); }, onError: (error) => { toast.error(getStandardErrorMessage(error, "Failed to update config")); }, }); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (isPending) return; if (!domain.trim()) { toast.error("Please enter a domain"); return; } const parsedDomain = domainField.safeParse(domain); if (!parsedDomain.success) { toast.error("Please enter a valid domain"); return; } setDomain(parsedDomain.data); if (isEdit) { updateMutation.mutate(parsedDomain.data); } else { createMutation.mutate(parsedDomain.data); } }; const handleDomainBlur = () => { try { setDomain(normalizeDomain(domain)); } catch { // Keep invalid partial input editable; submit validation will show the error. } }; const isPending = createMutation.isPending || updateMutation.isPending; if (step === "keywords" && createdConfigId) { const closeKeywordStep = () => onSaved(createdConfigId); return ( onSaved(id)} onClose={closeKeywordStep} /> ); } return (

{isEdit ? "Edit Domain Config" : "Add Domain"}

setDomain(e.target.value)} onBlur={handleDomainBlur} />
Most Google searches come from mobile, but select this based on your customer.
{devices === "both" && (
Tracking both devices uses 2x credits per keyword check
)}
{schedule === "daily" && (
Daily checks use 7x more credits than weekly
)}
10 pages is ~8x more expensive than 1 page
{(() => { // Scheduled checks run through the cheaper task queue; manual // configs only ever pay the live price. const { costUsd: costPerKeyword } = estimateRankCheckCredits( 1, devices, serpDepth, schedule === "manual" ? "live" : "queued", ); const checksPerMonth = schedule === "daily" ? 30 : schedule === "weekly" ? 4 : 1; return (
~${costPerKeyword.toFixed(4)} {" "} per keyword per check
{schedule !== "manual" && (
50 keywords would cost{" "} ~${(costPerKeyword * 50 * checksPerMonth).toFixed(2)} /month
)}
); })()}
); }