refactor: clean up complex code (#229)
This commit is contained in:
parent
010c93a536
commit
03fd3588ef
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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:"
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -149,7 +149,6 @@ function BrandLookupTable<T>({
|
||||
table.getColumn(columnId)?.getCanSort() ?? false,
|
||||
)
|
||||
}
|
||||
getRowClassName={() => ""}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -40,6 +40,7 @@ const PLATFORM_DOT_CLASS: Record<PlatformRow["platform"], string> = {
|
||||
};
|
||||
|
||||
export function BrandLookupResults({ result }: Props) {
|
||||
if (!result.hasData) {
|
||||
const erroredPlatforms = result.perPlatform.filter(
|
||||
(p) => p.status === "error",
|
||||
);
|
||||
@ -47,7 +48,6 @@ export function BrandLookupResults({ result }: Props) {
|
||||
erroredPlatforms.length === result.perPlatform.length &&
|
||||
result.perPlatform.length > 0;
|
||||
|
||||
if (!result.hasData) {
|
||||
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">
|
||||
|
||||
@ -131,24 +131,15 @@ function useLaunchMutations({
|
||||
return { startMutation, deleteMutation };
|
||||
}
|
||||
|
||||
function applyMaxPages(
|
||||
launchForm: {
|
||||
function commitMaxPagesInput(launchForm: {
|
||||
state: { values: { maxPagesInput: string } };
|
||||
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)
|
||||
? 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));
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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) : "";
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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"}`}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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),
|
||||
);
|
||||
}
|
||||
}}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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],
|
||||
);
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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");
|
||||
};
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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("");
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -122,7 +122,6 @@ function RankTrackingDomainDetailInner({
|
||||
|
||||
const handleKeywordsAdded = (result: {
|
||||
added: number;
|
||||
addedIds: string[];
|
||||
checkTriggered: boolean;
|
||||
}) => {
|
||||
void queryClient.invalidateQueries({
|
||||
|
||||
@ -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"} · {dl} · {sl}
|
||||
{LOCATIONS[summary.locationCode] ?? "US"} ·{" "}
|
||||
{devicesLabel(summary.devices)} ·{" "}
|
||||
{scheduleLabel(summary.scheduleInterval)}
|
||||
{summary.lastRunCompletedAt && (
|
||||
<>
|
||||
{" "}
|
||||
|
||||
@ -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,
|
||||
|
||||
9
src/client/lib/download.ts
Normal file
9
src/client/lib/download.ts
Normal 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);
|
||||
}
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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`;
|
||||
|
||||
@ -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}`;
|
||||
|
||||
16
src/server/auth/org-slug.ts
Normal file
16
src/server/auth/org-slug.ts
Normal 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("");
|
||||
}
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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),
|
||||
};
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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");
|
||||
}
|
||||
|
||||
@ -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" ||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
result.structuredContent = hasMeta
|
||||
? { ...opts.structuredContent, meta }
|
||||
: opts.structuredContent;
|
||||
} else if (meta && Object.keys(meta).length > 0) {
|
||||
} else if (hasMeta) {
|
||||
result.structuredContent = { meta };
|
||||
}
|
||||
if (meta) {
|
||||
if (Object.keys(meta).length > 0) {
|
||||
if (hasMeta) {
|
||||
result._meta = meta;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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)
|
||||
) {
|
||||
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;
|
||||
}
|
||||
throw new AppError(
|
||||
"VALIDATION_ERROR",
|
||||
"Only United States country targeting is supported by this MCP tool today.",
|
||||
);
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
@ -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,7 +151,6 @@ export function FeaturePageTemplate({ page }: FeaturePageProps) {
|
||||
}
|
||||
|
||||
function FeatureImage({ page }: FeaturePageProps) {
|
||||
if (page.imageSrc) {
|
||||
return (
|
||||
<figure className="mt-9">
|
||||
<img
|
||||
@ -168,27 +167,4 @@ function FeatureImage({ page }: FeaturePageProps) {
|
||||
</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>
|
||||
<figcaption className="mt-2 text-[11px] text-neutral-500">
|
||||
Placeholder for final OpenSEO product imagery.
|
||||
</figcaption>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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: [
|
||||
|
||||
@ -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: [
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user