refactor: clean up complex code (#229)

This commit is contained in:
Ben Senescu 2026-05-28 16:37:33 -04:00 committed by GitHub
parent 010c93a536
commit 03fd3588ef
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
70 changed files with 270 additions and 718 deletions

View File

@ -1,5 +1,7 @@
import { ExternalLink } from "lucide-react"; import { ExternalLink } from "lucide-react";
import { getSafeExternalUrl } from "./table/url";
export function SafeExternalLink({ export function SafeExternalLink({
url, url,
label, label,
@ -21,14 +23,3 @@ export function SafeExternalLink({
</a> </a>
); );
} }
function getSafeExternalUrl(value: string) {
try {
const parsed = new URL(value);
return parsed.protocol === "http:" || parsed.protocol === "https:"
? parsed.toString()
: null;
} catch {
return null;
}
}

View File

@ -182,11 +182,7 @@ export function AppDataTable<TData>({
.join(" ")} .join(" ")}
> >
{row.getVisibleCells().map((cell) => { {row.getVisibleCells().map((cell) => {
const rawMeta: unknown = cell.column.columnDef.meta; const metaClass = cell.column.columnDef.meta?.cellClassName;
const meta = isAppColumnMeta<TData>(rawMeta)
? rawMeta
: undefined;
const metaClass = meta?.cellClassName;
return ( return (
<td <td
key={cell.id} key={cell.id}
@ -224,8 +220,7 @@ function HeaderCell<TData>({
fixedLayout?: boolean; fixedLayout?: boolean;
stickyHeader?: boolean; stickyHeader?: boolean;
}) { }) {
const rawMeta: unknown = header.column.columnDef.meta; const meta = header.column.columnDef.meta;
const meta = isAppColumnMeta<TData>(rawMeta) ? rawMeta : undefined;
return ( return (
<th <th
className={[ className={[
@ -242,7 +237,3 @@ function HeaderCell<TData>({
</th> </th>
); );
} }
function isAppColumnMeta<TData>(value: unknown): value is AppColumnMeta<TData> {
return typeof value === "object" && value !== null;
}

View File

@ -73,7 +73,7 @@ function getUrlDisplayLabel(
return formatUrlForDisplay(value); return formatUrlForDisplay(value);
} }
function getSafeExternalUrl(value: string) { export function getSafeExternalUrl(value: string) {
try { try {
const parsed = new URL(value); const parsed = new URL(value);
return parsed.protocol === "http:" || parsed.protocol === "https:" return parsed.protocol === "http:" || parsed.protocol === "https:"

View File

@ -77,12 +77,10 @@ export function CodeBlock({ code }: { code: string }) {
export function CopyButton({ export function CopyButton({
value, value,
successMessage, successMessage,
label,
iconOnly = false, iconOnly = false,
}: { }: {
value: string; value: string;
successMessage: string; successMessage: string;
label?: string;
iconOnly?: boolean; iconOnly?: boolean;
}) { }) {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
@ -130,7 +128,7 @@ export function CopyButton({
) : ( ) : (
<Copy className="size-3" /> <Copy className="size-3" />
)} )}
{label ?? "Copy"} Copy
</button> </button>
); );
} }

View File

@ -19,10 +19,8 @@ import { BrandLookupSearchCard } from "@/client/features/ai-search/components/Br
import { BrandLookupHistorySection } from "@/client/features/ai-search/components/BrandLookupHistorySection"; import { BrandLookupHistorySection } from "@/client/features/ai-search/components/BrandLookupHistorySection";
import { AiSearchLoadingState } from "@/client/features/ai-search/components/AiSearchLoadingState"; import { AiSearchLoadingState } from "@/client/features/ai-search/components/AiSearchLoadingState";
import { AiSearchPaidPlanGate } from "@/client/features/ai-search/components/AiSearchPaidPlanGate"; import { AiSearchPaidPlanGate } from "@/client/features/ai-search/components/AiSearchPaidPlanGate";
import { import { AiSearchSetupGate } from "@/client/features/ai-search/components/AiSearchSetupGate";
AiSearchAccessLoadingState, import { AccessGateLoadingState } from "@/client/features/access-gate/AccessGate";
AiSearchSetupGate,
} from "@/client/features/ai-search/components/AiSearchSetupGate";
import { useAiSearchAccess } from "@/client/features/ai-search/useAiSearchAccess"; import { useAiSearchAccess } from "@/client/features/ai-search/useAiSearchAccess";
import { useBrandLookupSearchHistory } from "@/client/hooks/useBrandLookupSearchHistory"; import { useBrandLookupSearchHistory } from "@/client/hooks/useBrandLookupSearchHistory";
import { BRAND_LOOKUP_MAX_INPUT_LENGTH } from "@/types/schemas/ai-search"; import { BRAND_LOOKUP_MAX_INPUT_LENGTH } from "@/types/schemas/ai-search";
@ -151,7 +149,7 @@ function BrandLookupPageInner({
</div> </div>
{access.isLoading ? ( {access.isLoading ? (
<AiSearchAccessLoadingState /> <AccessGateLoadingState />
) : !access.enabled ? ( ) : !access.enabled ? (
<AiSearchSetupGate <AiSearchSetupGate
errorMessage={access.errorMessage ?? access.statusErrorMessage} errorMessage={access.errorMessage ?? access.statusErrorMessage}

View File

@ -19,10 +19,8 @@ import { PromptExplorerResults } from "@/client/features/ai-search/components/Pr
import { PromptExplorerLoadingState } from "@/client/features/ai-search/components/PromptExplorerLoadingState"; import { PromptExplorerLoadingState } from "@/client/features/ai-search/components/PromptExplorerLoadingState";
import { PromptExplorerHistorySection } from "@/client/features/ai-search/components/PromptExplorerHistorySection"; import { PromptExplorerHistorySection } from "@/client/features/ai-search/components/PromptExplorerHistorySection";
import { AiSearchPaidPlanGate } from "@/client/features/ai-search/components/AiSearchPaidPlanGate"; import { AiSearchPaidPlanGate } from "@/client/features/ai-search/components/AiSearchPaidPlanGate";
import { import { AiSearchSetupGate } from "@/client/features/ai-search/components/AiSearchSetupGate";
AiSearchAccessLoadingState, import { AccessGateLoadingState } from "@/client/features/access-gate/AccessGate";
AiSearchSetupGate,
} from "@/client/features/ai-search/components/AiSearchSetupGate";
import { useAiSearchAccess } from "@/client/features/ai-search/useAiSearchAccess"; import { useAiSearchAccess } from "@/client/features/ai-search/useAiSearchAccess";
import { usePromptExplorerSearchHistory } from "@/client/hooks/usePromptExplorerSearchHistory"; import { usePromptExplorerSearchHistory } from "@/client/hooks/usePromptExplorerSearchHistory";
import { import {
@ -215,7 +213,7 @@ function PromptExplorerPageInner({
</div> </div>
{access.isLoading ? ( {access.isLoading ? (
<AiSearchAccessLoadingState /> <AccessGateLoadingState />
) : !access.enabled ? ( ) : !access.enabled ? (
<AiSearchSetupGate <AiSearchSetupGate
errorMessage={access.errorMessage ?? access.statusErrorMessage} errorMessage={access.errorMessage ?? access.statusErrorMessage}

View File

@ -1,11 +1,4 @@
import { import { AccessGate } from "@/client/features/access-gate/AccessGate";
AccessGate,
AccessGateLoadingState,
} from "@/client/features/access-gate/AccessGate";
export function AiSearchAccessLoadingState() {
return <AccessGateLoadingState />;
}
export function AiSearchSetupGate({ export function AiSearchSetupGate({
errorMessage, errorMessage,

View File

@ -149,7 +149,6 @@ function BrandLookupTable<T>({
table.getColumn(columnId)?.getCanSort() ?? false, table.getColumn(columnId)?.getCanSort() ?? false,
) )
} }
getRowClassName={() => ""}
/> />
); );
} }

View File

@ -40,14 +40,14 @@ const PLATFORM_DOT_CLASS: Record<PlatformRow["platform"], string> = {
}; };
export function BrandLookupResults({ result }: Props) { export function BrandLookupResults({ result }: Props) {
const erroredPlatforms = result.perPlatform.filter(
(p) => p.status === "error",
);
const allPlatformsErrored =
erroredPlatforms.length === result.perPlatform.length &&
result.perPlatform.length > 0;
if (!result.hasData) { if (!result.hasData) {
const erroredPlatforms = result.perPlatform.filter(
(p) => p.status === "error",
);
const allPlatformsErrored =
erroredPlatforms.length === result.perPlatform.length &&
result.perPlatform.length > 0;
if (allPlatformsErrored) { if (allPlatformsErrored) {
return ( return (
<div className="rounded-lg border border-warning/30 bg-warning/10 p-4 text-sm"> <div className="rounded-lg border border-warning/30 bg-warning/10 p-4 text-sm">
@ -63,7 +63,10 @@ export function BrandLookupResults({ result }: Props) {
</div> </div>
{erroredPlatforms.length > 0 ? ( {erroredPlatforms.length > 0 ? (
<p className="text-xs text-base-content/60"> <p className="text-xs text-base-content/60">
Note: {formatPlatformList(erroredPlatforms.map((p) => p.platform))}{" "} Note:{" "}
{erroredPlatforms
.map((p) => formatPlatformLabel(p.platform))
.join(" and ")}{" "}
{erroredPlatforms.length === 1 ? "was" : "were"} unavailable some {erroredPlatforms.length === 1 ? "was" : "were"} unavailable some
mentions may be missing. mentions may be missing.
</p> </p>
@ -88,10 +91,6 @@ export function BrandLookupResults({ result }: Props) {
); );
} }
function formatPlatformList(platforms: PlatformRow["platform"][]): string {
return platforms.map(formatPlatformLabel).join(" and ");
}
function BrandHeader({ result }: { result: BrandLookupResult }) { function BrandHeader({ result }: { result: BrandLookupResult }) {
return ( return (
<section className="flex flex-wrap items-baseline justify-between gap-2"> <section className="flex flex-wrap items-baseline justify-between gap-2">

View File

@ -131,24 +131,15 @@ function useLaunchMutations({
return { startMutation, deleteMutation }; return { startMutation, deleteMutation };
} }
function applyMaxPages( function commitMaxPagesInput(launchForm: {
launchForm: { state: { values: { maxPagesInput: string } };
setFieldValue: (field: "maxPagesInput", value: string) => void; setFieldValue: (field: "maxPagesInput", value: string) => void;
}, }) {
value: number, const maxPagesInput = launchForm.state.values.maxPagesInput;
) { const value = maxPagesInput ? Number.parseInt(maxPagesInput, 10) : MIN_PAGES;
const safeValue = Number.isFinite(value) const safeValue = Number.isFinite(value)
? Math.max(MIN_PAGES, Math.min(MAX_PAGES_LIMIT, Math.round(value))) ? Math.max(MIN_PAGES, Math.min(MAX_PAGES_LIMIT, Math.round(value)))
: MIN_PAGES; : MIN_PAGES;
launchForm.setFieldValue("maxPagesInput", String(safeValue)); launchForm.setFieldValue("maxPagesInput", String(safeValue));
return safeValue; return safeValue;
} }
function commitMaxPagesInput(launchForm: {
state: { values: { maxPagesInput: string } };
setFieldValue: (field: "maxPagesInput", value: string) => void;
}) {
const maxPagesInput = launchForm.state.values.maxPagesInput;
if (!maxPagesInput) return applyMaxPages(launchForm, MIN_PAGES);
return applyMaxPages(launchForm, Number.parseInt(maxPagesInput, 10));
}

View File

@ -7,7 +7,7 @@ export type PerformanceRowData = PerformanceResultRow & {
pagePath: string | null; pagePath: string | null;
}; };
export type LighthouseFailureFields = { type LighthouseFailureFields = {
errorMessage: string | null; errorMessage: string | null;
performanceScore: number | null; performanceScore: number | null;
accessibilityScore: number | null; accessibilityScore: number | null;

View File

@ -30,24 +30,15 @@ import {
EMPTY_PERFORMANCE_FILTERS, EMPTY_PERFORMANCE_FILTERS,
filterPages, filterPages,
filterPerformanceRows, filterPerformanceRows,
isLighthouseFailure as getIsLighthouseFailure, isLighthouseFailure,
nullableNumberSort, nullableNumberSort,
nullableStringSort, nullableStringSort,
type LighthouseFailureFields,
type PageRow, type PageRow,
type PagesFilters, type PagesFilters,
type PerformanceFilters, type PerformanceFilters,
type PerformanceRowData, type PerformanceRowData,
} from "@/client/features/audit/results/AuditResultsTableFilterLogic"; } from "@/client/features/audit/results/AuditResultsTableFilterLogic";
export function isLighthouseFailure(row: LighthouseFailureFields) {
return getIsLighthouseFailure(row);
}
function getLighthouseFailureMessage(row: LighthouseFailureFields) {
return row.errorMessage ?? "Lighthouse returned no category scores";
}
const pageColumnHelper = createColumnHelper<PageRow>(); const pageColumnHelper = createColumnHelper<PageRow>();
const performanceColumnHelper = createColumnHelper<PerformanceRowData>(); const performanceColumnHelper = createColumnHelper<PerformanceRowData>();
@ -275,7 +266,8 @@ function buildPerformanceColumns({
header: ({ column }) => <SortableHeader column={column} label="Status" />, header: ({ column }) => <SortableHeader column={column} label="Status" />,
cell: ({ row }) => { cell: ({ row }) => {
const isFailed = isLighthouseFailure(row.original); const isFailed = isLighthouseFailure(row.original);
const failureMessage = getLighthouseFailureMessage(row.original); const failureMessage =
row.original.errorMessage ?? "Lighthouse returned no category scores";
return isFailed ? ( return isFailed ? (
<span <span
className="badge badge-error badge-outline text-xs" className="badge badge-error badge-outline text-xs"

View File

@ -5,9 +5,9 @@ import {
exportPerformance, exportPerformance,
} from "@/client/features/audit/results/export"; } from "@/client/features/audit/results/export";
import type { AuditResultsData } from "@/client/features/audit/results/types"; import type { AuditResultsData } from "@/client/features/audit/results/types";
import { isLighthouseFailure } from "@/client/features/audit/results/AuditResultsTableFilterLogic";
import { import {
ExportDropdown, ExportDropdown,
isLighthouseFailure,
PagesTable, PagesTable,
PerformanceTable, PerformanceTable,
} from "@/client/features/audit/results/ResultsTables"; } from "@/client/features/audit/results/ResultsTables";

View File

@ -1,17 +1,8 @@
import type { AuditResultsData } from "@/client/features/audit/results/types"; import type { AuditResultsData } from "@/client/features/audit/results/types";
import { buildCsv, type CsvValue, downloadCsv } from "@/client/lib/csv"; import { buildCsv, type CsvValue, downloadCsv } from "@/client/lib/csv";
import { downloadFile } from "@/client/lib/download";
import { exportTableToSheets } from "@/client/lib/exportToSheets"; import { exportTableToSheets } from "@/client/lib/exportToSheets";
function downloadFile(content: string, filename: string, mime: string) {
const blob = new Blob([content], { type: `${mime};charset=utf-8;` });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
link.click();
URL.revokeObjectURL(url);
}
const PAGES_HEADERS = [ const PAGES_HEADERS = [
"URL", "URL",
"Status", "Status",

View File

@ -4,10 +4,6 @@ import {
getOAuthSignedQuery, getOAuthSignedQuery,
} from "@/lib/auth-redirect"; } from "@/lib/auth-redirect";
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(),
@ -28,14 +24,6 @@ export function useAuthPageState(redirect: string | undefined) {
}; };
} }
export function getFieldError(errors: readonly unknown[]) {
return getSharedFieldError(errors);
}
export function getFormError(error: unknown) {
return getSharedFormError(error);
}
export function AuthMethodChooser({ export function AuthMethodChooser({
googleLabel, googleLabel,
emailLabel = "Continue with email", emailLabel = "Continue with email",

View File

@ -10,7 +10,7 @@ import {
} from "recharts"; } from "recharts";
import type { BacklinksOverviewData } from "./backlinksPageTypes"; import type { BacklinksOverviewData } from "./backlinksPageTypes";
import { import {
formatFullDate, formatCompactDate,
formatMonthLabel, formatMonthLabel,
formatTooltipValue, formatTooltipValue,
} from "./backlinksPageUtils"; } from "./backlinksPageUtils";
@ -194,5 +194,5 @@ function formatChartTick(value: unknown) {
} }
function formatChartLabel(value: unknown) { function formatChartLabel(value: unknown) {
return typeof value === "string" ? formatFullDate(value) : ""; return typeof value === "string" ? formatCompactDate(value) : "";
} }

View File

@ -4,7 +4,6 @@ import {
BacklinksResultsCard, BacklinksResultsCard,
} from "./BacklinksPageSections"; } from "./BacklinksPageSections";
import { import {
BacklinksAccessLoadingState,
BacklinksErrorState, BacklinksErrorState,
BacklinksLoadingState, BacklinksLoadingState,
BacklinksSetupGate, BacklinksSetupGate,
@ -18,6 +17,7 @@ import type {
BacklinksTopPagesData, BacklinksTopPagesData,
} from "./backlinksPageTypes"; } from "./backlinksPageTypes";
import type { UseAccessGateResult } from "@/client/features/access-gate/useAccessGate"; import type { UseAccessGateResult } from "@/client/features/access-gate/useAccessGate";
import { AccessGateLoadingState } from "@/client/features/access-gate/AccessGate";
import { buildSummaryStats } from "./backlinksPageUtils"; import { buildSummaryStats } from "./backlinksPageUtils";
import { import {
filterBacklinkRows, filterBacklinkRows,
@ -118,7 +118,7 @@ export function BacklinksBody({
) : null; ) : null;
if (accessGate.isLoading) { if (accessGate.isLoading) {
return <BacklinksAccessLoadingState />; return <AccessGateLoadingState />;
} }
if (accessGate.statusErrorMessage) { if (accessGate.statusErrorMessage) {

View File

@ -1,18 +1,6 @@
import { SafeExternalLink } from "@/client/components/SafeExternalLink"; import { SafeExternalLink } from "@/client/components/SafeExternalLink";
import { extractUrlPath, truncateMiddle } from "./backlinksPageUtils"; import { extractUrlPath, truncateMiddle } from "./backlinksPageUtils";
export function BacklinksExternalLink({
url,
label,
className,
}: {
url: string;
label: string;
className: string;
}) {
return <SafeExternalLink url={url} label={label} className={className} />;
}
export function BacklinksSourceLink({ export function BacklinksSourceLink({
url, url,
maxLength, maxLength,
@ -23,7 +11,7 @@ export function BacklinksSourceLink({
muted?: boolean; muted?: boolean;
}) { }) {
return ( return (
<BacklinksExternalLink <SafeExternalLink
url={url} url={url}
label={truncateMiddle(extractUrlPath(url), maxLength)} label={truncateMiddle(extractUrlPath(url), maxLength)}
className={`link link-hover break-all inline-flex items-center gap-1 ${muted ? "text-xs text-base-content/55" : "text-sm"}`} className={`link link-hover break-all inline-flex items-center gap-1 ${muted ? "text-xs text-base-content/55" : "text-sm"}`}

View File

@ -1,12 +1,5 @@
import { ShieldAlert } from "lucide-react"; import { ShieldAlert } from "lucide-react";
import { import { AccessGate } from "@/client/features/access-gate/AccessGate";
AccessGate,
AccessGateLoadingState,
} from "@/client/features/access-gate/AccessGate";
export function BacklinksAccessLoadingState() {
return <AccessGateLoadingState />;
}
export function BacklinksSetupGate({ export function BacklinksSetupGate({
errorMessage, errorMessage,

View File

@ -8,7 +8,10 @@ import {
shouldValidateFieldOnChange, shouldValidateFieldOnChange,
} from "@/client/lib/forms"; } from "@/client/lib/forms";
import type { BacklinksSearchState } from "./backlinksPageTypes"; import type { BacklinksSearchState } from "./backlinksPageTypes";
import { resolveBacklinksSearchScope } from "./backlinksSearchScope"; import {
inferBacklinksSearchScopeFromTarget,
resolveBacklinksSearchScope,
} from "./backlinksSearchScope";
type SearchDraft = Pick<BacklinksSearchState, "target" | "scope">; type SearchDraft = Pick<BacklinksSearchState, "target" | "scope">;
@ -124,11 +127,7 @@ export function BacklinksSearchCard({
if (!userSelectedScope) { if (!userSelectedScope) {
form.setFieldValue( form.setFieldValue(
"scope", "scope",
resolveBacklinksSearchScope({ inferBacklinksSearchScopeFromTarget(nextTarget),
target: nextTarget,
selectedScope: form.state.values.scope,
userSelectedScope: false,
}),
); );
} }
}} }}

View File

@ -4,6 +4,7 @@ import {
type SortingState, type SortingState,
} from "@tanstack/react-table"; } from "@tanstack/react-table";
import { useState } from "react"; import { useState } from "react";
import { SafeExternalLink } from "@/client/components/SafeExternalLink";
import { import {
AppDataTable, AppDataTable,
useAppTable, useAppTable,
@ -23,7 +24,6 @@ import {
formatDecimal, formatDecimal,
formatNumber, formatNumber,
} from "./backlinksPageUtils"; } from "./backlinksPageUtils";
import { BacklinksExternalLink } from "./BacklinksPageLinks";
type ReferringDomainRow = BacklinksOverviewData["referringDomains"][number]; type ReferringDomainRow = BacklinksOverviewData["referringDomains"][number];
@ -60,7 +60,7 @@ const columns = [
const domain = getValue(); const domain = getValue();
if (!domain) return "-"; if (!domain) return "-";
return ( return (
<BacklinksExternalLink <SafeExternalLink
url={getDomainWebsiteHref(domain)} url={getDomainWebsiteHref(domain)}
label={domain} label={domain}
className="link link-primary link-hover break-all inline-flex items-center gap-1" className="link link-primary link-hover break-all inline-flex items-center gap-1"

View File

@ -1,5 +1,6 @@
import { createColumnHelper, type SortingState } from "@tanstack/react-table"; import { createColumnHelper, type SortingState } from "@tanstack/react-table";
import { useState } from "react"; import { useState } from "react";
import { SafeExternalLink } from "@/client/components/SafeExternalLink";
import { import {
AppDataTable, AppDataTable,
useAppTable, useAppTable,
@ -10,7 +11,6 @@ import {
stringNullsLast, stringNullsLast,
} from "@/client/components/table/nullSafeSort"; } from "@/client/components/table/nullSafeSort";
import { EmptyTableState } from "./BacklinksPageEmptyTableState"; import { EmptyTableState } from "./BacklinksPageEmptyTableState";
import { BacklinksExternalLink } from "./BacklinksPageLinks";
import type { BacklinksOverviewData } from "./backlinksPageTypes"; import type { BacklinksOverviewData } from "./backlinksPageTypes";
import { formatNumber } from "./backlinksPageUtils"; import { formatNumber } from "./backlinksPageUtils";
@ -30,7 +30,7 @@ const columns = [
cell: ({ getValue }) => { cell: ({ getValue }) => {
const page = getValue(); const page = getValue();
return page ? ( return page ? (
<BacklinksExternalLink <SafeExternalLink
url={page} url={page}
label={page} label={page}
className="link link-hover break-all inline-flex items-center gap-1" className="link link-hover break-all inline-flex items-center gap-1"

View File

@ -89,16 +89,6 @@ export function formatCompactDate(value: string | null | undefined) {
}); });
} }
export function formatFullDate(value: string) {
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return value;
return parsed.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
});
}
export function formatMonthLabel(value: string) { export function formatMonthLabel(value: string) {
const parsed = new Date(value); const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return value; if (Number.isNaN(parsed.getTime())) return value;

View File

@ -80,10 +80,7 @@ function getSortSearchUpdate(
}; };
} }
function getLocationSearchUpdate( function getLocationSearchUpdate(nextLocationCode: number): DomainSearchUpdate {
nextLocationCode: number,
): DomainSearchUpdate | null {
if (!isSupportedLocationCode(nextLocationCode)) return null;
return { return {
loc: loc:
nextLocationCode === DEFAULT_LOCATION_CODE ? undefined : nextLocationCode, nextLocationCode === DEFAULT_LOCATION_CODE ? undefined : nextLocationCode,
@ -208,8 +205,7 @@ function useDomainOverviewState({
const applyLocationChange = useCallback( const applyLocationChange = useCallback(
(nextLocationCode: number) => { (nextLocationCode: number) => {
const update = getLocationSearchUpdate(nextLocationCode); setSearchParams(getLocationSearchUpdate(nextLocationCode));
if (update) setSearchParams(update);
}, },
[setSearchParams], [setSearchParams],
); );

View File

@ -60,9 +60,6 @@ export function DomainFilterPanel<TValues extends FilterValues>({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [appliedKey]); }, [appliedKey]);
const onValueChange = useCallback((key: keyof TValues, value: string) => {
setDraftFilters((current) => ({ ...current, [key]: value }));
}, []);
const meta = useMemo( const meta = useMemo(
() => () =>
getFilterMeta({ getFilterMeta({
@ -114,9 +111,9 @@ export function DomainFilterPanel<TValues extends FilterValues>({
field: String(key), field: String(key),
valueLength: value.length, valueLength: value.length,
}); });
onValueChange(key, value); setDraftFilters((current) => ({ ...current, [key]: value }));
}, },
[debugName, onValueChange], [debugName],
); );
return ( return (

View File

@ -29,7 +29,7 @@ export const PAGE_FILTER_FIELDS = [
"maxVol", "maxVol",
] as const satisfies ReadonlyArray<keyof PagesFilterValues>; ] as const satisfies ReadonlyArray<keyof PagesFilterValues>;
const PAGE_SEARCH_PARAM_BY_FIELD = { export const PAGE_SEARCH_PARAM_BY_FIELD = {
include: "pInclude", include: "pInclude",
exclude: "pExclude", exclude: "pExclude",
minTraffic: "pMinTraffic", minTraffic: "pMinTraffic",
@ -42,23 +42,10 @@ type SearchUpdate = Partial<DomainSearchParams>;
type FilterValues = Record<string, string>; type FilterValues = Record<string, string>;
type FilterKey<TValues extends FilterValues> = Extract<keyof TValues, string>; type FilterKey<TValues extends FilterValues> = Extract<keyof TValues, string>;
export function getPageFilterSearchParam(
key: PageFilterKey,
): (typeof PAGE_SEARCH_PARAM_BY_FIELD)[PageFilterKey] {
return PAGE_SEARCH_PARAM_BY_FIELD[key];
}
export function countKeywordFilterConditions( export function countKeywordFilterConditions(
values: KeywordsFilterValues, values: KeywordsFilterValues,
): number { ): number {
let n = 0; return countFilterConditions(values, KEYWORD_FILTER_FIELDS);
for (const term of values.include.split(/[,+]/)) if (term.trim()) n += 1;
for (const term of values.exclude.split(/[,+]/)) if (term.trim()) n += 1;
for (const key of KEYWORD_FILTER_FIELDS) {
if (key === "include" || key === "exclude") continue;
if (values[key].trim() !== "") n += 1;
}
return n;
} }
export function countPageFilterConditions(values: PagesFilterValues): number { export function countPageFilterConditions(values: PagesFilterValues): number {
@ -81,14 +68,14 @@ export function buildPagesSearchUpdate(
return buildFilterSearchUpdate<PagesFilterValues>( return buildFilterSearchUpdate<PagesFilterValues>(
values, values,
PAGE_FILTER_FIELDS, PAGE_FILTER_FIELDS,
(key) => getPageFilterSearchParam(key), (key) => PAGE_SEARCH_PARAM_BY_FIELD[key],
); );
} }
export function buildPagesClearSearchUpdate(): SearchUpdate { export function buildPagesClearSearchUpdate(): SearchUpdate {
return buildFilterClearSearchUpdate<PagesFilterValues>( return buildFilterClearSearchUpdate<PagesFilterValues>(
PAGE_FILTER_FIELDS, PAGE_FILTER_FIELDS,
(key) => getPageFilterSearchParam(key), (key) => PAGE_SEARCH_PARAM_BY_FIELD[key],
); );
} }
@ -99,8 +86,9 @@ export function buildDomainFiltersClearSearchUpdate(): SearchUpdate {
); );
Object.assign( Object.assign(
update, update,
buildFilterClearSearchUpdate<PagesFilterValues>(PAGE_FILTER_FIELDS, (key) => buildFilterClearSearchUpdate<PagesFilterValues>(
getPageFilterSearchParam(key), PAGE_FILTER_FIELDS,
(key) => PAGE_SEARCH_PARAM_BY_FIELD[key],
), ),
); );
return update; return update;

View File

@ -18,7 +18,7 @@ import {
import { import {
KEYWORD_FILTER_FIELDS, KEYWORD_FILTER_FIELDS,
PAGE_FILTER_FIELDS, PAGE_FILTER_FIELDS,
getPageFilterSearchParam, PAGE_SEARCH_PARAM_BY_FIELD,
} from "@/client/features/domain/domainFilterUtils"; } from "@/client/features/domain/domainFilterUtils";
import { resolveSortOrder, toSortMode, toSortOrder } from "./utils"; import { resolveSortOrder, toSortMode, toSortOrder } from "./utils";
@ -95,6 +95,6 @@ function hasKeywordSearchFilters(search: DomainSearchParams): boolean {
function hasPageSearchFilters(search: DomainSearchParams): boolean { function hasPageSearchFilters(search: DomainSearchParams): boolean {
return PAGE_FILTER_FIELDS.some( return PAGE_FILTER_FIELDS.some(
(key) => search[getPageFilterSearchParam(key)] != null, (key) => search[PAGE_SEARCH_PARAM_BY_FIELD[key]] != null,
); );
} }

View File

@ -17,7 +17,7 @@ export function OverviewStats({ keyword }: { keyword: KeywordResearchRow }) {
<span className="font-bold text-base truncate max-w-[240px] capitalize"> <span className="font-bold text-base truncate max-w-[240px] capitalize">
{keyword.keyword} {keyword.keyword}
</span> </span>
<ScoreBadge value={keyword.keywordDifficulty} size="sm" /> <ScoreBadge value={keyword.keywordDifficulty} />
</div> </div>
<div className="w-px h-6 bg-base-300 shrink-0" /> <div className="w-px h-6 bg-base-300 shrink-0" />
@ -47,24 +47,14 @@ export function OverviewStats({ keyword }: { keyword: KeywordResearchRow }) {
); );
} }
function ScoreBadge({ function ScoreBadge({ value }: { value: number | null }) {
value,
size = "sm",
}: {
value: number | null;
size?: "sm" | "lg";
}) {
if (value == null) return null; if (value == null) return null;
const tierClass = scoreTierClass(value); const tierClass = scoreTierClass(value);
const sizeClasses =
size === "lg"
? "size-9 text-sm font-bold"
: "size-6 text-[10px] font-semibold";
return ( return (
<span <span
className={`score-badge ${tierClass} inline-flex items-center justify-center rounded-full ${sizeClasses}`} className={`score-badge ${tierClass} inline-flex items-center justify-center rounded-full size-6 text-[10px] font-semibold`}
> >
{value} {value}
</span> </span>

View File

@ -58,121 +58,6 @@ export type KeywordResearchControllerInput = {
export function useKeywordResearchController( export function useKeywordResearchController(
input: KeywordResearchControllerInput, input: KeywordResearchControllerInput,
) { ) {
const state = useKeywordControllerState(input);
const controlsForm = state.controlsForm;
const setSearchParams = state.setSearchParams;
const retryResearch = state.retryResearch;
const onSearch = useCallback(
(overrides?: Partial<{ keyword: string; locationCode: number }>) => {
if (overrides?.keyword !== undefined) {
controlsForm.setFieldValue("keyword", overrides.keyword);
}
if (overrides?.locationCode !== undefined) {
controlsForm.setFieldValue("locationCode", overrides.locationCode);
}
void controlsForm.handleSubmit();
},
[controlsForm],
);
const retrySearch = useCallback(() => {
void retryResearch();
}, [retryResearch]);
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, sheetsExportRows } =
useSaveAndExportActions({
selectedRows: state.selectedRows,
rows: state.rows,
filteredRows: state.filteredRows,
input,
saveKeywordsMutate: state.saveMutation.mutate,
setShowSaveDialog: state.setShowSaveDialog,
});
const handleToggleAllRows = () => {
state.toggleAllRows(state.filteredRows.map((row) => row.keyword));
};
const handleRowClick = (row: KeywordResearchRow) => {
captureClientEvent("keyword_research:serp_open");
state.setSelectedKeyword(row);
state.setSerpKeyword(row.keyword);
state.setSerpPage(0);
};
return {
activeFilterCount: state.activeFilterCount,
activeSerpKeyword: state.activeSerpKeyword,
confirmSave,
controlsForm: state.controlsForm,
exportCsv,
sheetsExportRows,
filteredRows: state.filteredRows,
filtersForm: state.filtersForm,
handleRowClick,
handleSaveKeywords,
handleSearchSubmit,
hasSearched: state.hasSearched,
history: state.history,
historyLoaded: state.historyLoaded,
isLoading: state.isLoading,
lastResultSource: state.lastResultSource,
lastSearchError: state.lastSearchError,
lastSearchKeyword: state.lastSearchKeyword,
lastSearchLocationCode: state.lastSearchLocationCode,
lastUsedFallback: state.lastUsedFallback,
mobileTab: state.mobileTab,
onSearch,
overviewKeyword: state.overviewKeyword,
removeHistoryItem: state.removeHistoryItem,
researchError: state.researchError,
researchMutationError: state.researchMutationError,
retrySearch,
resetFilters: state.resetFilters,
rows: state.rows,
searchedKeyword: state.searchedKeyword,
selectedRows: state.selectedRows,
serpError: state.serpError,
serpLoading: state.serpLoading,
serpPage: state.serpPage,
serpQuery: state.serpQuery,
serpResults: state.serpResults,
setMobileTab: state.setMobileTab,
setSelectedRows: state.setSelectedRows,
setSerpPage: state.setSerpPage,
setShowFilters: state.setShowFilters,
setShowSaveDialog: state.setShowSaveDialog,
showApproximateMatchNotice: state.showApproximateMatchNotice,
showFilters: state.showFilters,
showSaveDialog: state.showSaveDialog,
sortDir: input.sortDir,
sortField: input.sortField,
toggleAllRows: handleToggleAllRows,
toggleRowSelection: state.toggleRowSelection,
toggleSort,
SERP_PAGE_SIZE: state.SERP_PAGE_SIZE,
};
}
function useKeywordControllerState(input: KeywordResearchControllerInput) {
const { locationCode, setPreferredLocationCode } = const { locationCode, setPreferredLocationCode } =
useResolvedKeywordLocation(input); useResolvedKeywordLocation(input);
const { const {
@ -277,11 +162,6 @@ function useKeywordControllerState(input: KeywordResearchControllerInput) {
previousSearchKeyRef.current = activeSearchKey; previousSearchKeyRef.current = activeSearchKey;
handledSerpSearchKeyRef.current = null; handledSerpSearchKeyRef.current = null;
if (!activeSearchKey) {
clearActiveKeywordResult();
return;
}
clearActiveKeywordResult(); clearActiveKeywordResult();
}, [activeSearchKey, clearActiveKeywordResult]); }, [activeSearchKey, clearActiveKeywordResult]);
@ -319,13 +199,58 @@ function useKeywordControllerState(input: KeywordResearchControllerInput) {
keywordMode: input.keywordMode, keywordMode: input.keywordMode,
}); });
const retrySearch = useCallback(() => {
void retryResearch();
}, [retryResearch]);
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, sheetsExportRows } =
useSaveAndExportActions({
selectedRows,
rows,
filteredRows,
input,
saveKeywordsMutate: saveMutation.mutate,
setShowSaveDialog: uiState.setShowSaveDialog,
});
const handleToggleAllRows = () => {
toggleAllRows(filteredRows.map((row) => row.keyword));
};
const handleRowClick = (row: KeywordResearchRow) => {
captureClientEvent("keyword_research:serp_open");
uiState.setSelectedKeyword(row);
setSerpKeyword(row.keyword);
setSerpPage(0);
};
return { return {
activeFilterCount, activeFilterCount,
activeSerpKeyword, activeSerpKeyword,
clearSelection, confirmSave,
controlsForm, controlsForm,
exportCsv,
sheetsExportRows,
filteredRows, filteredRows,
filtersForm, filtersForm,
handleRowClick,
handleSaveKeywords,
handleSearchSubmit,
hasSearched, hasSearched,
history, history,
historyLoaded, historyLoaded,
@ -340,32 +265,29 @@ function useKeywordControllerState(input: KeywordResearchControllerInput) {
removeHistoryItem, removeHistoryItem,
researchError, researchError,
researchMutationError, researchMutationError,
retryResearch, retrySearch,
resetFilters, resetFilters,
rows, rows,
searchedKeyword, searchedKeyword,
selectedKeyword: uiState.selectedKeyword,
selectedRows, selectedRows,
setSelectedRows,
saveMutation,
setPreferredLocationCode,
setSelectedKeyword: uiState.setSelectedKeyword,
setSearchParams,
setSerpKeyword,
serpError, serpError,
serpLoading, serpLoading,
serpPage, serpPage,
serpQuery, serpQuery,
serpResults, serpResults,
setMobileTab: uiState.setMobileTab, setMobileTab: uiState.setMobileTab,
setSelectedRows,
setSerpPage, setSerpPage,
setShowFilters: uiState.setShowFilters, setShowFilters: uiState.setShowFilters,
setShowSaveDialog: uiState.setShowSaveDialog, setShowSaveDialog: uiState.setShowSaveDialog,
showApproximateMatchNotice, showApproximateMatchNotice,
showFilters: uiState.showFilters, showFilters: uiState.showFilters,
showSaveDialog: uiState.showSaveDialog, showSaveDialog: uiState.showSaveDialog,
toggleAllRows, sortDir: input.sortDir,
sortField: input.sortField,
toggleAllRows: handleToggleAllRows,
toggleRowSelection, toggleRowSelection,
toggleSort,
SERP_PAGE_SIZE, SERP_PAGE_SIZE,
}; };
} }

View File

@ -5,15 +5,10 @@ import {
exportAuditLighthouseIssues, exportAuditLighthouseIssues,
getAuditLighthouseIssues, getAuditLighthouseIssues,
} from "@/serverFunctions/lighthouse"; } from "@/serverFunctions/lighthouse";
import { downloadFile } from "@/client/lib/download";
import { exportTableToSheets } from "@/client/lib/exportToSheets"; import { exportTableToSheets } from "@/client/lib/exportToSheets";
import type { CategoryTab, ExportPayload, LighthouseIssue } from "./types"; import type { CategoryTab, ExportPayload, LighthouseIssue } from "./types";
import { import { categoryLabel, issuesToCsv, issuesToTable } from "./utils";
categoryLabel,
categorySlug,
downloadTextFile,
issuesToCsv,
issuesToTable,
} from "./utils";
import { import {
LighthouseIssueList, LighthouseIssueList,
LighthouseIssuesHeader, LighthouseIssuesHeader,
@ -170,7 +165,7 @@ function useLighthouseIssuesActions({
const runExport = async (data: ExportPayload) => { const runExport = async (data: ExportPayload) => {
try { try {
const exported = await exportMutation.mutateAsync(data); const exported = await exportMutation.mutateAsync(data);
downloadTextFile(exported.filename, exported.content, "application/json"); downloadFile(exported.content, exported.filename, "application/json");
toast.success("Download started"); toast.success("Download started");
} catch (error) { } catch (error) {
const message = const message =
@ -183,8 +178,8 @@ function useLighthouseIssuesActions({
rows: LighthouseIssue[], rows: LighthouseIssue[],
variant: "all" | "current", variant: "all" | "current",
) => { ) => {
const filename = `lighthouse-${variant}-${categorySlug(category)}-issues.csv`; const filename = `lighthouse-${variant}-${category}-issues.csv`;
downloadTextFile(filename, issuesToCsv(rows), "text/csv"); downloadFile(issuesToCsv(rows), filename, "text/csv");
toast.success("CSV download started"); toast.success("CSV download started");
}; };

View File

@ -37,23 +37,6 @@ export function categoryLabel(category: CategoryTab) {
return `${category.charAt(0).toUpperCase()}${category.slice(1)}`; return `${category.charAt(0).toUpperCase()}${category.slice(1)}`;
} }
export function categorySlug(category: CategoryTab) {
return category === "all" ? "all" : category;
}
export function issuesToCsv(issues: LighthouseIssue[]) { export function issuesToCsv(issues: LighthouseIssue[]) {
return buildCsv(ISSUE_HEADERS, issuesToRows(issues)); return buildCsv(ISSUE_HEADERS, issuesToRows(issues));
} }
export function downloadTextFile(
filename: string,
content: string,
mimeType: string,
) {
const blob = new Blob([content], { type: mimeType });
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = filename;
link.click();
URL.revokeObjectURL(link.href);
}

View File

@ -234,7 +234,6 @@ function McpRecommendation({
function OnboardingChoiceGroup({ function OnboardingChoiceGroup({
title, title,
description, description,
reason,
options, options,
selectedValues, selectedValues,
onToggle, onToggle,
@ -246,7 +245,6 @@ function OnboardingChoiceGroup({
}: { }: {
title: string; title: string;
description?: string; description?: string;
reason?: string;
options: string[]; options: string[];
selectedValues: string[]; selectedValues: string[];
onToggle: (value: string) => void; onToggle: (value: string) => void;
@ -275,11 +273,6 @@ function OnboardingChoiceGroup({
{description ? ( {description ? (
<p className="mt-1 text-sm text-base-content/60">{description}</p> <p className="mt-1 text-sm text-base-content/60">{description}</p>
) : null} ) : null}
{reason ? (
<p className="mt-2 text-xs leading-relaxed text-base-content/55">
{reason}
</p>
) : null}
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">

View File

@ -13,11 +13,7 @@ export function AddKeywordsPanel({
}: { }: {
configId: string; configId: string;
projectId: string; projectId: string;
onSuccess: (result: { onSuccess: (result: { added: number; checkTriggered: boolean }) => void;
added: number;
addedIds: string[];
checkTriggered: boolean;
}) => void;
onCancel: () => void; onCancel: () => void;
}) { }) {
const [keywordInput, setKeywordInput] = useState(""); const [keywordInput, setKeywordInput] = useState("");

View File

@ -4,7 +4,6 @@ import type { ColumnDef, SortingFn } from "@tanstack/react-table";
import { makeSelectionColumn } from "@/client/components/table/AppDataTable"; import { makeSelectionColumn } from "@/client/components/table/AppDataTable";
import type { RankTrackingRow } from "@/types/schemas/rank-tracking"; import type { RankTrackingRow } from "@/types/schemas/rank-tracking";
import { import {
comparePositions,
CpcCell, CpcCell,
DeviceRankCell, DeviceRankCell,
DeviceUrlCell, DeviceUrlCell,
@ -101,20 +100,6 @@ const cpcColumn: ColumnDef<RankTrackingRow> = {
sortingFn: nullsLastNumeric, sortingFn: nullsLastNumeric,
}; };
const positionSort: SortingFn<RankTrackingRow> = (rowA, rowB, columnId) => {
const device = columnId === "desktopPosition" ? "desktop" : "mobile";
return comparePositions(
rowA.original[device].position,
rowB.original[device].position,
);
};
function makeSelectColumn(
anchorRef: MutableRefObject<SelectionAnchor | null>,
): ColumnDef<RankTrackingRow> {
return makeSelectionColumn<RankTrackingRow>(anchorRef);
}
const keywordColumn: ColumnDef<RankTrackingRow> = { const keywordColumn: ColumnDef<RankTrackingRow> = {
id: "keyword", id: "keyword",
accessorKey: "keyword", accessorKey: "keyword",
@ -140,7 +125,7 @@ function makeDeviceColumn(
size: 120, size: 120,
maxSize: 140, maxSize: 140,
cell: ({ row }) => <DeviceRankCell result={row.original[device]} />, cell: ({ row }) => <DeviceRankCell result={row.original[device]} />,
sortingFn: positionSort, sortingFn: nullsLastNumeric,
}; };
} }
@ -196,7 +181,7 @@ export function useRankTrackingColumns(
): ColumnDef<RankTrackingRow>[] { ): ColumnDef<RankTrackingRow>[] {
return useMemo(() => { return useMemo(() => {
const cols: ColumnDef<RankTrackingRow>[] = [ const cols: ColumnDef<RankTrackingRow>[] = [
makeSelectColumn(selectAnchorRef), makeSelectionColumn<RankTrackingRow>(selectAnchorRef),
keywordColumn, keywordColumn,
]; ];
if (showDesktop) { if (showDesktop) {

View File

@ -122,7 +122,6 @@ function RankTrackingDomainDetailInner({
const handleKeywordsAdded = (result: { const handleKeywordsAdded = (result: {
added: number; added: number;
addedIds: string[];
checkTriggered: boolean; checkTriggered: boolean;
}) => { }) => {
void queryClient.invalidateQueries({ void queryClient.invalidateQueries({

View File

@ -14,10 +14,7 @@ import {
getRankTrackingConfigSummaries, getRankTrackingConfigSummaries,
updateRankTrackingConfig, updateRankTrackingConfig,
} from "@/serverFunctions/rank-tracking"; } from "@/serverFunctions/rank-tracking";
import { import { devicesLabel, scheduleLabel } from "@/shared/rank-tracking";
devicesLabel as getDevicesLabel,
scheduleLabel as getScheduleLabel,
} from "@/shared/rank-tracking";
import { Modal } from "@/client/components/Modal"; import { Modal } from "@/client/components/Modal";
type ConfigSummary = Awaited< type ConfigSummary = Awaited<
@ -139,9 +136,6 @@ function DomainRow({
summary: ConfigSummary; summary: ConfigSummary;
onArchive: () => void; onArchive: () => void;
}) { }) {
const dl = getDevicesLabel(summary.devices);
const sl = getScheduleLabel(summary.scheduleInterval);
return ( return (
<div className="relative flex w-full items-center gap-4 px-5 py-3.5 transition-colors hover:bg-base-200/50"> <div className="relative flex w-full items-center gap-4 px-5 py-3.5 transition-colors hover:bg-base-200/50">
<Link <Link
@ -153,7 +147,9 @@ function DomainRow({
<div className="min-w-0 flex-1 pointer-events-none"> <div className="min-w-0 flex-1 pointer-events-none">
<p className="font-medium truncate">{summary.domain}</p> <p className="font-medium truncate">{summary.domain}</p>
<p className="text-xs text-base-content/60"> <p className="text-xs text-base-content/60">
{LOCATIONS[summary.locationCode] ?? "US"} &middot; {dl} &middot; {sl} {LOCATIONS[summary.locationCode] ?? "US"} &middot;{" "}
{devicesLabel(summary.devices)} &middot;{" "}
{scheduleLabel(summary.scheduleInterval)}
{summary.lastRunCompletedAt && ( {summary.lastRunCompletedAt && (
<> <>
{" "} {" "}

View File

@ -159,13 +159,6 @@ export function CpcCell({ value }: { value: number | null }) {
return <span className="font-mono text-sm">${value.toFixed(2)}</span>; return <span className="font-mono text-sm">${value.toFixed(2)}</span>;
} }
export function comparePositions(a: number | null, b: number | null): number {
if (a === null && b === null) return 0;
if (a === null) return 1; // nulls sort last
if (b === null) return -1;
return a - b;
}
/** Numeric change for CSV export — numbers bypass the CSV formula-injection sanitizer */ /** Numeric change for CSV export — numbers bypass the CSV formula-injection sanitizer */
function csvChange( function csvChange(
current: number | null, current: number | null,

View File

@ -0,0 +1,9 @@
export function downloadFile(content: string, filename: string, mime: string) {
const blob = new Blob([content], { type: `${mime};charset=utf-8;` });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
link.click();
URL.revokeObjectURL(url);
}

View File

@ -23,14 +23,10 @@ function extractProjectId(data: unknown) {
: null; : null;
} }
function getRuntimeAuthMode() {
return getAuthMode(import.meta.env.AUTH_MODE ?? env.AUTH_MODE);
}
export const ensureUserMiddleware = createMiddleware({ export const ensureUserMiddleware = createMiddleware({
type: "function", type: "function",
}).server(async ({ next, data }) => { }).server(async ({ next, data }) => {
const authMode = getRuntimeAuthMode(); const authMode = getAuthMode(import.meta.env.AUTH_MODE ?? env.AUTH_MODE);
const headers = getRequest().headers; const headers = getRequest().headers;
let context: EnsuredUserContext; let context: EnsuredUserContext;

View File

@ -59,11 +59,7 @@ function AiPage() {
<p className="text-xs font-medium uppercase tracking-wide text-base-content/50"> <p className="text-xs font-medium uppercase tracking-wide text-base-content/50">
MCP server URL MCP server URL
</p> </p>
<CopyButton <CopyButton value={mcpUrl} successMessage="MCP URL copied" />
value={mcpUrl}
successMessage="MCP URL copied"
label="Copy"
/>
</div> </div>
<code className="mt-2 block break-all font-mono text-sm text-base-content"> <code className="mt-2 block break-all font-mono text-sm text-base-content">
{mcpUrl} {mcpUrl}

View File

@ -6,10 +6,9 @@ import {
AuthPageCard, AuthPageCard,
AuthMethodChooser, AuthMethodChooser,
authRedirectSearchSchema, authRedirectSearchSchema,
getFieldError,
getFormError,
useAuthPageState, useAuthPageState,
} from "@/client/features/auth/AuthPage"; } from "@/client/features/auth/AuthPage";
import { getFieldError, getFormError } from "@/client/lib/forms";
import { captureClientEvent } from "@/client/lib/posthog"; import { captureClientEvent } from "@/client/lib/posthog";
import { authClient } from "@/lib/auth-client"; import { authClient } from "@/lib/auth-client";
import { getSignInSearch } from "@/lib/auth-redirect"; import { getSignInSearch } from "@/lib/auth-redirect";

View File

@ -5,10 +5,9 @@ import {
AuthPageCard, AuthPageCard,
AuthMethodChooser, AuthMethodChooser,
authRedirectSearchSchema, authRedirectSearchSchema,
getFieldError,
getFormError,
useAuthPageState, useAuthPageState,
} from "@/client/features/auth/AuthPage"; } from "@/client/features/auth/AuthPage";
import { getFieldError, getFormError } from "@/client/lib/forms";
import { captureClientEvent } from "@/client/lib/posthog"; import { captureClientEvent } from "@/client/lib/posthog";
import { authClient } from "@/lib/auth-client"; import { authClient } from "@/lib/auth-client";
import { getSignInSearch, getVerifyEmailSearch } from "@/lib/auth-redirect"; import { getSignInSearch, getVerifyEmailSearch } from "@/lib/auth-redirect";

View File

@ -4,9 +4,8 @@ import {
AuthPageCard, AuthPageCard,
AuthPageShell, AuthPageShell,
authRedirectSearchSchema, authRedirectSearchSchema,
getFieldError,
getFormError,
} from "@/client/features/auth/AuthPage"; } from "@/client/features/auth/AuthPage";
import { getFieldError, getFormError } from "@/client/lib/forms";
import { authClient } from "@/lib/auth-client"; import { authClient } from "@/lib/auth-client";
import { isHostedClientAuthMode } from "@/lib/auth-mode"; import { isHostedClientAuthMode } from "@/lib/auth-mode";
import { getSignInSearch, normalizeAuthRedirect } from "@/lib/auth-redirect"; import { getSignInSearch, normalizeAuthRedirect } from "@/lib/auth-redirect";

View File

@ -4,9 +4,8 @@ import {
AuthPageCard, AuthPageCard,
AuthPageShell, AuthPageShell,
authRedirectSearchSchema, authRedirectSearchSchema,
getFieldError,
getFormError,
} from "@/client/features/auth/AuthPage"; } from "@/client/features/auth/AuthPage";
import { getFieldError, getFormError } from "@/client/lib/forms";
import { authClient } from "@/lib/auth-client"; import { authClient } from "@/lib/auth-client";
import { isHostedClientAuthMode } from "@/lib/auth-mode"; import { isHostedClientAuthMode } from "@/lib/auth-mode";
import { getSignInSearch, normalizeAuthRedirect } from "@/lib/auth-redirect"; import { getSignInSearch, normalizeAuthRedirect } from "@/lib/auth-redirect";

View File

@ -230,11 +230,7 @@ function VerifyEmailPage() {
{isResending ? "Sending email..." : "Resend email"} {isResending ? "Sending email..." : "Resend email"}
</button> </button>
</div> </div>
) : isPending ? ( ) : isPending || isVerified ? (
<div className="flex justify-center py-4">
<span className="loading loading-spinner loading-md" />
</div>
) : isVerified ? (
<div className="flex justify-center py-4"> <div className="flex justify-center py-4">
<span className="loading loading-spinner loading-md" /> <span className="loading loading-spinner loading-md" />
</div> </div>

View File

@ -17,9 +17,7 @@ import { handleSelfHostedOpenSeoMcpRequest } from "@/server/mcp/transport";
import { computeNextCheckAt } from "@/shared/rank-tracking"; import { computeNextCheckAt } from "@/shared/rank-tracking";
const appFetch = createStartHandler(defaultStreamHandler); const appFetch = createStartHandler(defaultStreamHandler);
const handleAppFetch = (request: Request): Response | Promise<Response> => const openSeoOAuthProvider = createOpenSeoOAuthProvider(appFetch);
appFetch(request);
const openSeoOAuthProvider = createOpenSeoOAuthProvider(handleAppFetch);
function fetch( function fetch(
request: Request, request: Request,
@ -44,7 +42,7 @@ function fetch(
return handleSelfHostedOpenSeoMcpRequest(publicRequest, authMode, env, ctx); return handleSelfHostedOpenSeoMcpRequest(publicRequest, authMode, env, ctx);
} }
return handleAppFetch(request); return appFetch(request);
} }
// Export Workflow classes as named exports // Export Workflow classes as named exports

View File

@ -1,6 +1,7 @@
import { asc, eq } from "drizzle-orm"; import { asc, eq } from "drizzle-orm";
import { db } from "@/db"; import { db } from "@/db";
import { member, user as authUser } from "@/db/better-auth-schema"; import { member, user as authUser } from "@/db/better-auth-schema";
import { slugify, toHex } from "./org-slug";
type HostedUser = { type HostedUser = {
id: string; id: string;
@ -18,23 +19,6 @@ type HostedOrganizationCreator = (
input: HostedOrganizationCreateInput, input: HostedOrganizationCreateInput,
) => Promise<{ id: string }>; ) => Promise<{ id: string }>;
function slugify(value: string) {
const slug = value
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 48);
return slug || "workspace";
}
function toHex(value: string) {
return Array.from(new TextEncoder().encode(value), (byte) =>
byte.toString(16).padStart(2, "0"),
).join("");
}
function getDefaultHostedOrganizationName(user: HostedUser) { function getDefaultHostedOrganizationName(user: HostedUser) {
const name = user.name?.trim() || user.email.split("@")[0] || "OpenSEO"; const name = user.name?.trim() || user.email.split("@")[0] || "OpenSEO";
return `${name}'s workspace`; return `${name}'s workspace`;

View File

@ -1,22 +1,6 @@
import { db } from "@/db"; import { db } from "@/db";
import { organization } from "@/db/better-auth-schema"; import { organization } from "@/db/better-auth-schema";
import { slugify, toHex } from "./org-slug";
function slugify(value: string) {
const slug = value
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 48);
return slug || "workspace";
}
function toHex(value: string) {
return Array.from(new TextEncoder().encode(value), (byte) =>
byte.toString(16).padStart(2, "0"),
).join("");
}
function getDelegatedOrganizationId(userId: string) { function getDelegatedOrganizationId(userId: string) {
return `delegated-${userId}`; return `delegated-${userId}`;

View File

@ -0,0 +1,16 @@
export function slugify(value: string) {
const slug = value
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 48);
return slug || "workspace";
}
export function toHex(value: string) {
return Array.from(new TextEncoder().encode(value), (byte) =>
byte.toString(16).padStart(2, "0"),
).join("");
}

View File

@ -3,15 +3,21 @@ import { env } from "cloudflare:workers";
const LOOPS_TRANSACTIONAL_URL = "https://app.loops.so/api/v1/transactional"; const LOOPS_TRANSACTIONAL_URL = "https://app.loops.so/api/v1/transactional";
const LOOPS_CONTACT_UPDATE_URL = "https://app.loops.so/api/v1/contacts/update"; const LOOPS_CONTACT_UPDATE_URL = "https://app.loops.so/api/v1/contacts/update";
function getRequiredEnv(name: string) { function getOptionalEnv(name: string) {
const value: unknown = Reflect.get(env, name); const value: unknown = Reflect.get(env, name);
const trimmed = typeof value === "string" ? value.trim() : ""; const trimmed = typeof value === "string" ? value.trim() : "";
if (!trimmed) { return trimmed || null;
}
function getRequiredEnv(name: string) {
const value = getOptionalEnv(name);
if (!value) {
throw new Error(`${name} is required in hosted mode`); throw new Error(`${name} is required in hosted mode`);
} }
return trimmed; return value;
} }
function getHostedAuthEmailConfig() { function getHostedAuthEmailConfig() {
@ -68,13 +74,6 @@ async function sendLoopsTransactionalEmail({
); );
} }
function getOptionalEnv(name: string) {
const value: unknown = Reflect.get(env, name);
const trimmed = typeof value === "string" ? value.trim() : "";
return trimmed || null;
}
function getContactNameParts(name: string | null | undefined) { function getContactNameParts(name: string | null | undefined) {
const trimmedName = name?.trim(); const trimmedName = name?.trim();

View File

@ -1,7 +1,6 @@
import { z } from "zod"; import { z } from "zod";
import type { BillingCustomerContext } from "@/server/billing/subscription"; import type { BillingCustomerContext } from "@/server/billing/subscription";
import { import {
type BacklinksRequest,
type fetchBacklinksHistoryRaw, type fetchBacklinksHistoryRaw,
type fetchBacklinksRowsRaw, type fetchBacklinksRowsRaw,
type fetchBacklinksSummaryRaw, type fetchBacklinksSummaryRaw,
@ -159,9 +158,10 @@ export async function profileTopPagesRows(
const dataforseo = createDataforseoClient(billingCustomer); const dataforseo = createDataforseoClient(billingCustomer);
const request = buildBacklinksRequest( const request = {
normalizeBacklinksTarget(input.target, { scope: input.scope }).apiTarget, target: normalizeBacklinksTarget(input.target, { scope: input.scope })
); .apiTarget,
};
const response = await dataforseo.backlinks.domainPages({ const response = await dataforseo.backlinks.domainPages({
...request, ...request,
limit: 100, limit: 100,
@ -173,17 +173,13 @@ export async function profileTopPagesRows(
return { rows }; return { rows };
} }
function buildBacklinksRequest(target: string): BacklinksRequest {
return { target };
}
function buildBacklinksListRequest( function buildBacklinksListRequest(
target: string, target: string,
limit: number, limit: number,
options?: BacklinksSpamFilterOptions, options?: BacklinksSpamFilterOptions,
) { ) {
return { return {
...buildBacklinksRequest(target), target,
limit, limit,
...normalizeBacklinksSpamFilterOptions(options), ...normalizeBacklinksSpamFilterOptions(options),
}; };

View File

@ -83,10 +83,6 @@ const cachedResultSchema = z.object({
const CACHE_VERSION = 2; const CACHE_VERSION = 2;
function getMode(input: ResearchKeywordsInput): KeywordMode {
return input.mode ?? "auto";
}
async function fetchRowsFromSource( async function fetchRowsFromSource(
source: KeywordSource, source: KeywordSource,
input: ResearchKeywordsInput, input: ResearchKeywordsInput,
@ -195,12 +191,6 @@ async function fetchManualRows(
}; };
} }
function isUsableCachedResult(cached: CachedResult): boolean {
if (cached.rows.length === 0) return false;
return true;
}
async function buildResearchCacheKey( async function buildResearchCacheKey(
input: ResearchKeywordsInput, input: ResearchKeywordsInput,
normalizedKeywords: string[], normalizedKeywords: string[],
@ -254,7 +244,7 @@ export async function research(
} }
const seedKeyword = uniqueKeywords[0]; const seedKeyword = uniqueKeywords[0];
const mode = getMode(input); const mode = input.mode ?? "auto";
const cacheKey = await buildResearchCacheKey( const cacheKey = await buildResearchCacheKey(
input, input,
uniqueKeywords, uniqueKeywords,
@ -268,7 +258,7 @@ export async function research(
? cachedResult.data ? cachedResult.data
: null; : null;
if (cached && isUsableCachedResult(cached)) { if (cached && cached.rows.length > 0) {
return cached; return cached;
} }

View File

@ -69,7 +69,7 @@ async function createConfig(input: {
id: configId, id: configId,
projectId: input.projectId, projectId: input.projectId,
domain: normalizedDomain, domain: normalizedDomain,
locationCode: input.locationCode ?? 2840, locationCode,
languageCode: input.languageCode ?? "en", languageCode: input.languageCode ?? "en",
devices: input.devices ?? "both", devices: input.devices ?? "both",
serpDepth: input.serpDepth, serpDepth: input.serpDepth,

View File

@ -124,9 +124,7 @@ export async function getLatestResults(
} }
return { return {
rows: activeKeywords rows: [...rows.values()],
.map((keyword) => rows.get(keyword.id))
.filter((row): row is RankTrackingRow => row != null),
run: run:
latestRunId && latestStartedAt latestRunId && latestStartedAt
? { id: latestRunId, lastCheckedAt: latestStartedAt } ? { id: latestRunId, lastCheckedAt: latestStartedAt }

View File

@ -119,10 +119,6 @@ function isTimeoutError(error: unknown): boolean {
return "name" in error && error.name === "TimeoutError"; return "name" in error && error.name === "TimeoutError";
} }
function parseXmlDocument(body: string): unknown {
return xmlParser.parse(body) as unknown;
}
async function fetchSitemapDocumentWithRetry(sitemapUrl: string): Promise<{ async function fetchSitemapDocumentWithRetry(sitemapUrl: string): Promise<{
nestedSitemaps: string[]; nestedSitemaps: string[];
pageUrls: string[]; pageUrls: string[];
@ -156,7 +152,7 @@ async function fetchSitemapDocumentWithRetry(sitemapUrl: string): Promise<{
return { nestedSitemaps: [], pageUrls: [], timedOut: false }; return { nestedSitemaps: [], pageUrls: [], timedOut: false };
} }
const parsed = parseXmlDocument(body); const parsed = xmlParser.parse(body) as unknown;
const sections = getParsedSitemapSections(parsed); const sections = getParsedSitemapSections(parsed);
const nestedSitemaps = getSitemapLocations(sections.sitemap) const nestedSitemaps = getSitemapLocations(sections.sitemap)
.map((loc) => normalizeUrl(loc, finalUrl)) .map((loc) => normalizeUrl(loc, finalUrl))

View File

@ -21,17 +21,13 @@ export function analyzeHtml(
): PageAnalysis { ): PageAnalysis {
const $ = cheerio.load(html); const $ = cheerio.load(html);
// --- Title ---
const title = $("title").first().text().trim(); const title = $("title").first().text().trim();
// --- Meta description ---
const metaDescription = const metaDescription =
$('meta[name="description"]').first().attr("content")?.trim() ?? ""; $('meta[name="description"]').first().attr("content")?.trim() ?? "";
// --- Canonical ---
const canonical = $('link[rel="canonical"]').first().attr("href") ?? null; const canonical = $('link[rel="canonical"]').first().attr("href") ?? null;
// --- Robots meta ---
const robotsMeta = $('meta[name="robots"]').first().attr("content") ?? null; const robotsMeta = $('meta[name="robots"]').first().attr("content") ?? null;
// --- Open Graph --- // --- Open Graph ---
@ -67,7 +63,6 @@ export function analyzeHtml(
const bodyText = bodyClone.text().replace(/\s+/g, " ").trim(); const bodyText = bodyClone.text().replace(/\s+/g, " ").trim();
const wordCount = bodyText ? bodyText.split(/\s+/).length : 0; const wordCount = bodyText ? bodyText.split(/\s+/).length : 0;
// --- Images ---
const images: Array<{ src: string | null; alt: string | null }> = []; const images: Array<{ src: string | null; alt: string | null }> = [];
$("img").each((_, el) => { $("img").each((_, el) => {
images.push({ images.push({
@ -103,7 +98,6 @@ export function analyzeHtml(
hasStructuredData = true; hasStructuredData = true;
}); });
// --- Hreflang ---
const hreflangTags: string[] = []; const hreflangTags: string[] = [];
$('link[rel="alternate"][hreflang]').each((_, el) => { $('link[rel="alternate"][hreflang]').each((_, el) => {
const hreflang = $(el).attr("hreflang"); const hreflang = $(el).attr("hreflang");

View File

@ -37,17 +37,6 @@ function key(auditId: string): string {
return `${KV_PREFIX}${auditId}`; return `${KV_PREFIX}${auditId}`;
} }
/**
* Append a crawled URL entry to the progress list.
* Newest entries are prepended so the array is sorted newest-first.
*/
async function pushCrawledUrl(
auditId: string,
entry: CrawledUrlEntry,
): Promise<void> {
await pushCrawledUrls(auditId, [entry]);
}
/** /**
* Append multiple crawled URL entries in one KV write. * Append multiple crawled URL entries in one KV write.
* New entries are prepended and the list is capped. * New entries are prepended and the list is capped.
@ -85,7 +74,6 @@ async function clear(auditId: string): Promise<void> {
} }
export const AuditProgressKV = { export const AuditProgressKV = {
pushCrawledUrl,
pushCrawledUrls, pushCrawledUrls,
getCrawledUrls, getCrawledUrls,
clear, clear,

View File

@ -19,11 +19,7 @@ export function asAppError(error: unknown): AppError | null {
return null; return null;
} }
function toErrorCode(error: unknown): ErrorCode {
return asAppError(error)?.code ?? "INTERNAL_ERROR";
}
export function toClientError(error: unknown): Error { export function toClientError(error: unknown): Error {
const appError = asAppError(error); const appError = asAppError(error);
return new Error(appError?.code ?? toErrorCode(error)); return new Error(appError?.code ?? "INTERNAL_ERROR");
} }

View File

@ -1,62 +1,5 @@
import { z } from "zod"; import { z } from "zod";
import { import { LIGHTHOUSE_CATEGORIES } from "@/shared/lighthouse";
LIGHTHOUSE_CATEGORIES,
type LighthouseCategory,
} from "@/shared/lighthouse";
export type StoredLighthouseIssue = {
category: LighthouseCategory;
auditKey: string;
title: string;
description: string;
score: number | null;
scoreDisplayMode: string | null;
displayValue: string | null;
impactMs: number | null;
impactBytes: number | null;
severity: "critical" | "warning" | "info";
items: string[];
};
type StoredLighthouseMetric = {
score: number | null;
displayValue: string | null;
numericValue: number | null;
};
export type StoredLighthouseMetrics = {
firstContentfulPaint: StoredLighthouseMetric;
largestContentfulPaint: StoredLighthouseMetric;
totalBlockingTime: StoredLighthouseMetric;
cumulativeLayoutShift: StoredLighthouseMetric;
speedIndex: StoredLighthouseMetric;
timeToInteractive: StoredLighthouseMetric;
interactionToNextPaint: StoredLighthouseMetric;
serverResponseTime: StoredLighthouseMetric;
};
export type StoredLighthousePayload = {
version: 2;
source: "dataforseo-lighthouse";
hasIssueDetails: boolean;
metadata: {
requestedUrl: string;
finalUrl: string;
strategy: "mobile" | "desktop";
fetchedAt: string;
lighthouseVersion: string | null;
taskId: string | null;
cost: number | null;
};
scores: {
performance: number | null;
accessibility: number | null;
"best-practices": number | null;
seo: number | null;
};
metrics: StoredLighthouseMetrics;
issues: StoredLighthouseIssue[];
};
export type RawLighthouseAudit = { export type RawLighthouseAudit = {
title?: string; title?: string;
@ -85,6 +28,31 @@ const storedLighthouseMetricSchema = z.object({
numericValue: z.number().nullable(), numericValue: z.number().nullable(),
}); });
const storedLighthouseMetricsSchema = z.object({
firstContentfulPaint: storedLighthouseMetricSchema,
largestContentfulPaint: storedLighthouseMetricSchema,
totalBlockingTime: storedLighthouseMetricSchema,
cumulativeLayoutShift: storedLighthouseMetricSchema,
speedIndex: storedLighthouseMetricSchema,
timeToInteractive: storedLighthouseMetricSchema,
interactionToNextPaint: storedLighthouseMetricSchema,
serverResponseTime: storedLighthouseMetricSchema,
});
const storedLighthouseIssueSchema = z.object({
category: z.enum(LIGHTHOUSE_CATEGORIES),
auditKey: z.string(),
title: z.string(),
description: z.string(),
score: z.number().nullable(),
scoreDisplayMode: z.string().nullable(),
displayValue: z.string().nullable(),
impactMs: z.number().nullable(),
impactBytes: z.number().nullable(),
severity: z.enum(["critical", "warning", "info"]),
items: z.array(z.string()),
});
export const storedLighthousePayloadSchema = z.object({ export const storedLighthousePayloadSchema = z.object({
version: z.literal(2), version: z.literal(2),
source: z.literal("dataforseo-lighthouse"), source: z.literal("dataforseo-lighthouse"),
@ -104,33 +72,17 @@ export const storedLighthousePayloadSchema = z.object({
"best-practices": z.number().nullable(), "best-practices": z.number().nullable(),
seo: z.number().nullable(), seo: z.number().nullable(),
}), }),
metrics: z.object({ metrics: storedLighthouseMetricsSchema,
firstContentfulPaint: storedLighthouseMetricSchema, issues: z.array(storedLighthouseIssueSchema),
largestContentfulPaint: storedLighthouseMetricSchema,
totalBlockingTime: storedLighthouseMetricSchema,
cumulativeLayoutShift: storedLighthouseMetricSchema,
speedIndex: storedLighthouseMetricSchema,
timeToInteractive: storedLighthouseMetricSchema,
interactionToNextPaint: storedLighthouseMetricSchema,
serverResponseTime: storedLighthouseMetricSchema,
}),
issues: z.array(
z.object({
category: z.enum(LIGHTHOUSE_CATEGORIES),
auditKey: z.string(),
title: z.string(),
description: z.string(),
score: z.number().nullable(),
scoreDisplayMode: z.string().nullable(),
displayValue: z.string().nullable(),
impactMs: z.number().nullable(),
impactBytes: z.number().nullable(),
severity: z.enum(["critical", "warning", "info"]),
items: z.array(z.string()),
}),
),
}); });
type StoredLighthouseMetric = z.infer<typeof storedLighthouseMetricSchema>;
type StoredLighthouseMetrics = z.infer<typeof storedLighthouseMetricsSchema>;
export type StoredLighthouseIssue = z.infer<typeof storedLighthouseIssueSchema>;
export type StoredLighthousePayload = z.infer<
typeof storedLighthousePayloadSchema
>;
export function scoreToPercent( export function scoreToPercent(
score: number | null | undefined, score: number | null | undefined,
): number | null { ): number | null {
@ -244,7 +196,7 @@ export function buildStoredLighthouseIssues(input: {
const isPass = const isPass =
score == null || score == null ||
(score != null && score >= 90) || score >= 90 ||
scoreDisplayMode === "notApplicable" || scoreDisplayMode === "notApplicable" ||
scoreDisplayMode === "informative" || scoreDisplayMode === "informative" ||
scoreDisplayMode === "manual" || scoreDisplayMode === "manual" ||

View File

@ -82,12 +82,7 @@ async function upsertAttribution(args: CaptureRedditConversionArgs) {
updatedAt: now, updatedAt: now,
}); });
} }
}
async function hasSentConversion(args: CaptureRedditConversionArgs) {
const existing = await db.query.redditAttributions.findFirst({
where: eq(redditAttributions.userId, args.userId),
});
return args.eventType === "SIGN_UP" return args.eventType === "SIGN_UP"
? Boolean(existing?.signupSentAt) ? Boolean(existing?.signupSentAt)
: Boolean(existing?.purchaseSentAt); : Boolean(existing?.purchaseSentAt);
@ -112,8 +107,8 @@ export async function captureRedditConversion(
) { ) {
if (!hasRedditAttribution(args.attribution)) return "skipped" as const; if (!hasRedditAttribution(args.attribution)) return "skipped" as const;
await upsertAttribution(args); const alreadySent = await upsertAttribution(args);
if (await hasSentConversion(args)) return "already_sent" as const; if (alreadySent) return "already_sent" as const;
const config = getRedditConfig(); const config = getRedditConfig();
if (!config) return "stored" as const; if (!config) return "stored" as const;

View File

@ -90,10 +90,6 @@ export function getAuth(extra: ToolExtra): McpAuth {
return auth; return auth;
} }
export function getBaseUrl(extra: ToolExtra): string {
return requireMcpToolAuthContext(extra).baseUrl;
}
export function buildBillingCustomer( export function buildBillingCustomer(
auth: McpAuth, auth: McpAuth,
projectId: string, projectId: string,

View File

@ -24,18 +24,16 @@ export function mcpResponse(opts: {
if (value !== undefined) meta[key] = value; if (value !== undefined) meta[key] = value;
} }
} }
const hasMeta = meta != null && Object.keys(meta).length > 0;
if (opts.structuredContent) { if (opts.structuredContent) {
result.structuredContent = result.structuredContent = hasMeta
meta && Object.keys(meta).length > 0 ? { ...opts.structuredContent, meta }
? { ...opts.structuredContent, meta } : opts.structuredContent;
: opts.structuredContent; } else if (hasMeta) {
} else if (meta && Object.keys(meta).length > 0) {
result.structuredContent = { meta }; result.structuredContent = { meta };
} }
if (meta) { if (hasMeta) {
if (Object.keys(meta).length > 0) { result._meta = meta;
result._meta = meta;
}
} }
return result; return result;
} }

View File

@ -9,8 +9,8 @@ type ProjectScopedArgs = {
projectId: string; projectId: string;
}; };
async function requireProjectAccess(_extra: ToolExtra, projectId: string) { async function requireProjectAccess(extra: ToolExtra, projectId: string) {
const { baseUrl, ...auth } = requireMcpToolAuthContext(_extra); const { baseUrl, ...auth } = requireMcpToolAuthContext(extra);
// This lookup enforces that the project belongs to the authenticated org. // This lookup enforces that the project belongs to the authenticated org.
await ProjectService.getProjectForOrganization( await ProjectService.getProjectForOrganization(

View File

@ -1,6 +1,5 @@
/* eslint-disable max-lines */ /* eslint-disable max-lines */
import { z } from "zod"; import { z } from "zod";
import { AppError } from "@/server/lib/errors";
import { createDataforseoClient } from "@/server/lib/dataforseoClient"; import { createDataforseoClient } from "@/server/lib/dataforseoClient";
import { buildProjectMeta } from "@/server/mcp/context"; import { buildProjectMeta } from "@/server/mcp/context";
import { mcpResponse } from "@/server/mcp/formatters"; import { mcpResponse } from "@/server/mcp/formatters";
@ -85,7 +84,6 @@ const rankedTargetSchema = z
"Use a domain without protocol/www or an absolute page URL.", "Use a domain without protocol/www or an absolute page URL.",
); );
const looseRecordSchema = z.record(z.string(), z.unknown());
const getRankedKeywordsInputSchema = { const getRankedKeywordsInputSchema = {
projectId: projectIdSchema, projectId: projectIdSchema,
target: rankedTargetSchema, target: rankedTargetSchema,
@ -180,18 +178,10 @@ type GetGoogleBusinessQuestionsArgs = z.infer<
const QUESTIONS_ANSWERS_MIN_RADIUS = 200; const QUESTIONS_ANSWERS_MIN_RADIUS = 200;
const QUESTIONS_ANSWERS_MAX_RADIUS = 199999; const QUESTIONS_ANSWERS_MAX_RADIUS = 199999;
function resolveMarketLocationCode(market: Market | undefined): number { function resolveMarketLocationCode(_market: Market | undefined): number {
const country = market?.country?.trim().toLowerCase(); // The Zod enum on market.country already restricts values to United States
if ( // variants, so no other country can reach this code path.
!country || return DEFAULT_LOCATION_CODE;
["us", "usa", "united states", "united states of america"].includes(country)
) {
return DEFAULT_LOCATION_CODE;
}
throw new AppError(
"VALIDATION_ERROR",
"Only United States country targeting is supported by this MCP tool today.",
);
} }
function formatCoordinate(value: number): string { function formatCoordinate(value: number): string {
@ -263,9 +253,12 @@ function buildRankedKeywordFilters(args: {
return filters.length > 0 ? filters : undefined; return filters.length > 0 ? filters : undefined;
} }
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function asRecord(value: unknown): Record<string, unknown> | undefined { function asRecord(value: unknown): Record<string, unknown> | undefined {
const parsed = looseRecordSchema.safeParse(value); return isRecord(value) ? value : undefined;
return parsed.success ? parsed.data : undefined;
} }
function displayValue(value: unknown): string { function displayValue(value: unknown): string {

View File

@ -1,6 +1,9 @@
import { ProjectService } from "@/server/features/projects/services/ProjectService"; import { ProjectService } from "@/server/features/projects/services/ProjectService";
import { mcpResponse } from "@/server/mcp/formatters"; import { mcpResponse } from "@/server/mcp/formatters";
import { getAuth, getBaseUrl, type ToolExtra } from "@/server/mcp/context"; import {
requireMcpToolAuthContext,
type ToolExtra,
} from "@/server/mcp/context";
import { optionalMetaOutputSchema } from "@/server/mcp/output-schemas"; import { optionalMetaOutputSchema } from "@/server/mcp/output-schemas";
import { buildDashboardUrl } from "@/server/mcp/urls"; import { buildDashboardUrl } from "@/server/mcp/urls";
import { z } from "zod"; import { z } from "zod";
@ -32,8 +35,7 @@ export const listProjectsTool = {
}, },
}, },
handler: async (_args: Record<string, never>, extra: ToolExtra) => { handler: async (_args: Record<string, never>, extra: ToolExtra) => {
const auth = getAuth(extra); const { baseUrl, ...auth } = requireMcpToolAuthContext(extra);
const baseUrl = getBaseUrl(extra);
const projects = await ProjectService.listProjects(auth.organizationId); const projects = await ProjectService.listProjects(auth.organizationId);
const lines = const lines =
projects.length === 0 projects.length === 0

View File

@ -51,17 +51,16 @@ interface CheckContext {
export async function runLiveCheck( export async function runLiveCheck(
step: WorkflowStep, step: WorkflowStep,
ctx: CheckContext, ctx: CheckContext,
): Promise<{ totalFailed: number }> { ): Promise<void> {
const deviceList: Array<"desktop" | "mobile"> = const deviceList: Array<"desktop" | "mobile"> =
ctx.devices === "both" ? ["desktop", "mobile"] : [ctx.devices]; ctx.devices === "both" ? ["desktop", "mobile"] : [ctx.devices];
let checked = 0; let checked = 0;
let totalFailed = 0;
for (let i = 0; i < ctx.keywords.length; i += KEYWORDS_PER_BATCH) { for (let i = 0; i < ctx.keywords.length; i += KEYWORDS_PER_BATCH) {
const batch = ctx.keywords.slice(i, i + KEYWORDS_PER_BATCH); const batch = ctx.keywords.slice(i, i + KEYWORDS_PER_BATCH);
const batchIndex = Math.floor(i / KEYWORDS_PER_BATCH); const batchIndex = Math.floor(i / KEYWORDS_PER_BATCH);
const batchResults = await step.do( await step.do(
`live-batch-${batchIndex}`, `live-batch-${batchIndex}`,
SINGLE_ATTEMPT_STEP_CONFIG, SINGLE_ATTEMPT_STEP_CONFIG,
async () => { async () => {
@ -82,12 +81,10 @@ export async function runLiveCheck(
); );
const settled = await Promise.allSettled(promises); const settled = await Promise.allSettled(promises);
const results: RankCheckResultWithDevice[] = []; const results: RankCheckResultWithDevice[] = [];
let batchFailed = 0;
for (const outcome of settled) { for (const outcome of settled) {
if (outcome.status === "fulfilled") { if (outcome.status === "fulfilled") {
results.push(outcome.value); results.push(outcome.value);
} else { } else {
batchFailed++;
console.error("Rank check call failed:", outcome.reason); console.error("Rank check call failed:", outcome.reason);
} }
} }
@ -101,16 +98,7 @@ export async function runLiveCheck(
mapResultsToSnapshotRows(ctx.runId, results), mapResultsToSnapshotRows(ctx.runId, results),
); );
} }
return { batchFailed };
}, },
); );
totalFailed += batchResults.batchFailed;
} }
if (totalFailed > 0) {
console.warn(`Rank check completed with ${totalFailed} failed API call(s)`);
}
return { totalFailed };
} }

View File

@ -20,7 +20,7 @@ export function FeaturePageTemplate({ page }: FeaturePageProps) {
href="https://app.openseo.so/sign-up" href="https://app.openseo.so/sign-up"
className="inline-flex h-10 items-center justify-center rounded-md bg-neutral-900 px-5 text-sm font-medium text-white transition-colors hover:bg-neutral-800" className="inline-flex h-10 items-center justify-center rounded-md bg-neutral-900 px-5 text-sm font-medium text-white transition-colors hover:bg-neutral-800"
> >
{page.ctaLabel ?? "Try OpenSEO"} Try OpenSEO
</a> </a>
</div> </div>
</header> </header>
@ -142,7 +142,7 @@ export function FeaturePageTemplate({ page }: FeaturePageProps) {
href="https://app.openseo.so/sign-up" href="https://app.openseo.so/sign-up"
className="inline-flex h-9 items-center justify-center rounded-md bg-neutral-900 px-4 text-sm font-medium text-white transition-colors hover:bg-neutral-800" className="inline-flex h-9 items-center justify-center rounded-md bg-neutral-900 px-4 text-sm font-medium text-white transition-colors hover:bg-neutral-800"
> >
{page.ctaLabel ?? "Try OpenSEO"} Try OpenSEO
</a> </a>
</div> </div>
</section> </section>
@ -151,43 +151,19 @@ export function FeaturePageTemplate({ page }: FeaturePageProps) {
} }
function FeatureImage({ page }: FeaturePageProps) { function FeatureImage({ page }: FeaturePageProps) {
if (page.imageSrc) {
return (
<figure className="mt-9">
<img
src={page.imageSrc}
alt={page.imageAlt}
width={1600}
height={1000}
loading="eager"
decoding="async"
className="aspect-[16/10] w-full rounded-lg border border-neutral-200 object-cover object-top"
/>
<figcaption className="mt-2 text-[11px] text-neutral-500">
{page.eyebrow} in OpenSEO.
</figcaption>
</figure>
);
}
return ( return (
<figure className="mt-9"> <figure className="mt-9">
<div <img
role="img" src={page.imageSrc}
aria-label={page.imageAlt} alt={page.imageAlt}
className="flex aspect-[16/10] w-full items-center justify-center rounded-lg border border-dashed border-neutral-300 bg-neutral-50 px-6 text-center" width={1600}
> height={1000}
<div> loading="eager"
<p className="text-sm font-medium text-neutral-900"> decoding="async"
{page.imagePlaceholder} className="aspect-[16/10] w-full rounded-lg border border-neutral-200 object-cover object-top"
</p> />
<p className="mt-1 text-xs text-neutral-500">
Replace with final product screenshot or short demo asset.
</p>
</div>
</div>
<figcaption className="mt-2 text-[11px] text-neutral-500"> <figcaption className="mt-2 text-[11px] text-neutral-500">
Placeholder for final OpenSEO product imagery. {page.eyebrow} in OpenSEO.
</figcaption> </figcaption>
</figure> </figure>
); );

View File

@ -28,8 +28,8 @@ export const getGuidePosts = createServerFn({ method: "GET" }).handler(
}, },
); );
function getContentPost(source: typeof docsSource, slugs: string[]) { function getContentPost(slugs: string[]) {
const page = source.getPage(slugs); const page = docsSource.getPage(slugs);
if (!page) throw notFound(); if (!page) throw notFound();
return { return {
@ -40,12 +40,12 @@ function getContentPost(source: typeof docsSource, slugs: string[]) {
}; };
} }
function getContentPosts(source: typeof docsSource) { function getContentPosts() {
const topLevelOrder = new Map([ const topLevelOrder = new Map([
["mcp", 0], ["mcp", 0],
["skills", 1], ["skills", 1],
]); ]);
const pages = source.getPages(); const pages = docsSource.getPages();
return pages return pages
.map((page: (typeof pages)[number]) => ({ .map((page: (typeof pages)[number]) => ({
@ -69,10 +69,10 @@ function getContentPosts(source: typeof docsSource) {
export const getDocsPost = createServerFn({ method: "GET" }) export const getDocsPost = createServerFn({ method: "GET" })
.inputValidator((slugs: string[]) => slugs) .inputValidator((slugs: string[]) => slugs)
.handler(async ({ data: slugs }) => getContentPost(docsSource, slugs)); .handler(async ({ data: slugs }) => getContentPost(slugs));
export const getDocsPosts = createServerFn({ method: "GET" }).handler( export const getDocsPosts = createServerFn({ method: "GET" }).handler(
async () => getContentPosts(docsSource), async () => getContentPosts(),
); );
export const getDocsPageTree = createServerFn({ method: "GET" }).handler( export const getDocsPageTree = createServerFn({ method: "GET" }).handler(

View File

@ -9,9 +9,7 @@ export type FeaturePage = {
primaryKeyword: string; primaryKeyword: string;
secondaryKeywords: string[]; secondaryKeywords: string[];
imageAlt: string; imageAlt: string;
imagePlaceholder: string; imageSrc: string;
imageSrc?: string;
ctaLabel?: string;
workflows: Array<{ workflows: Array<{
title: string; title: string;
description: string; description: string;
@ -47,7 +45,6 @@ export const featurePages = {
"keyword research tools", "keyword research tools",
], ],
imageAlt: "OpenSEO keyword research dashboard", imageAlt: "OpenSEO keyword research dashboard",
imagePlaceholder: "Keyword research screenshot placeholder",
imageSrc: imageSrc:
"https://imagedelivery.net/ysLOa6bzFaM49Jxok-TAlw/d77077d0-cdf4-4523-0c41-56a7b4861300/public", "https://imagedelivery.net/ysLOa6bzFaM49Jxok-TAlw/d77077d0-cdf4-4523-0c41-56a7b4861300/public",
workflows: [ workflows: [
@ -123,7 +120,6 @@ export const featurePages = {
"seo audit tools", "seo audit tools",
], ],
imageAlt: "OpenSEO site audit report", imageAlt: "OpenSEO site audit report",
imagePlaceholder: "Site audit screenshot placeholder",
imageSrc: imageSrc:
"https://imagedelivery.net/ysLOa6bzFaM49Jxok-TAlw/53149e87-0027-4fa8-5d13-bcaab60c7100/public", "https://imagedelivery.net/ysLOa6bzFaM49Jxok-TAlw/53149e87-0027-4fa8-5d13-bcaab60c7100/public",
workflows: [ workflows: [
@ -196,7 +192,6 @@ export const featurePages = {
"google backlink checker", "google backlink checker",
], ],
imageAlt: "OpenSEO backlinks report", imageAlt: "OpenSEO backlinks report",
imagePlaceholder: "Backlink checker screenshot placeholder",
imageSrc: imageSrc:
"https://imagedelivery.net/ysLOa6bzFaM49Jxok-TAlw/d97206ed-bd64-447c-2b9e-1b9f07c5ec00/public", "https://imagedelivery.net/ysLOa6bzFaM49Jxok-TAlw/d97206ed-bd64-447c-2b9e-1b9f07c5ec00/public",
workflows: [ workflows: [
@ -272,7 +267,6 @@ export const featurePages = {
"competitor analysis seo tool", "competitor analysis seo tool",
], ],
imageAlt: "OpenSEO domain overview", imageAlt: "OpenSEO domain overview",
imagePlaceholder: "Domain overview screenshot placeholder",
imageSrc: imageSrc:
"https://imagedelivery.net/ysLOa6bzFaM49Jxok-TAlw/189e22b8-fdf8-46b4-198c-e912beef2300/public", "https://imagedelivery.net/ysLOa6bzFaM49Jxok-TAlw/189e22b8-fdf8-46b4-198c-e912beef2300/public",
workflows: [ workflows: [
@ -348,7 +342,6 @@ export const featurePages = {
"google rank tracker", "google rank tracker",
], ],
imageAlt: "OpenSEO rank tracking table", imageAlt: "OpenSEO rank tracking table",
imagePlaceholder: "Rank tracking screenshot placeholder",
imageSrc: imageSrc:
"https://imagedelivery.net/ysLOa6bzFaM49Jxok-TAlw/4a0f8508-1527-46a8-c91c-086456f21c00/public", "https://imagedelivery.net/ysLOa6bzFaM49Jxok-TAlw/4a0f8508-1527-46a8-c91c-086456f21c00/public",
workflows: [ workflows: [
@ -424,7 +417,6 @@ export const featurePages = {
"keyword planning", "keyword planning",
], ],
imageAlt: "OpenSEO saved keywords list", imageAlt: "OpenSEO saved keywords list",
imagePlaceholder: "Saved keywords screenshot placeholder",
imageSrc: imageSrc:
"https://imagedelivery.net/ysLOa6bzFaM49Jxok-TAlw/8938a529-b443-4d4f-9869-c972f3cef900/public", "https://imagedelivery.net/ysLOa6bzFaM49Jxok-TAlw/8938a529-b443-4d4f-9869-c972f3cef900/public",
workflows: [ workflows: [
@ -497,7 +489,6 @@ export const featurePages = {
"answer engine optimization", "answer engine optimization",
], ],
imageAlt: "OpenSEO AI brand visibility report", imageAlt: "OpenSEO AI brand visibility report",
imagePlaceholder: "AI brand visibility screenshot placeholder",
imageSrc: imageSrc:
"https://imagedelivery.net/ysLOa6bzFaM49Jxok-TAlw/cde3e4f8-079f-4890-cb17-371087107400/public", "https://imagedelivery.net/ysLOa6bzFaM49Jxok-TAlw/cde3e4f8-079f-4890-cb17-371087107400/public",
workflows: [ workflows: [
@ -570,7 +561,6 @@ export const featurePages = {
"answer engine optimization tool", "answer engine optimization tool",
], ],
imageAlt: "OpenSEO prompt explorer", imageAlt: "OpenSEO prompt explorer",
imagePlaceholder: "Prompt explorer screenshot placeholder",
imageSrc: imageSrc:
"https://imagedelivery.net/ysLOa6bzFaM49Jxok-TAlw/9f3d38f2-aa97-417c-ca74-ae378654d700/public", "https://imagedelivery.net/ysLOa6bzFaM49Jxok-TAlw/9f3d38f2-aa97-417c-ca74-ae378654d700/public",
workflows: [ workflows: [

View File

@ -25,7 +25,6 @@ type BuildSeoParams = {
description?: string; description?: string;
titleSuffix?: string; titleSuffix?: string;
ogType?: "website" | "article"; ogType?: "website" | "article";
imagePath?: string;
imageAlt?: string; imageAlt?: string;
}; };
@ -35,12 +34,11 @@ export function buildPageSeo({
description, description,
titleSuffix, titleSuffix,
ogType = "website", ogType = "website",
imagePath = DEFAULT_SOCIAL_IMAGE_PATH,
imageAlt = DEFAULT_SOCIAL_IMAGE_ALT, imageAlt = DEFAULT_SOCIAL_IMAGE_ALT,
}: BuildSeoParams) { }: BuildSeoParams) {
const fullTitle = titleSuffix ? `${title} - ${titleSuffix}` : title; const fullTitle = titleSuffix ? `${title} - ${titleSuffix}` : title;
const canonicalUrl = toCanonicalUrl(path); const canonicalUrl = toCanonicalUrl(path);
const socialImageUrl = toCanonicalUrl(imagePath); const socialImageUrl = toCanonicalUrl(DEFAULT_SOCIAL_IMAGE_PATH);
return { return {
meta: [ meta: [