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 { getSafeExternalUrl } from "./table/url";
export function SafeExternalLink({
url,
label,
@ -21,14 +23,3 @@ export function SafeExternalLink({
</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(" ")}
>
{row.getVisibleCells().map((cell) => {
const rawMeta: unknown = cell.column.columnDef.meta;
const meta = isAppColumnMeta<TData>(rawMeta)
? rawMeta
: undefined;
const metaClass = meta?.cellClassName;
const metaClass = cell.column.columnDef.meta?.cellClassName;
return (
<td
key={cell.id}
@ -224,8 +220,7 @@ function HeaderCell<TData>({
fixedLayout?: boolean;
stickyHeader?: boolean;
}) {
const rawMeta: unknown = header.column.columnDef.meta;
const meta = isAppColumnMeta<TData>(rawMeta) ? rawMeta : undefined;
const meta = header.column.columnDef.meta;
return (
<th
className={[
@ -242,7 +237,3 @@ function HeaderCell<TData>({
</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);
}
function getSafeExternalUrl(value: string) {
export function getSafeExternalUrl(value: string) {
try {
const parsed = new URL(value);
return parsed.protocol === "http:" || parsed.protocol === "https:"

View File

@ -77,12 +77,10 @@ export function CodeBlock({ code }: { code: string }) {
export function CopyButton({
value,
successMessage,
label,
iconOnly = false,
}: {
value: string;
successMessage: string;
label?: string;
iconOnly?: boolean;
}) {
const [copied, setCopied] = useState(false);
@ -130,7 +128,7 @@ export function CopyButton({
) : (
<Copy className="size-3" />
)}
{label ?? "Copy"}
Copy
</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 { AiSearchLoadingState } from "@/client/features/ai-search/components/AiSearchLoadingState";
import { AiSearchPaidPlanGate } from "@/client/features/ai-search/components/AiSearchPaidPlanGate";
import {
AiSearchAccessLoadingState,
AiSearchSetupGate,
} from "@/client/features/ai-search/components/AiSearchSetupGate";
import { AiSearchSetupGate } from "@/client/features/ai-search/components/AiSearchSetupGate";
import { AccessGateLoadingState } from "@/client/features/access-gate/AccessGate";
import { useAiSearchAccess } from "@/client/features/ai-search/useAiSearchAccess";
import { useBrandLookupSearchHistory } from "@/client/hooks/useBrandLookupSearchHistory";
import { BRAND_LOOKUP_MAX_INPUT_LENGTH } from "@/types/schemas/ai-search";
@ -151,7 +149,7 @@ function BrandLookupPageInner({
</div>
{access.isLoading ? (
<AiSearchAccessLoadingState />
<AccessGateLoadingState />
) : !access.enabled ? (
<AiSearchSetupGate
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 { PromptExplorerHistorySection } from "@/client/features/ai-search/components/PromptExplorerHistorySection";
import { AiSearchPaidPlanGate } from "@/client/features/ai-search/components/AiSearchPaidPlanGate";
import {
AiSearchAccessLoadingState,
AiSearchSetupGate,
} from "@/client/features/ai-search/components/AiSearchSetupGate";
import { AiSearchSetupGate } from "@/client/features/ai-search/components/AiSearchSetupGate";
import { AccessGateLoadingState } from "@/client/features/access-gate/AccessGate";
import { useAiSearchAccess } from "@/client/features/ai-search/useAiSearchAccess";
import { usePromptExplorerSearchHistory } from "@/client/hooks/usePromptExplorerSearchHistory";
import {
@ -215,7 +213,7 @@ function PromptExplorerPageInner({
</div>
{access.isLoading ? (
<AiSearchAccessLoadingState />
<AccessGateLoadingState />
) : !access.enabled ? (
<AiSearchSetupGate
errorMessage={access.errorMessage ?? access.statusErrorMessage}

View File

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

View File

@ -149,7 +149,6 @@ function BrandLookupTable<T>({
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) {
const erroredPlatforms = result.perPlatform.filter(
(p) => p.status === "error",
);
const allPlatformsErrored =
erroredPlatforms.length === result.perPlatform.length &&
result.perPlatform.length > 0;
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) {
return (
<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>
{erroredPlatforms.length > 0 ? (
<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
mentions may be missing.
</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 }) {
return (
<section className="flex flex-wrap items-baseline justify-between gap-2">

View File

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

View File

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

View File

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

View File

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

View File

@ -1,17 +1,8 @@
import type { AuditResultsData } from "@/client/features/audit/results/types";
import { buildCsv, type CsvValue, downloadCsv } from "@/client/lib/csv";
import { downloadFile } from "@/client/lib/download";
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 = [
"URL",
"Status",

View File

@ -4,10 +4,6 @@ import {
getOAuthSignedQuery,
} from "@/lib/auth-redirect";
import { isHostedClientAuthMode } from "@/lib/auth-mode";
import {
getFieldError as getSharedFieldError,
getFormError as getSharedFormError,
} from "@/client/lib/forms";
export const authRedirectSearchSchema = z.object({
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({
googleLabel,
emailLabel = "Continue with email",

View File

@ -10,7 +10,7 @@ import {
} from "recharts";
import type { BacklinksOverviewData } from "./backlinksPageTypes";
import {
formatFullDate,
formatCompactDate,
formatMonthLabel,
formatTooltipValue,
} from "./backlinksPageUtils";
@ -194,5 +194,5 @@ function formatChartTick(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,
} from "./BacklinksPageSections";
import {
BacklinksAccessLoadingState,
BacklinksErrorState,
BacklinksLoadingState,
BacklinksSetupGate,
@ -18,6 +17,7 @@ import type {
BacklinksTopPagesData,
} from "./backlinksPageTypes";
import type { UseAccessGateResult } from "@/client/features/access-gate/useAccessGate";
import { AccessGateLoadingState } from "@/client/features/access-gate/AccessGate";
import { buildSummaryStats } from "./backlinksPageUtils";
import {
filterBacklinkRows,
@ -118,7 +118,7 @@ export function BacklinksBody({
) : null;
if (accessGate.isLoading) {
return <BacklinksAccessLoadingState />;
return <AccessGateLoadingState />;
}
if (accessGate.statusErrorMessage) {

View File

@ -1,18 +1,6 @@
import { SafeExternalLink } from "@/client/components/SafeExternalLink";
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({
url,
maxLength,
@ -23,7 +11,7 @@ export function BacklinksSourceLink({
muted?: boolean;
}) {
return (
<BacklinksExternalLink
<SafeExternalLink
url={url}
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"}`}

View File

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

View File

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

View File

@ -4,6 +4,7 @@ import {
type SortingState,
} from "@tanstack/react-table";
import { useState } from "react";
import { SafeExternalLink } from "@/client/components/SafeExternalLink";
import {
AppDataTable,
useAppTable,
@ -23,7 +24,6 @@ import {
formatDecimal,
formatNumber,
} from "./backlinksPageUtils";
import { BacklinksExternalLink } from "./BacklinksPageLinks";
type ReferringDomainRow = BacklinksOverviewData["referringDomains"][number];
@ -60,7 +60,7 @@ const columns = [
const domain = getValue();
if (!domain) return "-";
return (
<BacklinksExternalLink
<SafeExternalLink
url={getDomainWebsiteHref(domain)}
label={domain}
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 { useState } from "react";
import { SafeExternalLink } from "@/client/components/SafeExternalLink";
import {
AppDataTable,
useAppTable,
@ -10,7 +11,6 @@ import {
stringNullsLast,
} from "@/client/components/table/nullSafeSort";
import { EmptyTableState } from "./BacklinksPageEmptyTableState";
import { BacklinksExternalLink } from "./BacklinksPageLinks";
import type { BacklinksOverviewData } from "./backlinksPageTypes";
import { formatNumber } from "./backlinksPageUtils";
@ -30,7 +30,7 @@ const columns = [
cell: ({ getValue }) => {
const page = getValue();
return page ? (
<BacklinksExternalLink
<SafeExternalLink
url={page}
label={page}
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) {
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return value;

View File

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

View File

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

View File

@ -29,7 +29,7 @@ export const PAGE_FILTER_FIELDS = [
"maxVol",
] as const satisfies ReadonlyArray<keyof PagesFilterValues>;
const PAGE_SEARCH_PARAM_BY_FIELD = {
export const PAGE_SEARCH_PARAM_BY_FIELD = {
include: "pInclude",
exclude: "pExclude",
minTraffic: "pMinTraffic",
@ -42,23 +42,10 @@ type SearchUpdate = Partial<DomainSearchParams>;
type FilterValues = Record<string, 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(
values: KeywordsFilterValues,
): number {
let n = 0;
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;
return countFilterConditions(values, KEYWORD_FILTER_FIELDS);
}
export function countPageFilterConditions(values: PagesFilterValues): number {
@ -81,14 +68,14 @@ export function buildPagesSearchUpdate(
return buildFilterSearchUpdate<PagesFilterValues>(
values,
PAGE_FILTER_FIELDS,
(key) => getPageFilterSearchParam(key),
(key) => PAGE_SEARCH_PARAM_BY_FIELD[key],
);
}
export function buildPagesClearSearchUpdate(): SearchUpdate {
return buildFilterClearSearchUpdate<PagesFilterValues>(
PAGE_FILTER_FIELDS,
(key) => getPageFilterSearchParam(key),
(key) => PAGE_SEARCH_PARAM_BY_FIELD[key],
);
}
@ -99,8 +86,9 @@ export function buildDomainFiltersClearSearchUpdate(): SearchUpdate {
);
Object.assign(
update,
buildFilterClearSearchUpdate<PagesFilterValues>(PAGE_FILTER_FIELDS, (key) =>
getPageFilterSearchParam(key),
buildFilterClearSearchUpdate<PagesFilterValues>(
PAGE_FILTER_FIELDS,
(key) => PAGE_SEARCH_PARAM_BY_FIELD[key],
),
);
return update;

View File

@ -18,7 +18,7 @@ import {
import {
KEYWORD_FILTER_FIELDS,
PAGE_FILTER_FIELDS,
getPageFilterSearchParam,
PAGE_SEARCH_PARAM_BY_FIELD,
} from "@/client/features/domain/domainFilterUtils";
import { resolveSortOrder, toSortMode, toSortOrder } from "./utils";
@ -95,6 +95,6 @@ function hasKeywordSearchFilters(search: DomainSearchParams): boolean {
function hasPageSearchFilters(search: DomainSearchParams): boolean {
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">
{keyword.keyword}
</span>
<ScoreBadge value={keyword.keywordDifficulty} size="sm" />
<ScoreBadge value={keyword.keywordDifficulty} />
</div>
<div className="w-px h-6 bg-base-300 shrink-0" />
@ -47,24 +47,14 @@ export function OverviewStats({ keyword }: { keyword: KeywordResearchRow }) {
);
}
function ScoreBadge({
value,
size = "sm",
}: {
value: number | null;
size?: "sm" | "lg";
}) {
function ScoreBadge({ value }: { value: number | null }) {
if (value == null) return null;
const tierClass = scoreTierClass(value);
const sizeClasses =
size === "lg"
? "size-9 text-sm font-bold"
: "size-6 text-[10px] font-semibold";
return (
<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}
</span>

View File

@ -58,121 +58,6 @@ export type KeywordResearchControllerInput = {
export function useKeywordResearchController(
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 } =
useResolvedKeywordLocation(input);
const {
@ -277,11 +162,6 @@ function useKeywordControllerState(input: KeywordResearchControllerInput) {
previousSearchKeyRef.current = activeSearchKey;
handledSerpSearchKeyRef.current = null;
if (!activeSearchKey) {
clearActiveKeywordResult();
return;
}
clearActiveKeywordResult();
}, [activeSearchKey, clearActiveKeywordResult]);
@ -319,13 +199,58 @@ function useKeywordControllerState(input: KeywordResearchControllerInput) {
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 {
activeFilterCount,
activeSerpKeyword,
clearSelection,
confirmSave,
controlsForm,
exportCsv,
sheetsExportRows,
filteredRows,
filtersForm,
handleRowClick,
handleSaveKeywords,
handleSearchSubmit,
hasSearched,
history,
historyLoaded,
@ -340,32 +265,29 @@ function useKeywordControllerState(input: KeywordResearchControllerInput) {
removeHistoryItem,
researchError,
researchMutationError,
retryResearch,
retrySearch,
resetFilters,
rows,
searchedKeyword,
selectedKeyword: uiState.selectedKeyword,
selectedRows,
setSelectedRows,
saveMutation,
setPreferredLocationCode,
setSelectedKeyword: uiState.setSelectedKeyword,
setSearchParams,
setSerpKeyword,
serpError,
serpLoading,
serpPage,
serpQuery,
serpResults,
setMobileTab: uiState.setMobileTab,
setSelectedRows,
setSerpPage,
setShowFilters: uiState.setShowFilters,
setShowSaveDialog: uiState.setShowSaveDialog,
showApproximateMatchNotice,
showFilters: uiState.showFilters,
showSaveDialog: uiState.showSaveDialog,
toggleAllRows,
sortDir: input.sortDir,
sortField: input.sortField,
toggleAllRows: handleToggleAllRows,
toggleRowSelection,
toggleSort,
SERP_PAGE_SIZE,
};
}

View File

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

View File

@ -37,23 +37,6 @@ export function categoryLabel(category: CategoryTab) {
return `${category.charAt(0).toUpperCase()}${category.slice(1)}`;
}
export function categorySlug(category: CategoryTab) {
return category === "all" ? "all" : category;
}
export function issuesToCsv(issues: LighthouseIssue[]) {
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({
title,
description,
reason,
options,
selectedValues,
onToggle,
@ -246,7 +245,6 @@ function OnboardingChoiceGroup({
}: {
title: string;
description?: string;
reason?: string;
options: string[];
selectedValues: string[];
onToggle: (value: string) => void;
@ -275,11 +273,6 @@ function OnboardingChoiceGroup({
{description ? (
<p className="mt-1 text-sm text-base-content/60">{description}</p>
) : null}
{reason ? (
<p className="mt-2 text-xs leading-relaxed text-base-content/55">
{reason}
</p>
) : null}
</div>
<div className="grid gap-2">

View File

@ -13,11 +13,7 @@ export function AddKeywordsPanel({
}: {
configId: string;
projectId: string;
onSuccess: (result: {
added: number;
addedIds: string[];
checkTriggered: boolean;
}) => void;
onSuccess: (result: { added: number; checkTriggered: boolean }) => void;
onCancel: () => void;
}) {
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 type { RankTrackingRow } from "@/types/schemas/rank-tracking";
import {
comparePositions,
CpcCell,
DeviceRankCell,
DeviceUrlCell,
@ -101,20 +100,6 @@ const cpcColumn: ColumnDef<RankTrackingRow> = {
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> = {
id: "keyword",
accessorKey: "keyword",
@ -140,7 +125,7 @@ function makeDeviceColumn(
size: 120,
maxSize: 140,
cell: ({ row }) => <DeviceRankCell result={row.original[device]} />,
sortingFn: positionSort,
sortingFn: nullsLastNumeric,
};
}
@ -196,7 +181,7 @@ export function useRankTrackingColumns(
): ColumnDef<RankTrackingRow>[] {
return useMemo(() => {
const cols: ColumnDef<RankTrackingRow>[] = [
makeSelectColumn(selectAnchorRef),
makeSelectionColumn<RankTrackingRow>(selectAnchorRef),
keywordColumn,
];
if (showDesktop) {

View File

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

View File

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

View File

@ -159,13 +159,6 @@ export function CpcCell({ value }: { value: number | null }) {
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 */
function csvChange(
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;
}
function getRuntimeAuthMode() {
return getAuthMode(import.meta.env.AUTH_MODE ?? env.AUTH_MODE);
}
export const ensureUserMiddleware = createMiddleware({
type: "function",
}).server(async ({ next, data }) => {
const authMode = getRuntimeAuthMode();
const authMode = getAuthMode(import.meta.env.AUTH_MODE ?? env.AUTH_MODE);
const headers = getRequest().headers;
let context: EnsuredUserContext;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,7 @@
import { asc, eq } from "drizzle-orm";
import { db } from "@/db";
import { member, user as authUser } from "@/db/better-auth-schema";
import { slugify, toHex } from "./org-slug";
type HostedUser = {
id: string;
@ -18,23 +19,6 @@ type HostedOrganizationCreator = (
input: HostedOrganizationCreateInput,
) => 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) {
const name = user.name?.trim() || user.email.split("@")[0] || "OpenSEO";
return `${name}'s workspace`;

View File

@ -1,22 +1,6 @@
import { db } from "@/db";
import { organization } from "@/db/better-auth-schema";
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("");
}
import { slugify, toHex } from "./org-slug";
function getDelegatedOrganizationId(userId: string) {
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_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 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`);
}
return trimmed;
return value;
}
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) {
const trimmedName = name?.trim();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -37,17 +37,6 @@ function key(auditId: string): string {
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.
* New entries are prepended and the list is capped.
@ -85,7 +74,6 @@ async function clear(auditId: string): Promise<void> {
}
export const AuditProgressKV = {
pushCrawledUrl,
pushCrawledUrls,
getCrawledUrls,
clear,

View File

@ -19,11 +19,7 @@ export function asAppError(error: unknown): AppError | null {
return null;
}
function toErrorCode(error: unknown): ErrorCode {
return asAppError(error)?.code ?? "INTERNAL_ERROR";
}
export function toClientError(error: unknown): 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 {
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[];
};
import { LIGHTHOUSE_CATEGORIES } from "@/shared/lighthouse";
export type RawLighthouseAudit = {
title?: string;
@ -85,6 +28,31 @@ const storedLighthouseMetricSchema = z.object({
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({
version: z.literal(2),
source: z.literal("dataforseo-lighthouse"),
@ -104,33 +72,17 @@ export const storedLighthousePayloadSchema = z.object({
"best-practices": z.number().nullable(),
seo: z.number().nullable(),
}),
metrics: z.object({
firstContentfulPaint: storedLighthouseMetricSchema,
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()),
}),
),
metrics: storedLighthouseMetricsSchema,
issues: z.array(storedLighthouseIssueSchema),
});
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(
score: number | null | undefined,
): number | null {
@ -244,7 +196,7 @@ export function buildStoredLighthouseIssues(input: {
const isPass =
score == null ||
(score != null && score >= 90) ||
score >= 90 ||
scoreDisplayMode === "notApplicable" ||
scoreDisplayMode === "informative" ||
scoreDisplayMode === "manual" ||

View File

@ -82,12 +82,7 @@ async function upsertAttribution(args: CaptureRedditConversionArgs) {
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"
? Boolean(existing?.signupSentAt)
: Boolean(existing?.purchaseSentAt);
@ -112,8 +107,8 @@ export async function captureRedditConversion(
) {
if (!hasRedditAttribution(args.attribution)) return "skipped" as const;
await upsertAttribution(args);
if (await hasSentConversion(args)) return "already_sent" as const;
const alreadySent = await upsertAttribution(args);
if (alreadySent) return "already_sent" as const;
const config = getRedditConfig();
if (!config) return "stored" as const;

View File

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

View File

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

View File

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

View File

@ -1,6 +1,5 @@
/* eslint-disable max-lines */
import { z } from "zod";
import { AppError } from "@/server/lib/errors";
import { createDataforseoClient } from "@/server/lib/dataforseoClient";
import { buildProjectMeta } from "@/server/mcp/context";
import { mcpResponse } from "@/server/mcp/formatters";
@ -85,7 +84,6 @@ const rankedTargetSchema = z
"Use a domain without protocol/www or an absolute page URL.",
);
const looseRecordSchema = z.record(z.string(), z.unknown());
const getRankedKeywordsInputSchema = {
projectId: projectIdSchema,
target: rankedTargetSchema,
@ -180,18 +178,10 @@ type GetGoogleBusinessQuestionsArgs = z.infer<
const QUESTIONS_ANSWERS_MIN_RADIUS = 200;
const QUESTIONS_ANSWERS_MAX_RADIUS = 199999;
function resolveMarketLocationCode(market: Market | undefined): number {
const country = market?.country?.trim().toLowerCase();
if (
!country ||
["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 resolveMarketLocationCode(_market: Market | undefined): number {
// The Zod enum on market.country already restricts values to United States
// variants, so no other country can reach this code path.
return DEFAULT_LOCATION_CODE;
}
function formatCoordinate(value: number): string {
@ -263,9 +253,12 @@ function buildRankedKeywordFilters(args: {
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 {
const parsed = looseRecordSchema.safeParse(value);
return parsed.success ? parsed.data : undefined;
return isRecord(value) ? value : undefined;
}
function displayValue(value: unknown): string {

View File

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

View File

@ -51,17 +51,16 @@ interface CheckContext {
export async function runLiveCheck(
step: WorkflowStep,
ctx: CheckContext,
): Promise<{ totalFailed: number }> {
): Promise<void> {
const deviceList: Array<"desktop" | "mobile"> =
ctx.devices === "both" ? ["desktop", "mobile"] : [ctx.devices];
let checked = 0;
let totalFailed = 0;
for (let i = 0; i < ctx.keywords.length; i += KEYWORDS_PER_BATCH) {
const batch = ctx.keywords.slice(i, i + KEYWORDS_PER_BATCH);
const batchIndex = Math.floor(i / KEYWORDS_PER_BATCH);
const batchResults = await step.do(
await step.do(
`live-batch-${batchIndex}`,
SINGLE_ATTEMPT_STEP_CONFIG,
async () => {
@ -82,12 +81,10 @@ export async function runLiveCheck(
);
const settled = await Promise.allSettled(promises);
const results: RankCheckResultWithDevice[] = [];
let batchFailed = 0;
for (const outcome of settled) {
if (outcome.status === "fulfilled") {
results.push(outcome.value);
} else {
batchFailed++;
console.error("Rank check call failed:", outcome.reason);
}
}
@ -101,16 +98,7 @@ export async function runLiveCheck(
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"
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>
</div>
</header>
@ -142,7 +142,7 @@ export function FeaturePageTemplate({ page }: FeaturePageProps) {
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"
>
{page.ctaLabel ?? "Try OpenSEO"}
Try OpenSEO
</a>
</div>
</section>
@ -151,43 +151,19 @@ export function FeaturePageTemplate({ 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 (
<figure className="mt-9">
<div
role="img"
aria-label={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"
>
<div>
<p className="text-sm font-medium text-neutral-900">
{page.imagePlaceholder}
</p>
<p className="mt-1 text-xs text-neutral-500">
Replace with final product screenshot or short demo asset.
</p>
</div>
</div>
<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">
Placeholder for final OpenSEO product imagery.
{page.eyebrow} in OpenSEO.
</figcaption>
</figure>
);

View File

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

View File

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

View File

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