refactor: use TanStack Form everywhere (#49)

* refactor: move lighthouse audits to dataforseo (#43)

* refactor: move lighthouse audits to dataforseo

* chore: remove obsolete audit settings modal

* refactor: rename psi flows to lighthouse

* save

* refactor: simplify audit lighthouse storage flow

* fix: separate lighthouse metrics from actionable audits

* refactor: remove redundant audit project inputs

* feat: redesign lighthouse issues screen with score gauges and table layout

Replace flat score cards with circular SVG gauges, condense metrics into
a compact grid, and switch issue list from cards to an expandable table
with fixed column widths.


* test: harden lighthouse regression coverage

* fix: restore project-scoped audit inputs

* refactor: simplify lighthouse payload handling

* refactor: inline lighthouse server handlers

* refactor: share audit workflow types

* refactor: simplify lighthouse payload flows

* save

* refactor: drop project pagespeed api key

* fix: restore lighthouse issues loading with resilient project context

* fix: restore audit issues back navigation

* refactor: simplify project context and lighthouse error handling

* fix: tolerate DataForSEO lighthouse payload drift

* refactor: route audit lighthouse through dataforseo client

---------


* refactor: unify app forms with TanStack Form

Replace bespoke field and submit error state so validation, async submit handling, and router-synced inputs behave consistently across the app.

* fix: remove dead code from ci check

* fix: delay required form errors until input interaction

* fix: preserve detailed DataForSEO error messages

* fix: sanitize internal errors and delay domain validation

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Senescu 2026-03-30 13:38:08 -04:00 committed by GitHub
parent 191879c87c
commit ae7712238e
25 changed files with 1131 additions and 1005 deletions

View File

@ -1,29 +1,17 @@
import type { FormEvent } from "react";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { import {
MAX_PAGES_LIMIT, MAX_PAGES_LIMIT,
MIN_PAGES, MIN_PAGES,
type LaunchFormApi,
type LaunchState,
} from "@/client/features/audit/launch/types"; } from "@/client/features/audit/launch/types";
import type { useLaunchController } from "@/client/features/audit/launch/useLaunchController";
import { getFieldError, getFormError } from "@/client/lib/forms";
export function LaunchFormCard({ type Props = {
launchForm, launchForm: ReturnType<typeof useLaunchController>["launchForm"];
state,
setState,
isPending,
onSubmit,
onRunLighthouseToggle,
commitMaxPagesInput,
}: {
launchForm: LaunchFormApi;
state: LaunchState;
setState: React.Dispatch<React.SetStateAction<LaunchState>>;
isPending: boolean;
onSubmit: (event: FormEvent) => void;
onRunLighthouseToggle: (checked: boolean) => void;
commitMaxPagesInput: () => number; commitMaxPagesInput: () => number;
}) { };
export function LaunchFormCard({ commitMaxPagesInput, launchForm }: Props) {
return ( return (
<div className="card bg-base-100 border border-base-300"> <div className="card bg-base-100 border border-base-300">
<div className="card-body gap-4"> <div className="card-body gap-4">
@ -31,32 +19,42 @@ export function LaunchFormCard({
<form <form
className="grid grid-cols-1 gap-3 lg:grid-cols-12 lg:items-center" className="grid grid-cols-1 gap-3 lg:grid-cols-12 lg:items-center"
onSubmit={onSubmit} onSubmit={(event) => {
> event.preventDefault();
<label void launchForm.handleSubmit();
className={`input input-bordered w-full lg:col-span-9 ${state.urlError ? "input-error" : ""}`} }}
> >
<launchForm.Field name="url"> <launchForm.Field name="url">
{(field) => ( {(field) => {
const urlError = getFieldError(field.state.meta.errors);
return (
<label
className={`input input-bordered w-full lg:col-span-9 ${urlError ? "input-error" : ""}`}
>
<input <input
placeholder="https://example.com" placeholder="https://example.com"
value={field.state.value} value={field.state.value}
onChange={(event) => { onChange={(event) => {
field.handleChange(event.target.value); field.handleChange(event.target.value);
if (state.urlError) if (launchForm.state.errorMap.onSubmit) {
setState((prev) => ({ ...prev, urlError: null })); launchForm.setErrorMap({ onSubmit: undefined });
}
}} }}
/> />
)}
</launchForm.Field>
</label> </label>
);
}}
</launchForm.Field>
<launchForm.Subscribe selector={(state) => state.isSubmitting}>
{(isSubmitting) => (
<button <button
type="submit" type="submit"
className="btn btn-primary btn-sm w-full lg:col-span-3" className="btn btn-primary btn-sm w-full lg:col-span-3"
disabled={isPending} disabled={isSubmitting}
> >
{isPending ? ( {isSubmitting ? (
<> <>
<Loader2 className="size-4 animate-spin" /> Starting... <Loader2 className="size-4 animate-spin" /> Starting...
</> </>
@ -64,32 +62,25 @@ export function LaunchFormCard({
"Start Audit" "Start Audit"
)} )}
</button> </button>
)}
</launchForm.Subscribe>
<div className="grid w-full grid-cols-1 gap-4 md:grid-cols-2 lg:col-span-12 lg:items-start"> <div className="grid w-full grid-cols-1 gap-4 md:grid-cols-2 lg:col-span-12 lg:items-start">
<LaunchOptions <LaunchOptions
launchForm={launchForm} launchForm={launchForm}
commitMaxPagesInput={commitMaxPagesInput} commitMaxPagesInput={commitMaxPagesInput}
/> />
<LighthouseOptions <LighthouseOptions launchForm={launchForm} />
launchForm={launchForm}
onRunLighthouseToggle={onRunLighthouseToggle}
/>
</div> </div>
</form> </form>
<LaunchErrors state={state} /> <LaunchErrors launchForm={launchForm} />
</div> </div>
</div> </div>
); );
} }
function LaunchOptions({ function LaunchOptions({ launchForm, commitMaxPagesInput }: Props) {
launchForm,
commitMaxPagesInput,
}: {
launchForm: LaunchFormApi;
commitMaxPagesInput: () => number;
}) {
return ( return (
<div className="rounded-lg border border-base-300 bg-base-200/20 p-3 space-y-2"> <div className="rounded-lg border border-base-300 bg-base-200/20 p-3 space-y-2">
<label className="text-xs font-medium uppercase tracking-wide text-base-content/60"> <label className="text-xs font-medium uppercase tracking-wide text-base-content/60">
@ -109,6 +100,9 @@ function LaunchOptions({
const next = event.target.value; const next = event.target.value;
if (!/^\d*$/.test(next)) return; if (!/^\d*$/.test(next)) return;
field.handleChange(next); field.handleChange(next);
if (launchForm.state.errorMap.onSubmit) {
launchForm.setErrorMap({ onSubmit: undefined });
}
}} }}
onBlur={commitMaxPagesInput} onBlur={commitMaxPagesInput}
/> />
@ -122,13 +116,7 @@ function LaunchOptions({
); );
} }
function LighthouseOptions({ function LighthouseOptions({ launchForm }: Pick<Props, "launchForm">) {
launchForm,
onRunLighthouseToggle,
}: {
launchForm: LaunchFormApi;
onRunLighthouseToggle: (checked: boolean) => void;
}) {
return ( return (
<div className="rounded-lg border border-base-300 bg-base-200/20 p-3 space-y-2"> <div className="rounded-lg border border-base-300 bg-base-200/20 p-3 space-y-2">
<label className="label cursor-pointer justify-start gap-2 p-0"> <label className="label cursor-pointer justify-start gap-2 p-0">
@ -138,7 +126,7 @@ function LighthouseOptions({
type="checkbox" type="checkbox"
className="toggle toggle-sm toggle-primary" className="toggle toggle-sm toggle-primary"
checked={Boolean(field.state.value)} checked={Boolean(field.state.value)}
onChange={(event) => onRunLighthouseToggle(event.target.checked)} onChange={(event) => field.handleChange(event.target.checked)}
/> />
)} )}
</launchForm.Field> </launchForm.Field>
@ -184,17 +172,30 @@ function LighthouseOptions({
); );
} }
function LaunchErrors({ state }: { state: LaunchState }) { function LaunchErrors({ launchForm }: Pick<Props, "launchForm">) {
return ( return (
<div className="space-y-2"> <div className="space-y-2">
{state.urlError ? ( <launchForm.Field name="url">
<p className="text-sm text-error">{state.urlError}</p> {(field) => {
) : null} const urlError = getFieldError(field.state.meta.errors);
{state.startError ? (
return urlError ? (
<p className="text-sm text-error">{urlError}</p>
) : null;
}}
</launchForm.Field>
<launchForm.Subscribe selector={(state) => state.errorMap.onSubmit}>
{(submitError) => {
const errorMessage = getFormError(submitError);
return errorMessage ? (
<div className="alert alert-error py-2"> <div className="alert alert-error py-2">
<span className="text-sm">{state.startError}</span> <span className="text-sm">{errorMessage}</span>
</div> </div>
) : null} ) : null;
}}
</launchForm.Subscribe>
</div> </div>
); );
} }

View File

@ -18,11 +18,6 @@ export function LaunchView({
<LaunchFormCard <LaunchFormCard
launchForm={controller.launchForm} launchForm={controller.launchForm}
state={controller.state}
setState={controller.setState}
isPending={controller.startMutation.isPending}
onSubmit={controller.handleSubmit}
onRunLighthouseToggle={controller.onRunLighthouseToggle}
commitMaxPagesInput={controller.commitMaxPagesInput} commitMaxPagesInput={controller.commitMaxPagesInput}
/> />

View File

@ -1,22 +1,16 @@
import { useForm } from "@tanstack/react-form";
export type LaunchState = {
urlError: string | null;
startError: string | null;
};
export const MIN_PAGES = 10; export const MIN_PAGES = 10;
export const MAX_PAGES_LIMIT = 10_000; export const MAX_PAGES_LIMIT = 10_000;
export function useLaunchForm() { export type LaunchFormValues = {
return useForm({ url: string;
defaultValues: { maxPagesInput: string;
runLighthouse: boolean;
lighthouseMode: "auto" | "all";
};
export const DEFAULT_LAUNCH_FORM_VALUES: LaunchFormValues = {
url: "", url: "",
maxPagesInput: "50", maxPagesInput: "50",
runLighthouse: false, runLighthouse: false,
lighthouseMode: "auto" as "auto" | "all", lighthouseMode: "auto",
}, };
});
}
export type LaunchFormApi = ReturnType<typeof useLaunchForm>;

View File

@ -1,4 +1,4 @@
import { useState, type FormEvent } from "react"; import { useForm } from "@tanstack/react-form";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
@ -7,13 +7,36 @@ import {
startAudit, startAudit,
} from "@/serverFunctions/audit"; } from "@/serverFunctions/audit";
import { import {
DEFAULT_LAUNCH_FORM_VALUES,
MAX_PAGES_LIMIT, MAX_PAGES_LIMIT,
MIN_PAGES, MIN_PAGES,
useLaunchForm, type LaunchFormValues,
type LaunchState,
} from "@/client/features/audit/launch/types"; } from "@/client/features/audit/launch/types";
import {
createFormValidationErrors,
shouldValidateFieldOnChange,
} from "@/client/lib/forms";
import { getStandardErrorMessage } from "@/client/lib/error-messages"; import { getStandardErrorMessage } from "@/client/lib/error-messages";
function getLaunchValidationErrors(
value: LaunchFormValues,
shouldValidateUntouchedField: boolean,
) {
if (value.url.trim()) {
return null;
}
if (!shouldValidateUntouchedField) {
return null;
}
return createFormValidationErrors({
fields: {
url: "Please enter a URL.",
},
});
}
export function useLaunchController({ export function useLaunchController({
projectId, projectId,
onAuditStarted, onAuditStarted,
@ -21,12 +44,6 @@ export function useLaunchController({
projectId: string; projectId: string;
onAuditStarted: (auditId: string) => void; onAuditStarted: (auditId: string) => void;
}) { }) {
const launchForm = useLaunchForm();
const [state, setState] = useState<LaunchState>({
urlError: null,
startError: null,
});
const historyQuery = useQuery({ const historyQuery = useQuery({
queryKey: ["audit-history", projectId], queryKey: ["audit-history", projectId],
queryFn: () => getAuditHistory({ data: { projectId } }), queryFn: () => getAuditHistory({ data: { projectId } }),
@ -36,74 +53,54 @@ export function useLaunchController({
historyRefetch: historyQuery.refetch, historyRefetch: historyQuery.refetch,
}); });
const applyMaxPages = (value: number) => { const launchForm = useForm({
const safeValue = Number.isFinite(value) defaultValues: DEFAULT_LAUNCH_FORM_VALUES,
? Math.max(MIN_PAGES, Math.min(MAX_PAGES_LIMIT, Math.round(value))) validators: {
: MIN_PAGES; onChange: ({ formApi, value }) =>
launchForm.setFieldValue("maxPagesInput", String(safeValue)); getLaunchValidationErrors(
return safeValue; value,
}; shouldValidateFieldOnChange(formApi, "url"),
),
const commitMaxPagesInput = () => { onSubmit: ({ value }) => getLaunchValidationErrors(value, true),
const maxPagesInput = launchForm.state.values.maxPagesInput; },
if (!maxPagesInput) return applyMaxPages(MIN_PAGES); onSubmit: async ({ formApi, value }) => {
return applyMaxPages(Number.parseInt(maxPagesInput, 10)); const effectiveMaxPages = commitMaxPagesInput(launchForm);
}; formApi.setErrorMap({ onSubmit: undefined });
const handleStart = () => {
const launchValues = launchForm.state.values;
const effectiveMaxPages = commitMaxPagesInput();
setState((prev) => ({ ...prev, startError: null }));
if (!launchValues.url.trim()) {
return setState((prev) => ({ ...prev, urlError: "Please enter a URL." }));
}
if (effectiveMaxPages > 500) { if (effectiveMaxPages > 500) {
const confirmed = window.confirm( const confirmed = window.confirm(
`You are about to crawl ${effectiveMaxPages.toLocaleString()} pages. This is okay, but it may take a while. Continue?`, `You are about to crawl ${effectiveMaxPages.toLocaleString()} pages. This is okay, but it may take a while. Continue?`,
); );
if (!confirmed) return; if (!confirmed) {
return;
}
} }
startMutation.mutate( try {
{ const result = await startMutation.mutateAsync({
projectId, projectId,
startUrl: launchValues.url, startUrl: value.url,
maxPages: effectiveMaxPages, maxPages: effectiveMaxPages,
lighthouseStrategy: launchValues.runLighthouse lighthouseStrategy: value.runLighthouse
? launchValues.lighthouseMode ? value.lighthouseMode
: "none", : "none",
}, });
{
onSuccess: (result) => {
setState({ urlError: null, startError: null });
toast.success("Audit started!"); toast.success("Audit started!");
onAuditStarted(result.auditId); onAuditStarted(result.auditId);
} catch (error) {
formApi.setErrorMap({
onSubmit: createFormValidationErrors({
form: getStandardErrorMessage(error, "Failed to start audit"),
}),
});
}
}, },
onError: (error) => { });
setState((prev) => ({
...prev,
startError: getStandardErrorMessage(error, "Failed to start audit"),
}));
},
},
);
};
return { return {
launchForm, launchForm,
state,
setState,
historyQuery, historyQuery,
startMutation, commitMaxPagesInput: () => commitMaxPagesInput(launchForm),
commitMaxPagesInput,
handleSubmit: (event: FormEvent) => {
event.preventDefault();
handleStart();
},
onRunLighthouseToggle: (checked: boolean) =>
handleRunLighthouseToggle(checked, launchForm),
deleteAudit: (auditId: string) => deleteMutation.mutate(auditId), deleteAudit: (auditId: string) => deleteMutation.mutate(auditId),
}; };
} }
@ -136,9 +133,24 @@ function useLaunchMutations({
return { startMutation, deleteMutation }; return { startMutation, deleteMutation };
} }
function handleRunLighthouseToggle( function applyMaxPages(
checked: boolean, launchForm: {
launchForm: ReturnType<typeof useLaunchForm>, setFieldValue: (field: "maxPagesInput", value: string) => void;
},
value: number,
) { ) {
launchForm.setFieldValue("runLighthouse", checked); const safeValue = Number.isFinite(value)
? Math.max(MIN_PAGES, Math.min(MAX_PAGES_LIMIT, Math.round(value)))
: MIN_PAGES;
launchForm.setFieldValue("maxPagesInput", String(safeValue));
return safeValue;
}
function commitMaxPagesInput(launchForm: {
state: { values: { maxPagesInput: string } };
setFieldValue: (field: "maxPagesInput", value: string) => void;
}) {
const maxPagesInput = launchForm.state.values.maxPagesInput;
if (!maxPagesInput) return applyMaxPages(launchForm, MIN_PAGES);
return applyMaxPages(launchForm, Number.parseInt(maxPagesInput, 10));
} }

View File

@ -2,6 +2,10 @@ import { z } from "zod";
import { normalizeAuthRedirect } from "@/lib/auth-redirect"; import { normalizeAuthRedirect } from "@/lib/auth-redirect";
import { useSession } from "@/lib/auth-client"; import { useSession } from "@/lib/auth-client";
import { isHostedClientAuthMode } from "@/lib/auth-mode"; import { isHostedClientAuthMode } from "@/lib/auth-mode";
import {
getFieldError as getSharedFieldError,
getFormError as getSharedFormError,
} from "@/client/lib/forms";
export const authRedirectSearchSchema = z.object({ export const authRedirectSearchSchema = z.object({
redirect: z.string().optional(), redirect: z.string().optional(),
@ -19,20 +23,12 @@ export function useAuthPageState(redirect: string | undefined) {
}; };
} }
export function getFieldError(errors: unknown[]) { export function getFieldError(errors: readonly unknown[]) {
const first = errors[0]; return getSharedFieldError(errors);
if (typeof first === "string") return first;
if (first && typeof first === "object" && "message" in first)
return String((first as { message: unknown }).message);
return null;
} }
export function getFormError(error: unknown): string | null { export function getFormError(error: unknown) {
if (!error) return null; return getSharedFormError(error);
if (typeof error === "string") return error;
if (typeof error === "object" && "form" in error)
return String((error as { form: unknown }).form);
return null;
} }
export function AuthPageCard({ export function AuthPageCard({

View File

@ -1,5 +1,11 @@
import { useEffect, useState } from "react";
import { useForm } from "@tanstack/react-form";
import { Search } from "lucide-react"; import { Search } from "lucide-react";
import { useEffect, useState, type FormEvent } from "react"; import {
createFormValidationErrors,
getFieldError,
shouldValidateFieldOnChange,
} from "@/client/lib/forms";
import type { BacklinksSearchState } from "./backlinksPageTypes"; import type { BacklinksSearchState } from "./backlinksPageTypes";
import { resolveBacklinksSearchScope } from "./backlinksSearchScope"; import { resolveBacklinksSearchScope } from "./backlinksSearchScope";
@ -8,12 +14,23 @@ type SearchDraft = Pick<
"target" | "scope" | "subdomains" | "indirect" | "excludeInternal" | "status" "target" | "scope" | "subdomains" | "indirect" | "excludeInternal" | "status"
>; >;
function toBacklinksStatus(value: string): SearchDraft["status"] { function getBacklinksValidationErrors(
if (value === "live" || value === "lost" || value === "all") { value: SearchDraft,
return value; shouldValidateUntouchedField: boolean,
) {
if (value.target.trim()) {
return null;
} }
return "live"; if (!shouldValidateUntouchedField) {
return null;
}
return createFormValidationErrors({
fields: {
target: "Enter a domain or URL to analyze.",
},
});
} }
export function BacklinksSearchCard({ export function BacklinksSearchCard({
@ -27,90 +44,221 @@ export function BacklinksSearchCard({
isFetching: boolean; isFetching: boolean;
onSubmit: (values: SearchDraft) => void; onSubmit: (values: SearchDraft) => void;
}) { }) {
const [targetInput, setTargetInput] = useState(initialValues.target);
const [scope, setScope] = useState(initialValues.scope);
const [includeSubdomains, setIncludeSubdomains] = useState(
initialValues.subdomains,
);
const [includeIndirectLinks, setIncludeIndirectLinks] = useState(
initialValues.indirect,
);
const [excludeInternalBacklinks, setExcludeInternalBacklinks] = useState(
initialValues.excludeInternal,
);
const [status, setStatus] = useState(initialValues.status);
const [showAdvanced, setShowAdvanced] = useState(false); const [showAdvanced, setShowAdvanced] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
const [userSelectedScope, setUserSelectedScope] = useState(false); const [userSelectedScope, setUserSelectedScope] = useState(false);
const form = useForm({
useEffect(() => { defaultValues: initialValues,
setTargetInput(initialValues.target); validators: {
setScope(initialValues.scope); onChange: ({ formApi, value }) =>
setIncludeSubdomains(initialValues.subdomains); getBacklinksValidationErrors(
setIncludeIndirectLinks(initialValues.indirect); value,
setExcludeInternalBacklinks(initialValues.excludeInternal); shouldValidateFieldOnChange(formApi, "target"),
setStatus(initialValues.status); ),
setFormError(null); onSubmit: ({ value }) => getBacklinksValidationErrors(value, true),
setUserSelectedScope(false); },
}, [initialValues]); onSubmit: ({ value }) => {
const target = value.target.trim();
const handleSubmit = (event: FormEvent) => {
event.preventDefault();
const target = targetInput.trim();
if (!target) {
setFormError("Enter a domain or URL to analyze.");
return;
}
setFormError(null);
onSubmit({ onSubmit({
...value,
target, target,
scope: resolveBacklinksSearchScope({ scope: resolveBacklinksSearchScope({
target, target,
selectedScope: scope, selectedScope: value.scope,
userSelectedScope, userSelectedScope,
}), }),
subdomains: includeSubdomains,
indirect: includeIndirectLinks,
excludeInternal: excludeInternalBacklinks,
status,
}); });
}; },
});
useEffect(() => {
form.reset(initialValues);
setUserSelectedScope(false);
}, [form, initialValues]);
return ( return (
<div className="card bg-base-100 border border-base-300"> <div className="card bg-base-100 border border-base-300">
<div className="card-body gap-4"> <div className="card-body gap-4">
<form className="space-y-3" onSubmit={handleSubmit}> <form
<SearchControls className="space-y-3"
formError={formError} onSubmit={(event) => {
isFetching={isFetching} event.preventDefault();
onScopeChange={setScope} void form.handleSubmit();
onStatusChange={(value) => setStatus(toBacklinksStatus(value))} }}
onTargetInputChange={setTargetInput} >
setFormError={setFormError} <div className="space-y-3">
scope={scope} <div className="grid grid-cols-1 gap-3 lg:grid-cols-12">
status={status} <form.Field name="target">
targetInput={targetInput} {(field) => {
userSelectedScope={userSelectedScope} const targetError = getFieldError(field.state.meta.errors);
onUserSelectedScopeChange={setUserSelectedScope}
return (
<label
className={`input input-bordered lg:col-span-8 flex items-center gap-2 ${targetError ? "input-error" : ""}`}
>
<Search className="size-4 text-base-content/60" />
<input
placeholder="Enter a domain or URL"
value={field.state.value}
onChange={(event) => {
const nextTarget = event.target.value;
field.handleChange(nextTarget);
if (!userSelectedScope) {
form.setFieldValue(
"scope",
resolveBacklinksSearchScope({
target: nextTarget,
selectedScope: form.state.values.scope,
userSelectedScope: false,
}),
);
}
}}
/> />
<SearchToggles </label>
includeSubdomains={includeSubdomains} );
onIncludeSubdomainsChange={setIncludeSubdomains} }}
showAdvanced={showAdvanced} </form.Field>
toggleAdvanced={() => setShowAdvanced((current) => !current)}
<form.Field name="status">
{(field) => (
<select
className="select select-bordered lg:col-span-2"
value={field.state.value}
onChange={(event) =>
field.handleChange(
event.target.value === "lost"
? "lost"
: event.target.value === "all"
? "all"
: "live",
)
}
>
<option value="live">Live links</option>
<option value="lost">Lost links</option>
<option value="all">All links</option>
</select>
)}
</form.Field>
<form.Subscribe selector={(state) => state.isSubmitting}>
{(isSubmitting) => (
<button
type="submit"
className="btn btn-primary lg:col-span-2"
disabled={isFetching || isSubmitting}
>
{isFetching || isSubmitting ? "Loading..." : "Search"}
</button>
)}
</form.Subscribe>
</div>
<form.Field name="target">
{(field) => {
const targetError = getFieldError(field.state.meta.errors);
return targetError ? (
<p className="text-sm text-error">{targetError}</p>
) : null;
}}
</form.Field>
<div className="flex items-center gap-1">
<form.Field name="scope">
{(field) => (
<>
<button
type="button"
className={`btn btn-xs ${field.state.value === "domain" ? "btn-soft" : "btn-ghost"}`}
onClick={() => {
setUserSelectedScope(true);
field.handleChange("domain");
}}
>
Site-wide
</button>
<button
type="button"
className={`btn btn-xs ${field.state.value === "page" ? "btn-soft" : "btn-ghost"}`}
onClick={() => {
setUserSelectedScope(true);
field.handleChange("page");
}}
>
Exact page
</button>
</>
)}
</form.Field>
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<form.Field name="subdomains">
{(field) => (
<label className="label cursor-pointer gap-2 py-0">
<input
type="checkbox"
className="checkbox checkbox-sm"
checked={field.state.value}
onChange={(event) =>
field.handleChange(event.target.checked)
}
/> />
<span className="label-text">Include subdomains</span>
</label>
)}
</form.Field>
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={() => setShowAdvanced((current) => !current)}
>
{showAdvanced ? "Hide advanced" : "Show advanced"}
</button>
</div>
{showAdvanced ? ( {showAdvanced ? (
<AdvancedSearchOptions <div className="grid grid-cols-1 gap-3 rounded-xl border border-base-300 bg-base-200/40 p-4 text-sm md:grid-cols-2">
excludeInternalBacklinks={excludeInternalBacklinks} <form.Field name="indirect">
includeIndirectLinks={includeIndirectLinks} {(field) => (
onExcludeInternalChange={setExcludeInternalBacklinks} <label className="label cursor-pointer justify-start gap-3 py-0">
onIncludeIndirectChange={setIncludeIndirectLinks} <input
type="checkbox"
className="checkbox checkbox-sm"
checked={field.state.value}
onChange={(event) =>
field.handleChange(event.target.checked)
}
/> />
<span className="label-text">Include indirect links</span>
</label>
)}
</form.Field>
<form.Field name="excludeInternal">
{(field) => (
<label className="label cursor-pointer justify-start gap-3 py-0">
<input
type="checkbox"
className="checkbox checkbox-sm"
checked={field.state.value}
onChange={(event) =>
field.handleChange(event.target.checked)
}
/>
<span className="label-text">
Exclude internal backlinks
</span>
</label>
)}
</form.Field>
</div>
) : null} ) : null}
</form> </form>
{formError ? <p className="text-sm text-error">{formError}</p> : null}
{errorMessage ? ( {errorMessage ? (
<div className="rounded-lg border border-error/30 bg-error/10 p-3 text-sm text-error"> <div className="rounded-lg border border-error/30 bg-error/10 p-3 text-sm text-error">
{errorMessage} {errorMessage}
@ -120,169 +268,3 @@ export function BacklinksSearchCard({
</div> </div>
); );
} }
function SearchControls({
formError,
isFetching,
onScopeChange,
onStatusChange,
onTargetInputChange,
onUserSelectedScopeChange,
setFormError,
scope,
status,
targetInput,
userSelectedScope,
}: {
formError: string | null;
isFetching: boolean;
onScopeChange: (value: BacklinksSearchState["scope"]) => void;
onStatusChange: (value: string) => void;
onTargetInputChange: (value: string) => void;
onUserSelectedScopeChange: (value: boolean) => void;
setFormError: (value: string | null) => void;
scope: BacklinksSearchState["scope"];
status: BacklinksSearchState["status"];
targetInput: string;
userSelectedScope: boolean;
}) {
return (
<div className="space-y-3">
<div className="grid grid-cols-1 gap-3 lg:grid-cols-12">
<label
className={`input input-bordered lg:col-span-8 flex items-center gap-2 ${formError ? "input-error" : ""}`}
>
<Search className="size-4 text-base-content/60" />
<input
placeholder={
scope === "page"
? "Enter a page URL or domain"
: "Enter a domain or URL"
}
value={targetInput}
onChange={(event) => {
const nextTarget = event.target.value;
onTargetInputChange(nextTarget);
if (!userSelectedScope) {
onScopeChange(
resolveBacklinksSearchScope({
target: nextTarget,
selectedScope: scope,
userSelectedScope: false,
}),
);
}
if (formError) setFormError(null);
}}
/>
</label>
<select
className="select select-bordered lg:col-span-2"
value={status}
onChange={(event) => onStatusChange(event.target.value)}
>
<option value="live">Live links</option>
<option value="lost">Lost links</option>
<option value="all">All links</option>
</select>
<button
type="submit"
className="btn btn-primary lg:col-span-2"
disabled={isFetching}
>
{isFetching ? "Loading..." : "Search"}
</button>
</div>
<div className="flex items-center gap-1">
<button
type="button"
className={`btn btn-xs ${scope === "domain" ? "btn-soft" : "btn-ghost"}`}
onClick={() => {
onUserSelectedScopeChange(true);
onScopeChange("domain");
}}
>
Site-wide
</button>
<button
type="button"
className={`btn btn-xs ${scope === "page" ? "btn-soft" : "btn-ghost"}`}
onClick={() => {
onUserSelectedScopeChange(true);
onScopeChange("page");
}}
>
Exact page
</button>
</div>
</div>
);
}
function SearchToggles({
includeSubdomains,
onIncludeSubdomainsChange,
showAdvanced,
toggleAdvanced,
}: {
includeSubdomains: boolean;
onIncludeSubdomainsChange: (checked: boolean) => void;
showAdvanced: boolean;
toggleAdvanced: () => void;
}) {
return (
<div className="flex flex-wrap items-center gap-3">
<label className="label cursor-pointer gap-2 py-0">
<input
type="checkbox"
className="checkbox checkbox-sm"
checked={includeSubdomains}
onChange={(event) => onIncludeSubdomainsChange(event.target.checked)}
/>
<span className="label-text">Include subdomains</span>
</label>
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={toggleAdvanced}
>
{showAdvanced ? "Hide advanced" : "Show advanced"}
</button>
</div>
);
}
function AdvancedSearchOptions({
excludeInternalBacklinks,
includeIndirectLinks,
onExcludeInternalChange,
onIncludeIndirectChange,
}: {
excludeInternalBacklinks: boolean;
includeIndirectLinks: boolean;
onExcludeInternalChange: (checked: boolean) => void;
onIncludeIndirectChange: (checked: boolean) => void;
}) {
return (
<div className="grid grid-cols-1 gap-3 rounded-xl border border-base-300 bg-base-200/40 p-4 text-sm md:grid-cols-2">
<label className="label cursor-pointer justify-start gap-3 py-0">
<input
type="checkbox"
className="checkbox checkbox-sm"
checked={includeIndirectLinks}
onChange={(event) => onIncludeIndirectChange(event.target.checked)}
/>
<span className="label-text">Include indirect links</span>
</label>
<label className="label cursor-pointer justify-start gap-3 py-0">
<input
type="checkbox"
className="checkbox checkbox-sm"
checked={excludeInternalBacklinks}
onChange={(event) => onExcludeInternalChange(event.target.checked)}
/>
<span className="label-text">Exclude internal backlinks</span>
</label>
</div>
);
}

View File

@ -57,16 +57,11 @@ export function DomainOverviewPage({
<DomainSearchCard <DomainSearchCard
controlsForm={state.controlsForm} controlsForm={state.controlsForm}
domainError={state.domainError}
overviewError={state.overviewError}
isLoading={state.isLoading} isLoading={state.isLoading}
onSubmit={state.handleSearchSubmit} onSubmit={state.handleSearchSubmit}
onSortChange={(sort) => onSortChange={(sort) =>
state.applySort(sort, getDefaultSortOrder(sort)) state.applySort(sort, getDefaultSortOrder(sort))
} }
onDomainInput={() => {
if (state.domainError) state.setDomainError(null);
}}
/> />
{state.isLoading ? ( {state.isLoading ? (

View File

@ -1,81 +1,22 @@
import type { ComponentType, FormEvent, ReactNode } from "react"; import type { FormEvent } from "react";
import { AlertCircle, Search } from "lucide-react"; import { AlertCircle, Search } from "lucide-react";
import { getFieldError, getFormError } from "@/client/lib/forms";
import type { useDomainOverviewController } from "@/client/features/domain/useDomainOverviewController";
import { toSortMode } from "@/client/features/domain/utils"; import { toSortMode } from "@/client/features/domain/utils";
import type { DomainSortMode } from "@/client/features/domain/types"; import type { DomainSortMode } from "@/client/features/domain/types";
type FieldHost = {
Field: ComponentType<{
name: "domain" | "sort" | "subdomains";
children: (field: unknown) => ReactNode;
}>;
};
type TextField = {
state: { value: string };
handleChange: (value: string) => void;
};
type SortField = {
state: { value: DomainSortMode };
handleChange: (value: DomainSortMode) => void;
};
type ToggleField = {
state: { value: boolean };
handleChange: (value: boolean) => void;
};
function isTextField(field: unknown): field is TextField {
if (!field || typeof field !== "object") return false;
const candidate = field as {
state?: { value?: unknown };
handleChange?: unknown;
};
return (
typeof candidate.handleChange === "function" &&
typeof candidate.state?.value === "string"
);
}
function isSortField(field: unknown): field is SortField {
if (!isTextField(field)) return false;
return (
field.state.value === "rank" ||
field.state.value === "traffic" ||
field.state.value === "volume"
);
}
function isToggleField(field: unknown): field is ToggleField {
if (!field || typeof field !== "object") return false;
const candidate = field as {
state?: { value?: unknown };
handleChange?: unknown;
};
return (
typeof candidate.handleChange === "function" &&
typeof candidate.state?.value === "boolean"
);
}
type Props = { type Props = {
controlsForm: FieldHost; controlsForm: ReturnType<typeof useDomainOverviewController>["controlsForm"];
domainError: string | null;
overviewError: string | null;
isLoading: boolean; isLoading: boolean;
onSubmit: (event: FormEvent) => void; onSubmit: (event: FormEvent) => void;
onSortChange: (sort: DomainSortMode) => void; onSortChange: (sort: DomainSortMode) => void;
onDomainInput: () => void;
}; };
export function DomainSearchCard({ export function DomainSearchCard({
controlsForm, controlsForm,
domainError,
overviewError,
isLoading, isLoading,
onSubmit, onSubmit,
onSortChange, onSortChange,
onDomainInput,
}: Props) { }: Props) {
return ( return (
<div className="card bg-base-100 border border-base-300"> <div className="card bg-base-100 border border-base-300">
@ -84,40 +25,36 @@ export function DomainSearchCard({
className="grid grid-cols-1 gap-3 lg:grid-cols-12" className="grid grid-cols-1 gap-3 lg:grid-cols-12"
onSubmit={onSubmit} onSubmit={onSubmit}
> >
<controlsForm.Field name="domain">
{(field) => {
const domainError = getFieldError(field.state.meta.errors);
return (
<label <label
className={`input input-bordered lg:col-span-8 flex items-center gap-2 ${domainError ? "input-error" : ""}`} className={`input input-bordered lg:col-span-8 flex items-center gap-2 ${domainError ? "input-error" : ""}`}
> >
<Search className="size-4 text-base-content/60" /> <Search className="size-4 text-base-content/60" />
<controlsForm.Field name="domain">
{(field) => {
if (!isTextField(field)) return null;
return (
<input <input
placeholder="Enter a domain (e.g. coolify.io or example.com/blog)" placeholder="Enter a domain (e.g. coolify.io or example.com/blog)"
value={field.state.value} value={field.state.value}
onChange={(e) => { onChange={(event) => field.handleChange(event.target.value)}
field.handleChange(e.target.value);
onDomainInput();
}}
aria-invalid={domainError ? true : undefined} aria-invalid={domainError ? true : undefined}
aria-describedby={ aria-describedby={
domainError ? "domain-input-error" : undefined domainError ? "domain-input-error" : undefined
} }
/> />
</label>
); );
}} }}
</controlsForm.Field> </controlsForm.Field>
</label>
<controlsForm.Field name="sort"> <controlsForm.Field name="sort">
{(field) => { {(field) => (
if (!isSortField(field)) return null;
return (
<select <select
className="select select-bordered lg:col-span-2" className="select select-bordered lg:col-span-2"
value={field.state.value} value={field.state.value}
onChange={(e) => { onChange={(event) => {
const next = toSortMode(e.target.value) ?? "rank"; const next = toSortMode(event.target.value) ?? "rank";
field.handleChange(next); field.handleChange(next);
onSortChange(next); onSortChange(next);
}} }}
@ -126,46 +63,58 @@ export function DomainSearchCard({
<option value="traffic">By Traffic</option> <option value="traffic">By Traffic</option>
<option value="volume">By Volume</option> <option value="volume">By Volume</option>
</select> </select>
); )}
}}
</controlsForm.Field> </controlsForm.Field>
<controlsForm.Subscribe selector={(state) => state.isSubmitting}>
{(isSubmitting) => (
<button <button
type="submit" type="submit"
className="btn btn-primary lg:col-span-2" className="btn btn-primary lg:col-span-2"
disabled={isLoading} disabled={isLoading || isSubmitting}
> >
{isLoading ? "Loading..." : "Search"} {isLoading || isSubmitting ? "Loading..." : "Search"}
</button> </button>
)}
</controlsForm.Subscribe>
</form> </form>
{domainError ? ( <controlsForm.Field name="domain">
{(field) => {
const domainError = getFieldError(field.state.meta.errors);
return domainError ? (
<p id="domain-input-error" className="text-sm text-error"> <p id="domain-input-error" className="text-sm text-error">
{domainError} {domainError}
</p> </p>
) : null} ) : null;
}}
</controlsForm.Field>
{overviewError ? ( <controlsForm.Subscribe selector={(state) => state.errorMap.onSubmit}>
{(submitError) => {
const errorMessage = getFormError(submitError);
return errorMessage ? (
<div className="rounded-lg border border-error/30 bg-error/10 p-3 text-sm text-error flex items-start gap-2"> <div className="rounded-lg border border-error/30 bg-error/10 p-3 text-sm text-error flex items-start gap-2">
<AlertCircle className="size-4 shrink-0 mt-0.5" /> <AlertCircle className="size-4 shrink-0 mt-0.5" />
<span>{overviewError}</span> <span>{errorMessage}</span>
</div> </div>
) : null} ) : null;
}}
</controlsForm.Subscribe>
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<label className="label cursor-pointer gap-2 py-0"> <label className="label cursor-pointer gap-2 py-0">
<controlsForm.Field name="subdomains"> <controlsForm.Field name="subdomains">
{(field) => { {(field) => (
if (!isToggleField(field)) return null;
return (
<input <input
type="checkbox" type="checkbox"
className="checkbox checkbox-sm" className="checkbox checkbox-sm"
checked={field.state.value} checked={field.state.value}
onChange={(e) => field.handleChange(e.target.checked)} onChange={(event) => field.handleChange(event.target.checked)}
/> />
); )}
}}
</controlsForm.Field> </controlsForm.Field>
<span className="label-text">Include subdomains</span> <span className="label-text">Include subdomains</span>
</label> </label>

View File

@ -45,6 +45,11 @@ type DomainControlsFormAccess = {
sort: DomainSortMode; sort: DomainSortMode;
}; };
}; };
reset: (values: {
domain: string;
subdomains: boolean;
sort: DomainSortMode;
}) => void;
setFieldValue: ( setFieldValue: (
field: "domain" | "subdomains" | "sort", field: "domain" | "subdomains" | "sort",
updater: string | boolean, updater: string | boolean,
@ -152,10 +157,11 @@ export function useOverviewDataState({
setSelectedKeywords((prev) => { setSelectedKeywords((prev) => {
if ( if (
visibleKeywords.length > 0 && visibleKeywords.length > 0 &&
visibleKeywords.every((k) => prev.has(k)) visibleKeywords.every((keyword) => prev.has(keyword))
) { ) {
return new Set(); return new Set();
} }
return new Set(visibleKeywords); return new Set(visibleKeywords);
}); });
}, },
@ -174,9 +180,11 @@ export function useSyncRouteState({
navigate: DomainNavigate; navigate: DomainNavigate;
}) { }) {
useEffect(() => { useEffect(() => {
controlsForm.setFieldValue("domain", searchState.domain); controlsForm.reset({
controlsForm.setFieldValue("subdomains", searchState.subdomains); domain: searchState.domain,
controlsForm.setFieldValue("sort", searchState.sort); subdomains: searchState.subdomains,
sort: searchState.sort,
});
setPendingSearch(searchState.search); setPendingSearch(searchState.search);
}, [controlsForm, searchState, setPendingSearch]); }, [controlsForm, searchState, setPendingSearch]);
@ -217,13 +225,7 @@ export function useSyncRouteState({
}, [navigate]); }, [navigate]);
} }
export function useDomainLookupMutation({ export function useDomainLookupMutation() {
setOverview,
setOverviewError,
}: {
setOverview: (value: DomainOverviewData) => void;
setOverviewError: (value: string | null) => void;
}) {
return useMutation({ return useMutation({
mutationFn: (data: { mutationFn: (data: {
domain: string; domain: string;
@ -231,20 +233,11 @@ export function useDomainLookupMutation({
locationCode: number; locationCode: number;
languageCode: string; languageCode: string;
}) => getDomainOverview({ data }), }) => getDomainOverview({ data }),
onError: (error) => {
setOverviewError(getStandardErrorMessage(error, "Lookup failed."));
},
onSuccess: (response) => {
setOverview(response);
if (!response.hasData) toast.info("Not enough data for this domain");
},
}); });
} }
export function useSearchRunner({ export function useSearchRunner({
controlsForm, controlsForm,
setDomainError,
setOverviewError,
setPendingSearch, setPendingSearch,
setSearchParams, setSearchParams,
domainMutation, domainMutation,
@ -255,20 +248,18 @@ export function useSearchRunner({
currentSortOrder, currentSortOrder,
}: { }: {
controlsForm: ControlsFormLike; controlsForm: ControlsFormLike;
setDomainError: (value: string | null) => void;
setOverviewError: (value: string | null) => void;
setPendingSearch: (value: string) => void; setPendingSearch: (value: string) => void;
setSearchParams: ( setSearchParams: (
updates: Record<string, string | boolean | undefined>, updates: Record<string, string | boolean | undefined>,
) => void; ) => void;
domainMutation: ReturnType<typeof useDomainLookupMutation>["mutate"]; domainMutation: ReturnType<typeof useDomainLookupMutation>;
addSearch: (item: Omit<DomainSearchHistoryItem, "timestamp">) => void; addSearch: (item: Omit<DomainSearchHistoryItem, "timestamp">) => void;
setOverview: (value: DomainOverviewData) => void; setOverview: (value: DomainOverviewData) => void;
setSelectedKeywords: (value: Set<string>) => void; setSelectedKeywords: Dispatch<SetStateAction<Set<string>>>;
currentState: SearchState; currentState: SearchState;
currentSortOrder: SortOrder; currentSortOrder: SortOrder;
}) { }) {
return (params?: Partial<SearchState>) => { return async (params?: Partial<SearchState>) => {
const values = controlsForm.state.values; const values = controlsForm.state.values;
const rawTarget = params?.domain ?? values.domain; const rawTarget = params?.domain ?? values.domain;
const activeSubdomains = params?.subdomains ?? values.subdomains; const activeSubdomains = params?.subdomains ?? values.subdomains;
@ -276,22 +267,12 @@ export function useSearchRunner({
const activeOrder = params?.order ?? currentSortOrder; const activeOrder = params?.order ?? currentSortOrder;
const activeTab = params?.tab ?? currentState.tab; const activeTab = params?.tab ?? currentState.tab;
const activeSearch = params?.search ?? currentState.search; const activeSearch = params?.search ?? currentState.search;
if (!rawTarget.trim()) {
setDomainError("Please enter a domain");
return;
}
const target = normalizeDomainTarget(rawTarget); const target = normalizeDomainTarget(rawTarget);
if (!target) { if (!target) {
setDomainError(
"Please enter a valid URL or domain (e.g. browserbase.com)",
);
return; return;
} }
setDomainError(null);
setOverviewError(null);
setPendingSearch(activeSearch); setPendingSearch(activeSearch);
controlsForm.setFieldValue("domain", target); controlsForm.setFieldValue("domain", target);
controlsForm.setFieldValue("subdomains", activeSubdomains); controlsForm.setFieldValue("subdomains", activeSubdomains);
@ -306,15 +287,14 @@ export function useSearchRunner({
search: activeSearch.trim() || undefined, search: activeSearch.trim() || undefined,
}); });
domainMutation( try {
{ const response = await domainMutation.mutateAsync({
domain: target, domain: target,
includeSubdomains: activeSubdomains, includeSubdomains: activeSubdomains,
locationCode: 2840, locationCode: 2840,
languageCode: "en", languageCode: "en",
}, });
{
onSuccess: (response) => {
setOverview(response); setOverview(response);
setSelectedKeywords(new Set()); setSelectedKeywords(new Set());
addSearch({ addSearch({
@ -324,11 +304,13 @@ export function useSearchRunner({
tab: activeTab, tab: activeTab,
search: activeSearch.trim() || undefined, search: activeSearch.trim() || undefined,
}); });
},
onError: (error) => { if (!response.hasData) {
setOverviewError(getStandardErrorMessage(error, "Lookup failed.")); toast.info("Not enough data for this domain");
}, }
}, return null;
); } catch (error) {
return getStandardErrorMessage(error, "Lookup failed.");
}
}; };
} }

View File

@ -1,16 +1,21 @@
import { useCallback, useEffect, useState, type FormEvent } from "react"; import { useCallback, useEffect, useState, type FormEvent } from "react";
import { type QueryClient } from "@tanstack/react-query";
import { useForm } from "@tanstack/react-form"; import { useForm } from "@tanstack/react-form";
import { type QueryClient } from "@tanstack/react-query";
import { import {
useDomainSearchHistory, useDomainSearchHistory,
type DomainSearchHistoryItem, type DomainSearchHistoryItem,
} from "@/client/hooks/useDomainSearchHistory"; } from "@/client/hooks/useDomainSearchHistory";
import { import {
getDefaultSortOrder, getDefaultSortOrder,
normalizeDomainTarget,
resolveSortOrder, resolveSortOrder,
toSortOrderSearchParam, toSortOrderSearchParam,
toSortSearchParam, toSortSearchParam,
} from "@/client/features/domain/utils"; } from "@/client/features/domain/utils";
import {
createFormValidationErrors,
shouldValidateFieldOnChange,
} from "@/client/lib/forms";
import type { import type {
DomainControlsValues, DomainControlsValues,
DomainOverviewData, DomainOverviewData,
@ -37,8 +42,60 @@ type Params = {
searchState: SearchState; searchState: SearchState;
}; };
function useDomainControlsForm(defaultValues: DomainControlsValues) { type DomainControlsFormApi = {
return useForm({ defaultValues }); state: {
values: DomainControlsValues;
};
handleSubmit: () => Promise<unknown>;
reset: (values: DomainControlsValues) => void;
setFieldValue: (
field: keyof DomainControlsValues,
value: string | boolean,
) => void;
};
function getDomainSearchValidationErrors(value: DomainControlsValues) {
if (!value.domain.trim()) {
return createFormValidationErrors({
fields: {
domain: "Please enter a domain",
},
});
}
if (!normalizeDomainTarget(value.domain)) {
return createFormValidationErrors({
fields: {
domain: "Please enter a valid URL or domain (e.g. browserbase.com)",
},
});
}
return null;
}
function getDomainSearchChangeValidationErrors(
value: DomainControlsValues,
shouldValidateUntouchedField: boolean,
shouldValidateFormat: boolean,
) {
if (!value.domain.trim()) {
if (!shouldValidateUntouchedField) {
return null;
}
return createFormValidationErrors({
fields: {
domain: "Please enter a domain",
},
});
}
if (!shouldValidateFormat) {
return null;
}
return getDomainSearchValidationErrors(value);
} }
export function useDomainOverviewController({ export function useDomainOverviewController({
@ -47,18 +104,11 @@ export function useDomainOverviewController({
navigate, navigate,
searchState, searchState,
}: Params) { }: Params) {
const [domainError, setDomainError] = useState<string | null>(null);
const [overviewError, setOverviewError] = useState<string | null>(null);
const [pendingSearch, setPendingSearch] = useState(searchState.search); const [pendingSearch, setPendingSearch] = useState(searchState.search);
const [overview, setOverview] = useState<DomainOverviewData | null>(null); const [overview, setOverview] = useState<DomainOverviewData | null>(null);
const [selectedKeywords, setSelectedKeywords] = useState<Set<string>>( const [selectedKeywords, setSelectedKeywords] = useState<Set<string>>(
new Set(), new Set(),
); );
const controlsForm = useDomainControlsForm({
domain: searchState.domain,
subdomains: searchState.subdomains,
sort: searchState.sort,
});
const { history, isLoaded, addSearch, clearHistory, removeHistoryItem } = const { history, isLoaded, addSearch, clearHistory, removeHistoryItem } =
useDomainSearchHistory(projectId); useDomainSearchHistory(projectId);
@ -76,11 +126,41 @@ export function useDomainOverviewController({
[navigate], [navigate],
); );
useSyncRouteState({ controlsForm, searchState, setPendingSearch, navigate }); const controlsForm = useForm({
const domainMutation = useDomainLookupMutation({ defaultValues: {
setOverview, domain: searchState.domain,
setOverviewError, subdomains: searchState.subdomains,
sort: searchState.sort,
},
validators: {
onChange: ({ formApi, value }) =>
getDomainSearchChangeValidationErrors(
value,
shouldValidateFieldOnChange(formApi, "domain"),
formApi.state.submissionAttempts > 0,
),
onSubmit: ({ value }) => getDomainSearchValidationErrors(value),
},
onSubmit: async ({ formApi, value }) => {
const submitError = await runSearch({
domain: value.domain,
subdomains: value.subdomains,
sort: value.sort,
order: currentSortOrder,
tab: searchState.tab,
search: searchState.search,
}); });
formApi.setErrorMap({
onSubmit: submitError
? createFormValidationErrors({ form: submitError })
: undefined,
});
},
});
useSyncRouteState({ controlsForm, searchState, setPendingSearch, navigate });
const domainMutation = useDomainLookupMutation();
const saveMutation = useSaveKeywordsMutation({ projectId, queryClient }); const saveMutation = useSaveKeywordsMutation({ projectId, queryClient });
const dataState = useOverviewDataState({ const dataState = useOverviewDataState({
overview, overview,
@ -94,29 +174,32 @@ export function useDomainOverviewController({
setSearchParams({ search: pendingSearch.trim() || undefined }); setSearchParams({ search: pendingSearch.trim() || undefined });
}, [pendingSearch, setSearchParams]); }, [pendingSearch, setSearchParams]);
const handlers = useDomainControllerHandlers({ const runSearch = useSearchRunner({
controlsForm,
setPendingSearch,
setSearchParams,
domainMutation,
addSearch, addSearch,
setOverview: (value) => setOverview(value),
setSelectedKeywords,
currentState: searchState,
currentSortOrder,
});
const handlers = useDomainControllerHandlers({
controlsForm, controlsForm,
currentSortOrder, currentSortOrder,
currentState: searchState, currentState: searchState,
dataState, dataState,
domainMutation: domainMutation.mutate,
projectId, projectId,
runSearch,
saveMutation, saveMutation,
selectedKeywords, selectedKeywords,
setDomainError,
setOverview,
setOverviewError,
setPendingSearch,
setSearchParams, setSearchParams,
setSelectedKeywords,
}); });
return { return {
controlsForm, controlsForm,
domainError,
setDomainError,
overviewError,
isLoading: domainMutation.isPending, isLoading: domainMutation.isPending,
overview, overview,
history, history,
@ -134,39 +217,27 @@ export function useDomainOverviewController({
} }
function useDomainControllerHandlers({ function useDomainControllerHandlers({
addSearch,
controlsForm, controlsForm,
currentSortOrder, currentSortOrder,
currentState, currentState,
dataState, dataState,
domainMutation,
projectId, projectId,
runSearch,
saveMutation, saveMutation,
selectedKeywords, selectedKeywords,
setDomainError,
setOverview,
setOverviewError,
setPendingSearch,
setSearchParams, setSearchParams,
setSelectedKeywords,
}: { }: {
addSearch: (item: Omit<DomainSearchHistoryItem, "timestamp">) => void; controlsForm: DomainControlsFormApi;
controlsForm: ReturnType<typeof useDomainControlsForm>;
currentSortOrder: SortOrder; currentSortOrder: SortOrder;
currentState: SearchState; currentState: SearchState;
dataState: ReturnType<typeof useOverviewDataState>; dataState: ReturnType<typeof useOverviewDataState>;
domainMutation: ReturnType<typeof useDomainLookupMutation>["mutate"];
projectId: string; projectId: string;
runSearch: ReturnType<typeof useSearchRunner>;
saveMutation: ReturnType<typeof useSaveKeywordsMutation>; saveMutation: ReturnType<typeof useSaveKeywordsMutation>;
selectedKeywords: Set<string>; selectedKeywords: Set<string>;
setDomainError: (value: string | null) => void;
setOverview: (value: DomainOverviewData | null) => void;
setOverviewError: (value: string | null) => void;
setPendingSearch: (value: string) => void;
setSearchParams: ( setSearchParams: (
updates: Record<string, string | number | boolean | undefined>, updates: Record<string, string | number | boolean | undefined>,
) => void; ) => void;
setSelectedKeywords: (value: Set<string>) => void;
}) { }) {
const applySort = useCallback( const applySort = useCallback(
(nextSort: DomainSortMode, nextOrder: SortOrder) => { (nextSort: DomainSortMode, nextOrder: SortOrder) => {
@ -200,22 +271,13 @@ function useDomainControllerHandlers({
projectId, projectId,
}); });
const runSearch = useSearchRunner({
controlsForm,
setDomainError,
setOverviewError,
setPendingSearch,
setSearchParams,
domainMutation,
addSearch,
setOverview: (value) => setOverview(value),
setSelectedKeywords,
currentState,
currentSortOrder,
});
const handleHistorySelect = (item: DomainSearchHistoryItem) => { const handleHistorySelect = (item: DomainSearchHistoryItem) => {
runSearch({ controlsForm.reset({
domain: item.domain,
subdomains: item.subdomains,
sort: item.sort,
});
void runSearch({
domain: item.domain, domain: item.domain,
subdomains: item.subdomains, subdomains: item.subdomains,
sort: item.sort, sort: item.sort,
@ -227,7 +289,7 @@ function useDomainControllerHandlers({
const handleSearchSubmit = (event: FormEvent) => { const handleSearchSubmit = (event: FormEvent) => {
event.preventDefault(); event.preventDefault();
runSearch(); void controlsForm.handleSubmit();
}; };
return { return {

View File

@ -1,9 +1,14 @@
import { useForm } from "@tanstack/react-form";
import { useEffect } from "react"; import { useEffect } from "react";
import type { import { useForm } from "@tanstack/react-form";
KeywordMode, import {
ResultLimit, createFormValidationErrors,
shouldValidateFieldOnChange,
} from "@/client/lib/forms";
import {
type KeywordMode,
type ResultLimit,
} from "@/client/features/keywords/keywordResearchTypes"; } from "@/client/features/keywords/keywordResearchTypes";
import { parseKeywordInput } from "@/client/features/keywords/state/keywordControllerActions";
type UseKeywordControlsFormInput = { type UseKeywordControlsFormInput = {
keywordInput: string; keywordInput: string;
@ -12,7 +17,36 @@ type UseKeywordControlsFormInput = {
keywordMode: KeywordMode; keywordMode: KeywordMode;
}; };
export function useKeywordControlsForm(input: UseKeywordControlsFormInput) { type KeywordControlsValues = {
keyword: string;
locationCode: number;
resultLimit: ResultLimit;
mode: KeywordMode;
};
function getKeywordSearchValidationErrors(
value: KeywordControlsValues,
shouldValidateUntouchedField: boolean,
) {
if (parseKeywordInput(value.keyword).length > 0) {
return null;
}
if (!shouldValidateUntouchedField) {
return null;
}
return createFormValidationErrors({
fields: {
keyword: "Please enter at least one keyword.",
},
});
}
export function useKeywordControlsForm(
input: UseKeywordControlsFormInput,
onSubmit: (value: KeywordControlsValues) => void,
) {
const form = useForm({ const form = useForm({
defaultValues: { defaultValues: {
keyword: input.keywordInput, keyword: input.keywordInput,
@ -20,13 +54,26 @@ export function useKeywordControlsForm(input: UseKeywordControlsFormInput) {
resultLimit: input.resultLimit, resultLimit: input.resultLimit,
mode: input.keywordMode, mode: input.keywordMode,
}, },
validators: {
onChange: ({ formApi, value }) =>
getKeywordSearchValidationErrors(
value,
shouldValidateFieldOnChange(formApi, "keyword"),
),
onSubmit: ({ value }) => getKeywordSearchValidationErrors(value, true),
},
onSubmit: ({ value }) => {
onSubmit(value);
},
}); });
useEffect(() => { useEffect(() => {
form.setFieldValue("keyword", input.keywordInput); form.reset({
form.setFieldValue("locationCode", input.locationCode); keyword: input.keywordInput,
form.setFieldValue("resultLimit", input.resultLimit); locationCode: input.locationCode,
form.setFieldValue("mode", input.keywordMode); resultLimit: input.resultLimit,
mode: input.keywordMode,
});
}, [ }, [
form, form,
input.keywordInput, input.keywordInput,

View File

@ -1,4 +1,5 @@
import { Search } from "lucide-react"; import { Search } from "lucide-react";
import { getFieldError } from "@/client/lib/forms";
import { import {
isResultLimit, isResultLimit,
normalizeKeywordMode, normalizeKeywordMode,
@ -12,8 +13,7 @@ type Props = {
}; };
export function KeywordResearchSearchBar({ controller }: Props) { export function KeywordResearchSearchBar({ controller }: Props) {
const { controlsForm, handleSearchSubmit, isLoading, searchInputError } = const { controlsForm, handleSearchSubmit, isLoading } = controller;
controller;
return ( return (
<div className="shrink-0 px-4 md:px-6 pt-4 pb-2 max-w-8xl mx-auto w-full"> <div className="shrink-0 px-4 md:px-6 pt-4 pb-2 max-w-8xl mx-auto w-full">
@ -21,24 +21,25 @@ export function KeywordResearchSearchBar({ controller }: Props) {
className="bg-base-100 border border-base-300 rounded-xl px-4 py-3 flex flex-wrap items-center gap-2" className="bg-base-100 border border-base-300 rounded-xl px-4 py-3 flex flex-wrap items-center gap-2"
onSubmit={handleSearchSubmit} onSubmit={handleSearchSubmit}
> >
<controlsForm.Field name="keyword">
{(field) => {
const keywordError = getFieldError(field.state.meta.errors);
return (
<label <label
className={`input input-bordered input-sm flex items-center gap-2 flex-1 min-w-0 max-w-md ${searchInputError ? "input-error" : ""}`} className={`input input-bordered input-sm flex items-center gap-2 flex-1 min-w-0 max-w-md ${keywordError ? "input-error" : ""}`}
> >
<Search className="size-3.5 shrink-0 text-base-content/50" /> <Search className="size-3.5 shrink-0 text-base-content/50" />
<controlsForm.Field name="keyword">
{(field) => (
<input <input
className="grow min-w-0" className="grow min-w-0"
placeholder="Enter Keyword" placeholder="Enter Keyword"
value={field.state.value} value={field.state.value}
onChange={(event) => { onChange={(event) => field.handleChange(event.target.value)}
field.handleChange(event.target.value);
if (searchInputError) controller.setSearchInputError(null);
}}
/> />
)}
</controlsForm.Field>
</label> </label>
);
}}
</controlsForm.Field>
<controlsForm.Field name="locationCode"> <controlsForm.Field name="locationCode">
{(field) => ( {(field) => (
@ -102,9 +103,15 @@ export function KeywordResearchSearchBar({ controller }: Props) {
{isLoading ? "Searching..." : "Search"} {isLoading ? "Searching..." : "Search"}
</button> </button>
</form> </form>
{searchInputError ? ( <controlsForm.Field name="keyword">
<p className="mt-2 text-sm text-error">{searchInputError}</p> {(field) => {
) : null} const keywordError = getFieldError(field.state.meta.errors);
return keywordError ? (
<p className="mt-2 text-sm text-error">{keywordError}</p>
) : null;
}}
</controlsForm.Field>
</div> </div>
); );
} }

View File

@ -1,57 +1,11 @@
import { type FormEvent } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { DEFAULT_LOCATION_CODE } from "@/client/features/keywords/locations";
import { buildCsv, downloadCsv } from "@/client/lib/csv"; import { buildCsv, downloadCsv } from "@/client/lib/csv";
import { getStandardErrorMessage } from "@/client/lib/error-messages"; import { getStandardErrorMessage } from "@/client/lib/error-messages";
import type {
KeywordMode,
ResultLimit,
} from "@/client/features/keywords/keywordResearchTypes";
import { getLanguageCode } from "@/client/features/keywords/utils"; import { getLanguageCode } from "@/client/features/keywords/utils";
import type { KeywordResearchRow } from "@/types/keywords"; import type { KeywordResearchRow } from "@/types/keywords";
import type { SortDir, SortField } from "@/client/features/keywords/components"; import type { SortDir, SortField } from "@/client/features/keywords/components";
import type { KeywordResearchControllerInput } from "./useKeywordResearchController"; import type { KeywordResearchControllerInput } from "./useKeywordResearchController";
type ControlsFormLike = {
state: {
values: {
keyword: string;
locationCode: number;
resultLimit: ResultLimit;
mode: KeywordMode;
};
};
};
type RunSearchLike = (
args: {
projectId: string;
keywords: string[];
locationCode: number;
resultLimit: ResultLimit;
mode: KeywordMode;
},
options: {
onSuccess: (seedKeyword: string, nextRows: KeywordResearchRow[]) => void;
},
) => void;
type SearchActionParams = {
controlsForm: ControlsFormLike;
input: KeywordResearchControllerInput;
beginSearch: (seedKeyword: string, locationCode: number) => void;
runSearch: RunSearchLike;
clearSelection: () => void;
setSelectedKeyword: (keyword: KeywordResearchRow | null) => void;
setSerpKeyword: (keyword: string | null) => void;
setSerpPage: (page: number) => void;
setSearchInputError: (error: string | null) => void;
setSearchParams: (
updates: Record<string, string | number | boolean | undefined>,
) => void;
setPreferredLocationCode: (locationCode: number) => void;
};
type SaveExportActionParams = { type SaveExportActionParams = {
selectedRows: Set<string>; selectedRows: Set<string>;
filteredRows: KeywordResearchRow[]; filteredRows: KeywordResearchRow[];
@ -71,7 +25,14 @@ type SaveExportActionParams = {
setShowSaveDialog: (show: boolean) => void; setShowSaveDialog: (show: boolean) => void;
}; };
function getNextSortParams( export function parseKeywordInput(value: string) {
return value
.split(/[\n,]/)
.map((keyword) => keyword.trim())
.filter(Boolean);
}
export function getNextSortParams(
currentField: SortField, currentField: SortField,
currentDirection: SortDir, currentDirection: SortDir,
targetField: SortField, targetField: SortField,
@ -86,93 +47,6 @@ function getNextSortParams(
}; };
} }
export function useSearchActions(params: SearchActionParams) {
const {
controlsForm,
input,
beginSearch,
runSearch,
clearSelection,
setSelectedKeyword,
setSerpKeyword,
setSerpPage,
setSearchInputError,
setSearchParams,
setPreferredLocationCode,
} = params;
const onSearch = (
overrides?: Partial<{
keyword: string;
locationCode: number;
}>,
) => {
const values = controlsForm.state.values;
const inputKeyword = overrides?.keyword ?? values.keyword;
const activeLocation = overrides?.locationCode ?? values.locationCode;
const activeResultLimit = values.resultLimit;
const activeMode = values.mode;
const keywords = inputKeyword
.split(/[\n,]/)
.map((keyword) => keyword.trim())
.filter(Boolean);
if (keywords.length === 0) {
setSearchInputError("Please enter at least one keyword.");
return;
}
setSearchInputError(null);
setPreferredLocationCode(activeLocation);
setSearchParams({
q: inputKeyword,
loc:
input.hasExplicitLocationCode ||
activeLocation !== DEFAULT_LOCATION_CODE
? activeLocation
: undefined,
kLimit: activeResultLimit === 150 ? undefined : activeResultLimit,
mode: activeMode === "auto" ? undefined : activeMode,
});
setSelectedKeyword(null);
clearSelection();
setSerpKeyword(null);
beginSearch(keywords[0], activeLocation);
runSearch(
{
projectId: input.projectId,
keywords,
locationCode: activeLocation,
resultLimit: activeResultLimit,
mode: activeMode,
},
{
onSuccess: (seedKeyword, nextRows) => {
if (nextRows.length === 0) {
setSerpKeyword(null);
return;
}
setSerpKeyword(seedKeyword);
setSerpPage(0);
},
},
);
};
const handleSearchSubmit = (event: FormEvent) => {
event.preventDefault();
onSearch();
};
const toggleSort = (field: SortField) => {
setSearchParams(getNextSortParams(input.sortField, input.sortDir, field));
};
return { onSearch, handleSearchSubmit, toggleSort };
}
export function useSaveAndExportActions(params: SaveExportActionParams) { export function useSaveAndExportActions(params: SaveExportActionParams) {
const { const {
selectedRows, selectedRows,

View File

@ -0,0 +1,68 @@
import { useNavigate } from "@tanstack/react-router";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { usePreferredKeywordLocation } from "@/client/features/keywords/hooks/usePreferredKeywordLocation";
import { saveKeywords } from "@/serverFunctions/keywords";
import type { KeywordResearchRow } from "@/types/keywords";
import type { KeywordResearchControllerInput } from "./useKeywordResearchController";
export function useResolvedKeywordLocation(
input: KeywordResearchControllerInput,
) {
const { preferredLocationCode, setPreferredLocationCode } =
usePreferredKeywordLocation();
const locationCode =
!input.hasExplicitLocationCode && input.keywordInput === ""
? preferredLocationCode
: input.locationCode;
return { locationCode, setPreferredLocationCode };
}
export function useKeywordUiState() {
const [showFilters, setShowFilters] = useState(false);
const [selectedKeyword, setSelectedKeyword] =
useState<KeywordResearchRow | null>(null);
const [showSaveDialog, setShowSaveDialog] = useState(false);
const [mobileTab, setMobileTab] = useState<"keywords" | "serp">("keywords");
return {
mobileTab,
selectedKeyword,
setMobileTab,
setSelectedKeyword,
setShowFilters,
setShowSaveDialog,
showFilters,
showSaveDialog,
};
}
export function useKeywordSearchParams() {
const navigate = useNavigate({ from: "/p/$projectId/keywords" });
return (updates: Record<string, string | number | boolean | undefined>) => {
void navigate({
search: (prev) => ({ ...prev, ...updates }),
replace: true,
});
};
}
export function useKeywordSaveMutation(projectId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: {
projectId: string;
keywords: string[];
locationCode: number;
languageCode: string;
}) => saveKeywords({ data }),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: ["savedKeywords", projectId],
});
},
});
}

View File

@ -1,9 +1,6 @@
import { useNavigate } from "@tanstack/react-router"; import { useCallback, type FormEvent } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useCallback, useState } from "react";
import { useKeywordControlsForm } from "@/client/features/keywords/hooks/useKeywordControlsForm"; import { useKeywordControlsForm } from "@/client/features/keywords/hooks/useKeywordControlsForm";
import { useKeywordFiltering } from "@/client/features/keywords/hooks/useKeywordFiltering"; import { useKeywordFiltering } from "@/client/features/keywords/hooks/useKeywordFiltering";
import { usePreferredKeywordLocation } from "@/client/features/keywords/hooks/usePreferredKeywordLocation";
import { useLocalKeywordFilters } from "@/client/features/keywords/hooks/useLocalKeywordFilters"; import { useLocalKeywordFilters } from "@/client/features/keywords/hooks/useLocalKeywordFilters";
import { useKeywordResearchData } from "@/client/features/keywords/hooks/useKeywordResearchData"; import { useKeywordResearchData } from "@/client/features/keywords/hooks/useKeywordResearchData";
import { useKeywordSelection } from "@/client/features/keywords/hooks/useKeywordSelection"; import { useKeywordSelection } from "@/client/features/keywords/hooks/useKeywordSelection";
@ -13,13 +10,20 @@ import {
type KeywordMode, type KeywordMode,
type ResultLimit, type ResultLimit,
} from "@/client/features/keywords/keywordResearchTypes"; } from "@/client/features/keywords/keywordResearchTypes";
import { saveKeywords } from "@/serverFunctions/keywords"; import { DEFAULT_LOCATION_CODE } from "@/client/features/keywords/locations";
import type { KeywordResearchRow } from "@/types/keywords"; import type { KeywordResearchRow } from "@/types/keywords";
import type { SortDir, SortField } from "@/client/features/keywords/components"; import type { SortDir, SortField } from "@/client/features/keywords/components";
import { import {
getNextSortParams,
parseKeywordInput,
useSaveAndExportActions, useSaveAndExportActions,
useSearchActions,
} from "./keywordControllerActions"; } from "./keywordControllerActions";
import {
useKeywordSaveMutation,
useKeywordSearchParams,
useKeywordUiState,
useResolvedKeywordLocation,
} from "./keywordControllerInternals";
import { useKeywordOverviewState } from "./useKeywordOverviewState"; import { useKeywordOverviewState } from "./useKeywordOverviewState";
export type KeywordResearchControllerInput = { export type KeywordResearchControllerInput = {
@ -37,20 +41,38 @@ export function useKeywordResearchController(
input: KeywordResearchControllerInput, input: KeywordResearchControllerInput,
) { ) {
const state = useKeywordControllerState(input); const state = useKeywordControllerState(input);
const controlsForm = state.controlsForm;
const setSearchParams = state.setSearchParams;
const { onSearch, handleSearchSubmit, toggleSort } = useSearchActions({ const onSearch = useCallback(
controlsForm: state.controlsForm, (overrides?: Partial<{ keyword: string; locationCode: number }>) => {
input, if (overrides?.keyword !== undefined) {
beginSearch: state.beginSearch, controlsForm.setFieldValue("keyword", overrides.keyword);
runSearch: state.runSearch, }
clearSelection: state.clearSelection,
setSelectedKeyword: state.setSelectedKeyword, if (overrides?.locationCode !== undefined) {
setSerpKeyword: state.setSerpKeyword, controlsForm.setFieldValue("locationCode", overrides.locationCode);
setSerpPage: state.setSerpPage, }
setSearchInputError: state.setSearchInputError,
setSearchParams: state.setSearchParams, void controlsForm.handleSubmit();
setPreferredLocationCode: state.setPreferredLocationCode, },
}); [controlsForm],
);
const handleSearchSubmit = useCallback(
(event: FormEvent) => {
event.preventDefault();
void controlsForm.handleSubmit();
},
[controlsForm],
);
const toggleSort = useCallback(
(field: SortField) => {
setSearchParams(getNextSortParams(input.sortField, input.sortDir, field));
},
[input.sortDir, input.sortField, setSearchParams],
);
const { handleSaveKeywords, confirmSave, exportCsv } = const { handleSaveKeywords, confirmSave, exportCsv } =
useSaveAndExportActions({ useSaveAndExportActions({
@ -71,7 +93,7 @@ export function useKeywordResearchController(
state.setSerpPage(0); state.setSerpPage(0);
}; };
return buildControllerOutput({ return {
activeFilterCount: state.activeFilterCount, activeFilterCount: state.activeFilterCount,
activeSerpKeyword: state.activeSerpKeyword, activeSerpKeyword: state.activeSerpKeyword,
clearHistory: state.clearHistory, clearHistory: state.clearHistory,
@ -100,7 +122,6 @@ export function useKeywordResearchController(
resetFilters: state.resetFilters, resetFilters: state.resetFilters,
rows: state.rows, rows: state.rows,
searchedKeyword: state.searchedKeyword, searchedKeyword: state.searchedKeyword,
searchInputError: state.searchInputError,
selectedRows: state.selectedRows, selectedRows: state.selectedRows,
serpError: state.serpError, serpError: state.serpError,
serpLoading: state.serpLoading, serpLoading: state.serpLoading,
@ -108,7 +129,6 @@ export function useKeywordResearchController(
serpQuery: state.serpQuery, serpQuery: state.serpQuery,
serpResults: state.serpResults, serpResults: state.serpResults,
setMobileTab: state.setMobileTab, setMobileTab: state.setMobileTab,
setSearchInputError: state.setSearchInputError,
setSerpPage: state.setSerpPage, setSerpPage: state.setSerpPage,
setShowFilters: state.setShowFilters, setShowFilters: state.setShowFilters,
setShowSaveDialog: state.setShowSaveDialog, setShowSaveDialog: state.setShowSaveDialog,
@ -121,18 +141,13 @@ export function useKeywordResearchController(
toggleRowSelection: state.toggleRowSelection, toggleRowSelection: state.toggleRowSelection,
toggleSort, toggleSort,
SERP_PAGE_SIZE: state.SERP_PAGE_SIZE, SERP_PAGE_SIZE: state.SERP_PAGE_SIZE,
}); };
} }
function useKeywordControllerState(input: KeywordResearchControllerInput) { function useKeywordControllerState(input: KeywordResearchControllerInput) {
const uiState = useKeywordUiState(); const uiState = useKeywordUiState();
const { locationCode, setPreferredLocationCode } = const { locationCode, setPreferredLocationCode } =
useResolvedKeywordLocation(input); useResolvedKeywordLocation(input);
const controlsForm = useKeywordControlsForm({
...input,
locationCode,
});
const { const {
filtersForm, filtersForm,
values: filterValues, values: filterValues,
@ -177,6 +192,56 @@ function useKeywordControllerState(input: KeywordResearchControllerInput) {
const setSearchParams = useKeywordSearchParams(); const setSearchParams = useKeywordSearchParams();
const saveMutation = useKeywordSaveMutation(input.projectId); const saveMutation = useKeywordSaveMutation(input.projectId);
const controlsForm = useKeywordControlsForm(
{
...input,
locationCode,
},
(value) => {
const keywords = parseKeywordInput(value.keyword);
const activeLocation = value.locationCode;
const activeResultLimit = value.resultLimit;
const activeMode = value.mode;
setPreferredLocationCode(activeLocation);
setSearchParams({
q: value.keyword,
loc:
input.hasExplicitLocationCode ||
activeLocation !== DEFAULT_LOCATION_CODE
? activeLocation
: undefined,
kLimit: activeResultLimit === 150 ? undefined : activeResultLimit,
mode: activeMode === "auto" ? undefined : activeMode,
});
uiState.setSelectedKeyword(null);
clearSelection();
setSerpKeyword(null);
beginSearch(keywords[0] ?? "", activeLocation);
runSearch(
{
projectId: input.projectId,
keywords,
locationCode: activeLocation,
resultLimit: activeResultLimit,
mode: activeMode,
},
{
onSuccess: (seedKeyword, nextRows) => {
if (nextRows.length === 0) {
setSerpKeyword(null);
return;
}
setSerpKeyword(seedKeyword);
setSerpPage(0);
},
},
);
},
);
const { filteredRows, activeFilterCount } = useKeywordFiltering({ const { filteredRows, activeFilterCount } = useKeywordFiltering({
rows, rows,
filters: filterValues, filters: filterValues,
@ -195,7 +260,7 @@ function useKeywordControllerState(input: KeywordResearchControllerInput) {
keywordMode: input.keywordMode, keywordMode: input.keywordMode,
}); });
return buildKeywordControllerState({ return {
activeFilterCount, activeFilterCount,
activeSerpKeyword, activeSerpKeyword,
beginSearch, beginSearch,
@ -221,7 +286,6 @@ function useKeywordControllerState(input: KeywordResearchControllerInput) {
resetFilters, resetFilters,
rows, rows,
searchedKeyword, searchedKeyword,
searchInputError: uiState.searchInputError,
selectedKeyword: uiState.selectedKeyword, selectedKeyword: uiState.selectedKeyword,
selectedRows, selectedRows,
saveMutation, saveMutation,
@ -235,7 +299,6 @@ function useKeywordControllerState(input: KeywordResearchControllerInput) {
serpQuery, serpQuery,
serpResults, serpResults,
setMobileTab: uiState.setMobileTab, setMobileTab: uiState.setMobileTab,
setSearchInputError: uiState.setSearchInputError,
setSerpPage, setSerpPage,
setShowFilters: uiState.setShowFilters, setShowFilters: uiState.setShowFilters,
setShowSaveDialog: uiState.setShowSaveDialog, setShowSaveDialog: uiState.setShowSaveDialog,
@ -245,80 +308,5 @@ function useKeywordControllerState(input: KeywordResearchControllerInput) {
toggleAllRows, toggleAllRows,
toggleRowSelection, toggleRowSelection,
SERP_PAGE_SIZE, SERP_PAGE_SIZE,
});
}
function useResolvedKeywordLocation(input: KeywordResearchControllerInput) {
const { preferredLocationCode, setPreferredLocationCode } =
usePreferredKeywordLocation();
const locationCode =
!input.hasExplicitLocationCode && input.keywordInput === ""
? preferredLocationCode
: input.locationCode;
return { locationCode, setPreferredLocationCode };
}
function useKeywordUiState() {
const [showFilters, setShowFilters] = useState(false);
const [selectedKeyword, setSelectedKeyword] =
useState<KeywordResearchRow | null>(null);
const [searchInputError, setSearchInputError] = useState<string | null>(null);
const [showSaveDialog, setShowSaveDialog] = useState(false);
const [mobileTab, setMobileTab] = useState<"keywords" | "serp">("keywords");
return {
mobileTab,
searchInputError,
selectedKeyword,
setMobileTab,
setSearchInputError,
setSelectedKeyword,
setShowFilters,
setShowSaveDialog,
showFilters,
showSaveDialog,
}; };
} }
function useKeywordSearchParams() {
const navigate = useNavigate({ from: "/p/$projectId/keywords" });
return useCallback(
(updates: Record<string, string | number | boolean | undefined>) => {
void navigate({
search: (prev) => ({ ...prev, ...updates }),
replace: true,
});
},
[navigate],
);
}
function useKeywordSaveMutation(projectId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: {
projectId: string;
keywords: string[];
locationCode: number;
languageCode: string;
}) => saveKeywords({ data }),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: ["savedKeywords", projectId],
});
},
});
}
function buildControllerOutput<T extends Record<string, unknown>>(state: T): T {
return state;
}
function buildKeywordControllerState<T extends Record<string, unknown>>(
state: T,
): T {
return state;
}

View File

@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
describe("getStandardErrorMessage", () => {
it("maps known error codes to standard copy", () => {
expect(getStandardErrorMessage(new Error("PAYMENT_REQUIRED"))).toBe(
"An active hosted subscription is required before you can use OpenSEO.",
);
});
it("returns custom messages when the error is not a shared code", () => {
expect(
getStandardErrorMessage(
new Error("DataForSEO task missing billing metadata. Response: {...}"),
),
).toBe("DataForSEO task missing billing metadata. Response: {...}");
});
});

View File

@ -28,6 +28,7 @@ export function getStandardErrorMessage(
): string { ): string {
if (!(error instanceof Error)) return fallback; if (!(error instanceof Error)) return fallback;
if (isErrorCode(error.message)) return STANDARD_MESSAGES[error.message]; if (isErrorCode(error.message)) return STANDARD_MESSAGES[error.message];
if (error.message) return error.message;
return fallback; return fallback;
} }

81
src/client/lib/forms.ts Normal file
View File

@ -0,0 +1,81 @@
type FormValidationErrors = {
form?: string;
fields?: Record<string, string>;
} | null;
type FieldValidationFormApi<TField extends string> = {
state: {
submissionAttempts: number;
};
getFieldMeta: (field: TField) =>
| {
isTouched?: boolean;
}
| undefined;
};
export function createFormValidationErrors({
fields,
form,
}: {
fields?: Record<string, string | null | undefined>;
form?: string | null | undefined;
}): FormValidationErrors {
const normalizedFields: Record<string, string> = {};
for (const [key, value] of Object.entries(fields ?? {})) {
if (typeof value === "string") {
normalizedFields[key] = value;
}
}
if (!form && Object.keys(normalizedFields).length === 0) {
return null;
}
return {
fields:
Object.keys(normalizedFields).length > 0 ? normalizedFields : undefined,
form: form ?? undefined,
};
}
export function getFormError(error: unknown): string | null {
if (typeof error === "string") {
return error;
}
if (
error &&
typeof error === "object" &&
"form" in error &&
typeof error.form === "string"
) {
return error.form;
}
if (
error &&
typeof error === "object" &&
"message" in error &&
typeof error.message === "string"
) {
return error.message;
}
return null;
}
export function getFieldError(errors: readonly unknown[]) {
return getFormError(errors[0]);
}
export function shouldValidateFieldOnChange<TField extends string>(
formApi: FieldValidationFormApi<TField>,
field: TField,
) {
return (
formApi.state.submissionAttempts > 0 ||
Boolean(formApi.getFieldMeta(field)?.isTouched)
);
}

View File

@ -85,7 +85,7 @@ function SignInPage() {
} catch { } catch {
formApi.setErrorMap({ formApi.setErrorMap({
onSubmit: { onSubmit: {
form: "We couldn't sign you in right now. Please try again.", form: "Unable to sign in right now. Please try again.",
fields: {}, fields: {},
}, },
}); });

View File

@ -44,7 +44,7 @@ export const Route = createFileRoute("/_auth/sign-up")({
function getHelperText(isHostedMode: boolean) { function getHelperText(isHostedMode: boolean) {
return isHostedMode return isHostedMode
? "Create your OpenSEO account." ? "Create your OpenSEO account."
: "Account creation isn't available right now."; : "Account creation is only available when AUTH_MODE=hosted.";
} }
function SignUpPage() { function SignUpPage() {
@ -85,7 +85,7 @@ function SignUpPage() {
if (result.error) { if (result.error) {
formApi.setErrorMap({ formApi.setErrorMap({
onSubmit: { onSubmit: {
form: result.error.message || "We couldn't create your account.", form: result.error.message || "Unable to create account.",
fields: {}, fields: {},
}, },
}); });
@ -99,7 +99,7 @@ function SignUpPage() {
} catch { } catch {
formApi.setErrorMap({ formApi.setErrorMap({
onSubmit: { onSubmit: {
form: "We couldn't create your account right now. Please try again.", form: "Unable to create account right now. Please try again.",
fields: {}, fields: {},
}, },
}); });

View File

@ -1,13 +1,10 @@
import { import { type DomainRankedKeywordItem } from "@/server/lib/dataforseo";
normalizeDomainInput,
toRelativePath,
type DomainRankedKeywordItem,
} from "@/server/lib/dataforseo";
import { sortBy } from "remeda"; import { sortBy } from "remeda";
import { buildCacheKey, getCached, setCached } from "@/server/lib/r2-cache"; import { buildCacheKey, getCached, setCached } from "@/server/lib/r2-cache";
import { z } from "zod"; import { z } from "zod";
import type { BillingCustomerContext } from "@/server/billing/subscription"; import type { BillingCustomerContext } from "@/server/billing/subscription";
import { createDataforseoClient } from "@/server/lib/dataforseoClient"; import { createDataforseoClient } from "@/server/lib/dataforseoClient";
import { normalizeDomainInput, toRelativePath } from "@/server/lib/domainUtils";
/** Domain overview data is refreshed every 12 hours. */ /** Domain overview data is refreshed every 12 hours. */
const DOMAIN_OVERVIEW_TTL_SECONDS = 12 * 60 * 60; const DOMAIN_OVERVIEW_TTL_SECONDS = 12 * 60 * 60;

View File

@ -7,7 +7,6 @@ import {
DataforseoLabsGoogleRankedKeywordsLiveRequestInfo, DataforseoLabsGoogleRankedKeywordsLiveRequestInfo,
} from "dataforseo-client"; } from "dataforseo-client";
import { env } from "cloudflare:workers"; import { env } from "cloudflare:workers";
import { getDomain } from "tldts";
import type { DataforseoApiResponse } from "@/server/lib/dataforseoCost"; import type { DataforseoApiResponse } from "@/server/lib/dataforseoCost";
import { AppError } from "@/server/lib/errors"; import { AppError } from "@/server/lib/errors";
import { import {
@ -50,6 +49,7 @@ function createAuthenticatedFetch() {
} }
const API_BASE = "https://api.dataforseo.com"; const API_BASE = "https://api.dataforseo.com";
const MAX_DATAFORSEO_ERROR_PAYLOAD_LENGTH = 1600;
function getLabsApi() { function getLabsApi() {
return new DataforseoLabsApi(API_BASE, { fetch: createAuthenticatedFetch() }); return new DataforseoLabsApi(API_BASE, { fetch: createAuthenticatedFetch() });
@ -68,14 +68,23 @@ async function postDataforseo(
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
const rawText = await response.text();
if (!response.ok) { if (!response.ok) {
throw new AppError( throw new AppError(
"INTERNAL_ERROR", "INTERNAL_ERROR",
`DataForSEO HTTP ${response.status} on ${path}`, `DataForSEO HTTP ${response.status} on ${path}. Response: ${formatDataforseoErrorPayload(rawText)}`,
); );
} }
return await response.json(); try {
return JSON.parse(rawText);
} catch {
throw new AppError(
"INTERNAL_ERROR",
`DataForSEO ${path} returned a non-JSON response. Response: ${formatDataforseoErrorPayload(rawText)}`,
);
}
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -87,14 +96,49 @@ async function postDataforseo(
* Throws a descriptive error on failure. Returns the first task. * Throws a descriptive error on failure. Returns the first task.
*/ */
type DataforseoTaskLike = { type DataforseoTaskLike = {
id?: string;
status_code?: number; status_code?: number;
status_message?: string; status_message?: string;
path?: string[]; path?: string[];
cost?: number; cost?: number;
result_count?: number | null; result_count?: number | null;
data?: unknown;
result?: DataforseoTask["result"]; result?: DataforseoTask["result"];
}; };
function formatDataforseoErrorPayload(value: unknown): string {
const text =
typeof value === "string"
? value
: (() => {
try {
return JSON.stringify(value);
} catch {
return String(value);
}
})();
return text.length > MAX_DATAFORSEO_ERROR_PAYLOAD_LENGTH
? `${text.slice(0, MAX_DATAFORSEO_ERROR_PAYLOAD_LENGTH)}... [truncated]`
: text;
}
function getTaskDebugPayload(task: DataforseoTaskLike) {
return {
id: task.id ?? null,
status_code: task.status_code ?? null,
status_message: task.status_message ?? null,
path: task.path ?? null,
cost: task.cost ?? null,
result_count: task.result_count ?? null,
data: task.data ?? null,
result_length: Array.isArray(task.result) ? task.result.length : null,
result_preview: Array.isArray(task.result)
? (task.result[0] ?? null)
: null,
};
}
function assertOk<T extends DataforseoTaskLike>( function assertOk<T extends DataforseoTaskLike>(
response: { response: {
status_code?: number; status_code?: number;
@ -127,9 +171,22 @@ function assertOk<T extends DataforseoTaskLike>(
const parsedTask = successfulDataforseoTaskSchema.safeParse(task); const parsedTask = successfulDataforseoTaskSchema.safeParse(task);
if (!parsedTask.success) { if (!parsedTask.success) {
const issueSummary = parsedTask.error.issues
.slice(0, 5)
.map((issue) => {
const path = issue.path.length > 0 ? issue.path.join(".") : "task";
return `${path}: ${issue.message}`;
})
.join("; ");
const responseSummary = formatDataforseoErrorPayload({
status_code: response.status_code ?? null,
status_message: response.status_message ?? null,
task: getTaskDebugPayload(task),
});
throw new AppError( throw new AppError(
"INTERNAL_ERROR", "INTERNAL_ERROR",
"DataForSEO task missing billing metadata", `DataForSEO task missing billing metadata (${issueSummary}). Response: ${responseSummary}`,
); );
} }
@ -323,49 +380,3 @@ export async function fetchLiveSerpItemsRaw(
billing: buildTaskBilling(task), billing: buildTaskBilling(task),
}; };
} }
// ---------------------------------------------------------------------------
// Domain utility functions (unchanged)
// ---------------------------------------------------------------------------
export function toRelativePath(url: string | null | undefined): string | null {
if (!url) return null;
try {
const parsed = new URL(url);
return `${parsed.pathname}${parsed.search}` || "/";
} catch {
return null;
}
}
export function normalizeDomainInput(
input: string,
includeSubdomains: boolean,
): string {
const trimmed = input.trim().toLowerCase();
if (!trimmed) {
throw new AppError("VALIDATION_ERROR", "Domain is required");
}
const withProtocol = /^[a-zA-Z][a-zA-Z\d+.-]*:\/\//.test(trimmed)
? trimmed
: `https://${trimmed}`;
let host: string;
try {
host = new URL(withProtocol).hostname.toLowerCase().replace(/^www\./, "");
} catch {
throw new AppError("VALIDATION_ERROR", "Domain is invalid");
}
if (!host) {
throw new AppError("VALIDATION_ERROR", "Domain is invalid");
}
if (includeSubdomains) {
return host;
}
return getDomain(host) ?? host;
}

View File

@ -0,0 +1,44 @@
import { getDomain } from "tldts";
import { AppError } from "@/server/lib/errors";
export function toRelativePath(url: string | null | undefined): string | null {
if (!url) return null;
try {
const parsed = new URL(url);
return `${parsed.pathname}${parsed.search}` || "/";
} catch {
return null;
}
}
export function normalizeDomainInput(
input: string,
includeSubdomains: boolean,
): string {
const trimmed = input.trim().toLowerCase();
if (!trimmed) {
throw new AppError("VALIDATION_ERROR", "Domain is required");
}
const withProtocol = /^[a-zA-Z][a-zA-Z\d+.-]*:\/\//.test(trimmed)
? trimmed
: `https://${trimmed}`;
let host: string;
try {
host = new URL(withProtocol).hostname.toLowerCase().replace(/^www\./, "");
} catch {
throw new AppError("VALIDATION_ERROR", "Domain is invalid");
}
if (!host) {
throw new AppError("VALIDATION_ERROR", "Domain is invalid");
}
if (includeSubdomains) {
return host;
}
return getDomain(host) ?? host;
}

View File

@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { AppError, toClientError } from "@/server/lib/errors";
describe("toClientError", () => {
it("sanitizes detailed internal error messages", () => {
const error = toClientError(
new AppError(
"INTERNAL_ERROR",
"DataForSEO task missing billing metadata (path: Invalid input). Response: {...}",
),
);
expect(error.message).toBe("INTERNAL_ERROR");
});
it("keeps public error codes unchanged", () => {
const error = toClientError(new AppError("PAYMENT_REQUIRED"));
expect(error.message).toBe("PAYMENT_REQUIRED");
});
});

View File

@ -23,5 +23,6 @@ function toErrorCode(error: unknown): ErrorCode {
} }
export function toClientError(error: unknown): Error { export function toClientError(error: unknown): Error {
return new Error(toErrorCode(error)); const appError = asAppError(error);
return new Error(appError?.code ?? toErrorCode(error));
} }