* feat: add monthly rank tracking schedule * fix(ci): un-export ScheduledRankTrackingInterval to satisfy knip --------- Co-authored-by: Ben Senescu <bensenescu@gmail.com>
336 lines
11 KiB
TypeScript
336 lines
11 KiB
TypeScript
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<string | null>(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 (
|
|
<Modal
|
|
maxWidth="max-w-3xl"
|
|
onClose={closeKeywordStep}
|
|
labelledBy="keyword-suggestions-title"
|
|
>
|
|
<KeywordSuggestionStep
|
|
configId={createdConfigId}
|
|
projectId={projectId}
|
|
domain={domain}
|
|
locationCode={locationCode}
|
|
languageCode={getLanguageCode(locationCode)}
|
|
onDone={(id) => onSaved(id)}
|
|
onClose={closeKeywordStep}
|
|
/>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Modal
|
|
maxWidth="max-w-lg"
|
|
onClose={onClose}
|
|
labelledBy="rank-config-modal-title"
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<h2 id="rank-config-modal-title" className="text-lg font-semibold">
|
|
{isEdit ? "Edit Domain Config" : "Add Domain"}
|
|
</h2>
|
|
<button className="btn btn-ghost btn-sm btn-square" onClick={onClose}>
|
|
<X className="size-4" />
|
|
</button>
|
|
</div>
|
|
|
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
|
<div className="form-control">
|
|
<label className="label">
|
|
<span className="label-text font-medium">Target Domain</span>
|
|
</label>
|
|
<input
|
|
type="text"
|
|
placeholder="example.com"
|
|
className="input input-bordered w-full"
|
|
value={domain}
|
|
onChange={(e) => setDomain(e.target.value)}
|
|
onBlur={handleDomainBlur}
|
|
/>
|
|
</div>
|
|
|
|
<div className="form-control">
|
|
<label className="label">
|
|
<span className="label-text font-medium">Country</span>
|
|
</label>
|
|
<LocationSelect value={locationCode} onChange={setLocationCode} />
|
|
</div>
|
|
|
|
<div className="form-control">
|
|
<label className="label">
|
|
<span className="label-text font-medium">Devices</span>
|
|
</label>
|
|
<select
|
|
className="select select-bordered w-full"
|
|
value={devices}
|
|
onChange={(e) => {
|
|
const value = e.target.value;
|
|
if (
|
|
value === "both" ||
|
|
value === "desktop" ||
|
|
value === "mobile"
|
|
) {
|
|
setDevices(value);
|
|
}
|
|
}}
|
|
>
|
|
<option value="both">Desktop + Mobile</option>
|
|
<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">
|
|
<label className="label">
|
|
<span className="label-text font-medium">Schedule</span>
|
|
</label>
|
|
<select
|
|
className="select select-bordered w-full"
|
|
value={schedule}
|
|
onChange={(e) => {
|
|
const value = e.target.value;
|
|
if (
|
|
value === "daily" ||
|
|
value === "weekly" ||
|
|
value === "monthly" ||
|
|
value === "manual"
|
|
) {
|
|
setSchedule(value);
|
|
}
|
|
}}
|
|
>
|
|
<option value="daily">Daily</option>
|
|
<option value="weekly">Weekly</option>
|
|
<option value="monthly">Monthly (end of month)</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>
|
|
|
|
<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>
|
|
|
|
{(() => {
|
|
// 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 (
|
|
<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
|
|
type="button"
|
|
className="btn btn-ghost btn-sm"
|
|
onClick={onClose}
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
className="btn btn-primary btn-sm"
|
|
disabled={isPending || !domain.trim()}
|
|
>
|
|
{isPending && <Loader2 className="size-3.5 animate-spin" />}
|
|
{isEdit ? "Save Changes" : "Add Domain"}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</Modal>
|
|
);
|
|
}
|