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:
parent
191879c87c
commit
ae7712238e
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@ -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>;
|
|
||||||
|
|||||||
@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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({
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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 ? (
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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.");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
|
|||||||
18
src/client/lib/error-messages.test.ts
Normal file
18
src/client/lib/error-messages.test.ts
Normal 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: {...}");
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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
81
src/client/lib/forms.ts
Normal 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)
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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: {},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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: {},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
|
|||||||
44
src/server/lib/domainUtils.ts
Normal file
44
src/server/lib/domainUtils.ts
Normal 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;
|
||||||
|
}
|
||||||
21
src/server/lib/errors.test.ts
Normal file
21
src/server/lib/errors.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user