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 { ExternalLink } from "lucide-react";
|
||||||
|
|
||||||
|
import { getSafeExternalUrl } from "./table/url";
|
||||||
|
|
||||||
export function SafeExternalLink({
|
export function SafeExternalLink({
|
||||||
url,
|
url,
|
||||||
label,
|
label,
|
||||||
@ -21,14 +23,3 @@ export function SafeExternalLink({
|
|||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSafeExternalUrl(value: string) {
|
|
||||||
try {
|
|
||||||
const parsed = new URL(value);
|
|
||||||
return parsed.protocol === "http:" || parsed.protocol === "https:"
|
|
||||||
? parsed.toString()
|
|
||||||
: null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -182,11 +182,7 @@ export function AppDataTable<TData>({
|
|||||||
.join(" ")}
|
.join(" ")}
|
||||||
>
|
>
|
||||||
{row.getVisibleCells().map((cell) => {
|
{row.getVisibleCells().map((cell) => {
|
||||||
const rawMeta: unknown = cell.column.columnDef.meta;
|
const metaClass = cell.column.columnDef.meta?.cellClassName;
|
||||||
const meta = isAppColumnMeta<TData>(rawMeta)
|
|
||||||
? rawMeta
|
|
||||||
: undefined;
|
|
||||||
const metaClass = meta?.cellClassName;
|
|
||||||
return (
|
return (
|
||||||
<td
|
<td
|
||||||
key={cell.id}
|
key={cell.id}
|
||||||
@ -224,8 +220,7 @@ function HeaderCell<TData>({
|
|||||||
fixedLayout?: boolean;
|
fixedLayout?: boolean;
|
||||||
stickyHeader?: boolean;
|
stickyHeader?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const rawMeta: unknown = header.column.columnDef.meta;
|
const meta = header.column.columnDef.meta;
|
||||||
const meta = isAppColumnMeta<TData>(rawMeta) ? rawMeta : undefined;
|
|
||||||
return (
|
return (
|
||||||
<th
|
<th
|
||||||
className={[
|
className={[
|
||||||
@ -242,7 +237,3 @@ function HeaderCell<TData>({
|
|||||||
</th>
|
</th>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isAppColumnMeta<TData>(value: unknown): value is AppColumnMeta<TData> {
|
|
||||||
return typeof value === "object" && value !== null;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -73,7 +73,7 @@ function getUrlDisplayLabel(
|
|||||||
return formatUrlForDisplay(value);
|
return formatUrlForDisplay(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSafeExternalUrl(value: string) {
|
export function getSafeExternalUrl(value: string) {
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(value);
|
const parsed = new URL(value);
|
||||||
return parsed.protocol === "http:" || parsed.protocol === "https:"
|
return parsed.protocol === "http:" || parsed.protocol === "https:"
|
||||||
|
|||||||
@ -77,12 +77,10 @@ export function CodeBlock({ code }: { code: string }) {
|
|||||||
export function CopyButton({
|
export function CopyButton({
|
||||||
value,
|
value,
|
||||||
successMessage,
|
successMessage,
|
||||||
label,
|
|
||||||
iconOnly = false,
|
iconOnly = false,
|
||||||
}: {
|
}: {
|
||||||
value: string;
|
value: string;
|
||||||
successMessage: string;
|
successMessage: string;
|
||||||
label?: string;
|
|
||||||
iconOnly?: boolean;
|
iconOnly?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
@ -130,7 +128,7 @@ export function CopyButton({
|
|||||||
) : (
|
) : (
|
||||||
<Copy className="size-3" />
|
<Copy className="size-3" />
|
||||||
)}
|
)}
|
||||||
{label ?? "Copy"}
|
Copy
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,10 +19,8 @@ import { BrandLookupSearchCard } from "@/client/features/ai-search/components/Br
|
|||||||
import { BrandLookupHistorySection } from "@/client/features/ai-search/components/BrandLookupHistorySection";
|
import { BrandLookupHistorySection } from "@/client/features/ai-search/components/BrandLookupHistorySection";
|
||||||
import { AiSearchLoadingState } from "@/client/features/ai-search/components/AiSearchLoadingState";
|
import { AiSearchLoadingState } from "@/client/features/ai-search/components/AiSearchLoadingState";
|
||||||
import { AiSearchPaidPlanGate } from "@/client/features/ai-search/components/AiSearchPaidPlanGate";
|
import { AiSearchPaidPlanGate } from "@/client/features/ai-search/components/AiSearchPaidPlanGate";
|
||||||
import {
|
import { AiSearchSetupGate } from "@/client/features/ai-search/components/AiSearchSetupGate";
|
||||||
AiSearchAccessLoadingState,
|
import { AccessGateLoadingState } from "@/client/features/access-gate/AccessGate";
|
||||||
AiSearchSetupGate,
|
|
||||||
} from "@/client/features/ai-search/components/AiSearchSetupGate";
|
|
||||||
import { useAiSearchAccess } from "@/client/features/ai-search/useAiSearchAccess";
|
import { useAiSearchAccess } from "@/client/features/ai-search/useAiSearchAccess";
|
||||||
import { useBrandLookupSearchHistory } from "@/client/hooks/useBrandLookupSearchHistory";
|
import { useBrandLookupSearchHistory } from "@/client/hooks/useBrandLookupSearchHistory";
|
||||||
import { BRAND_LOOKUP_MAX_INPUT_LENGTH } from "@/types/schemas/ai-search";
|
import { BRAND_LOOKUP_MAX_INPUT_LENGTH } from "@/types/schemas/ai-search";
|
||||||
@ -151,7 +149,7 @@ function BrandLookupPageInner({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{access.isLoading ? (
|
{access.isLoading ? (
|
||||||
<AiSearchAccessLoadingState />
|
<AccessGateLoadingState />
|
||||||
) : !access.enabled ? (
|
) : !access.enabled ? (
|
||||||
<AiSearchSetupGate
|
<AiSearchSetupGate
|
||||||
errorMessage={access.errorMessage ?? access.statusErrorMessage}
|
errorMessage={access.errorMessage ?? access.statusErrorMessage}
|
||||||
|
|||||||
@ -19,10 +19,8 @@ import { PromptExplorerResults } from "@/client/features/ai-search/components/Pr
|
|||||||
import { PromptExplorerLoadingState } from "@/client/features/ai-search/components/PromptExplorerLoadingState";
|
import { PromptExplorerLoadingState } from "@/client/features/ai-search/components/PromptExplorerLoadingState";
|
||||||
import { PromptExplorerHistorySection } from "@/client/features/ai-search/components/PromptExplorerHistorySection";
|
import { PromptExplorerHistorySection } from "@/client/features/ai-search/components/PromptExplorerHistorySection";
|
||||||
import { AiSearchPaidPlanGate } from "@/client/features/ai-search/components/AiSearchPaidPlanGate";
|
import { AiSearchPaidPlanGate } from "@/client/features/ai-search/components/AiSearchPaidPlanGate";
|
||||||
import {
|
import { AiSearchSetupGate } from "@/client/features/ai-search/components/AiSearchSetupGate";
|
||||||
AiSearchAccessLoadingState,
|
import { AccessGateLoadingState } from "@/client/features/access-gate/AccessGate";
|
||||||
AiSearchSetupGate,
|
|
||||||
} from "@/client/features/ai-search/components/AiSearchSetupGate";
|
|
||||||
import { useAiSearchAccess } from "@/client/features/ai-search/useAiSearchAccess";
|
import { useAiSearchAccess } from "@/client/features/ai-search/useAiSearchAccess";
|
||||||
import { usePromptExplorerSearchHistory } from "@/client/hooks/usePromptExplorerSearchHistory";
|
import { usePromptExplorerSearchHistory } from "@/client/hooks/usePromptExplorerSearchHistory";
|
||||||
import {
|
import {
|
||||||
@ -215,7 +213,7 @@ function PromptExplorerPageInner({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{access.isLoading ? (
|
{access.isLoading ? (
|
||||||
<AiSearchAccessLoadingState />
|
<AccessGateLoadingState />
|
||||||
) : !access.enabled ? (
|
) : !access.enabled ? (
|
||||||
<AiSearchSetupGate
|
<AiSearchSetupGate
|
||||||
errorMessage={access.errorMessage ?? access.statusErrorMessage}
|
errorMessage={access.errorMessage ?? access.statusErrorMessage}
|
||||||
|
|||||||
@ -1,11 +1,4 @@
|
|||||||
import {
|
import { AccessGate } from "@/client/features/access-gate/AccessGate";
|
||||||
AccessGate,
|
|
||||||
AccessGateLoadingState,
|
|
||||||
} from "@/client/features/access-gate/AccessGate";
|
|
||||||
|
|
||||||
export function AiSearchAccessLoadingState() {
|
|
||||||
return <AccessGateLoadingState />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AiSearchSetupGate({
|
export function AiSearchSetupGate({
|
||||||
errorMessage,
|
errorMessage,
|
||||||
|
|||||||
@ -149,7 +149,6 @@ function BrandLookupTable<T>({
|
|||||||
table.getColumn(columnId)?.getCanSort() ?? false,
|
table.getColumn(columnId)?.getCanSort() ?? false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
getRowClassName={() => ""}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -40,6 +40,7 @@ const PLATFORM_DOT_CLASS: Record<PlatformRow["platform"], string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function BrandLookupResults({ result }: Props) {
|
export function BrandLookupResults({ result }: Props) {
|
||||||
|
if (!result.hasData) {
|
||||||
const erroredPlatforms = result.perPlatform.filter(
|
const erroredPlatforms = result.perPlatform.filter(
|
||||||
(p) => p.status === "error",
|
(p) => p.status === "error",
|
||||||
);
|
);
|
||||||
@ -47,7 +48,6 @@ export function BrandLookupResults({ result }: Props) {
|
|||||||
erroredPlatforms.length === result.perPlatform.length &&
|
erroredPlatforms.length === result.perPlatform.length &&
|
||||||
result.perPlatform.length > 0;
|
result.perPlatform.length > 0;
|
||||||
|
|
||||||
if (!result.hasData) {
|
|
||||||
if (allPlatformsErrored) {
|
if (allPlatformsErrored) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-warning/30 bg-warning/10 p-4 text-sm">
|
<div className="rounded-lg border border-warning/30 bg-warning/10 p-4 text-sm">
|
||||||
@ -63,7 +63,10 @@ export function BrandLookupResults({ result }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
{erroredPlatforms.length > 0 ? (
|
{erroredPlatforms.length > 0 ? (
|
||||||
<p className="text-xs text-base-content/60">
|
<p className="text-xs text-base-content/60">
|
||||||
Note: {formatPlatformList(erroredPlatforms.map((p) => p.platform))}{" "}
|
Note:{" "}
|
||||||
|
{erroredPlatforms
|
||||||
|
.map((p) => formatPlatformLabel(p.platform))
|
||||||
|
.join(" and ")}{" "}
|
||||||
{erroredPlatforms.length === 1 ? "was" : "were"} unavailable — some
|
{erroredPlatforms.length === 1 ? "was" : "were"} unavailable — some
|
||||||
mentions may be missing.
|
mentions may be missing.
|
||||||
</p>
|
</p>
|
||||||
@ -88,10 +91,6 @@ export function BrandLookupResults({ result }: Props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatPlatformList(platforms: PlatformRow["platform"][]): string {
|
|
||||||
return platforms.map(formatPlatformLabel).join(" and ");
|
|
||||||
}
|
|
||||||
|
|
||||||
function BrandHeader({ result }: { result: BrandLookupResult }) {
|
function BrandHeader({ result }: { result: BrandLookupResult }) {
|
||||||
return (
|
return (
|
||||||
<section className="flex flex-wrap items-baseline justify-between gap-2">
|
<section className="flex flex-wrap items-baseline justify-between gap-2">
|
||||||
|
|||||||
@ -131,24 +131,15 @@ function useLaunchMutations({
|
|||||||
return { startMutation, deleteMutation };
|
return { startMutation, deleteMutation };
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyMaxPages(
|
function commitMaxPagesInput(launchForm: {
|
||||||
launchForm: {
|
state: { values: { maxPagesInput: string } };
|
||||||
setFieldValue: (field: "maxPagesInput", value: string) => void;
|
setFieldValue: (field: "maxPagesInput", value: string) => void;
|
||||||
},
|
}) {
|
||||||
value: number,
|
const maxPagesInput = launchForm.state.values.maxPagesInput;
|
||||||
) {
|
const value = maxPagesInput ? Number.parseInt(maxPagesInput, 10) : MIN_PAGES;
|
||||||
const safeValue = Number.isFinite(value)
|
const safeValue = Number.isFinite(value)
|
||||||
? Math.max(MIN_PAGES, Math.min(MAX_PAGES_LIMIT, Math.round(value)))
|
? Math.max(MIN_PAGES, Math.min(MAX_PAGES_LIMIT, Math.round(value)))
|
||||||
: MIN_PAGES;
|
: MIN_PAGES;
|
||||||
launchForm.setFieldValue("maxPagesInput", String(safeValue));
|
launchForm.setFieldValue("maxPagesInput", String(safeValue));
|
||||||
return safeValue;
|
return safeValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
function commitMaxPagesInput(launchForm: {
|
|
||||||
state: { values: { maxPagesInput: string } };
|
|
||||||
setFieldValue: (field: "maxPagesInput", value: string) => void;
|
|
||||||
}) {
|
|
||||||
const maxPagesInput = launchForm.state.values.maxPagesInput;
|
|
||||||
if (!maxPagesInput) return applyMaxPages(launchForm, MIN_PAGES);
|
|
||||||
return applyMaxPages(launchForm, Number.parseInt(maxPagesInput, 10));
|
|
||||||
}
|
|
||||||
|
|||||||
@ -7,7 +7,7 @@ export type PerformanceRowData = PerformanceResultRow & {
|
|||||||
pagePath: string | null;
|
pagePath: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type LighthouseFailureFields = {
|
type LighthouseFailureFields = {
|
||||||
errorMessage: string | null;
|
errorMessage: string | null;
|
||||||
performanceScore: number | null;
|
performanceScore: number | null;
|
||||||
accessibilityScore: number | null;
|
accessibilityScore: number | null;
|
||||||
|
|||||||
@ -30,24 +30,15 @@ import {
|
|||||||
EMPTY_PERFORMANCE_FILTERS,
|
EMPTY_PERFORMANCE_FILTERS,
|
||||||
filterPages,
|
filterPages,
|
||||||
filterPerformanceRows,
|
filterPerformanceRows,
|
||||||
isLighthouseFailure as getIsLighthouseFailure,
|
isLighthouseFailure,
|
||||||
nullableNumberSort,
|
nullableNumberSort,
|
||||||
nullableStringSort,
|
nullableStringSort,
|
||||||
type LighthouseFailureFields,
|
|
||||||
type PageRow,
|
type PageRow,
|
||||||
type PagesFilters,
|
type PagesFilters,
|
||||||
type PerformanceFilters,
|
type PerformanceFilters,
|
||||||
type PerformanceRowData,
|
type PerformanceRowData,
|
||||||
} from "@/client/features/audit/results/AuditResultsTableFilterLogic";
|
} from "@/client/features/audit/results/AuditResultsTableFilterLogic";
|
||||||
|
|
||||||
export function isLighthouseFailure(row: LighthouseFailureFields) {
|
|
||||||
return getIsLighthouseFailure(row);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getLighthouseFailureMessage(row: LighthouseFailureFields) {
|
|
||||||
return row.errorMessage ?? "Lighthouse returned no category scores";
|
|
||||||
}
|
|
||||||
|
|
||||||
const pageColumnHelper = createColumnHelper<PageRow>();
|
const pageColumnHelper = createColumnHelper<PageRow>();
|
||||||
const performanceColumnHelper = createColumnHelper<PerformanceRowData>();
|
const performanceColumnHelper = createColumnHelper<PerformanceRowData>();
|
||||||
|
|
||||||
@ -275,7 +266,8 @@ function buildPerformanceColumns({
|
|||||||
header: ({ column }) => <SortableHeader column={column} label="Status" />,
|
header: ({ column }) => <SortableHeader column={column} label="Status" />,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const isFailed = isLighthouseFailure(row.original);
|
const isFailed = isLighthouseFailure(row.original);
|
||||||
const failureMessage = getLighthouseFailureMessage(row.original);
|
const failureMessage =
|
||||||
|
row.original.errorMessage ?? "Lighthouse returned no category scores";
|
||||||
return isFailed ? (
|
return isFailed ? (
|
||||||
<span
|
<span
|
||||||
className="badge badge-error badge-outline text-xs"
|
className="badge badge-error badge-outline text-xs"
|
||||||
|
|||||||
@ -5,9 +5,9 @@ import {
|
|||||||
exportPerformance,
|
exportPerformance,
|
||||||
} from "@/client/features/audit/results/export";
|
} from "@/client/features/audit/results/export";
|
||||||
import type { AuditResultsData } from "@/client/features/audit/results/types";
|
import type { AuditResultsData } from "@/client/features/audit/results/types";
|
||||||
|
import { isLighthouseFailure } from "@/client/features/audit/results/AuditResultsTableFilterLogic";
|
||||||
import {
|
import {
|
||||||
ExportDropdown,
|
ExportDropdown,
|
||||||
isLighthouseFailure,
|
|
||||||
PagesTable,
|
PagesTable,
|
||||||
PerformanceTable,
|
PerformanceTable,
|
||||||
} from "@/client/features/audit/results/ResultsTables";
|
} from "@/client/features/audit/results/ResultsTables";
|
||||||
|
|||||||
@ -1,17 +1,8 @@
|
|||||||
import type { AuditResultsData } from "@/client/features/audit/results/types";
|
import type { AuditResultsData } from "@/client/features/audit/results/types";
|
||||||
import { buildCsv, type CsvValue, downloadCsv } from "@/client/lib/csv";
|
import { buildCsv, type CsvValue, downloadCsv } from "@/client/lib/csv";
|
||||||
|
import { downloadFile } from "@/client/lib/download";
|
||||||
import { exportTableToSheets } from "@/client/lib/exportToSheets";
|
import { exportTableToSheets } from "@/client/lib/exportToSheets";
|
||||||
|
|
||||||
function downloadFile(content: string, filename: string, mime: string) {
|
|
||||||
const blob = new Blob([content], { type: `${mime};charset=utf-8;` });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const link = document.createElement("a");
|
|
||||||
link.href = url;
|
|
||||||
link.download = filename;
|
|
||||||
link.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
const PAGES_HEADERS = [
|
const PAGES_HEADERS = [
|
||||||
"URL",
|
"URL",
|
||||||
"Status",
|
"Status",
|
||||||
|
|||||||
@ -4,10 +4,6 @@ import {
|
|||||||
getOAuthSignedQuery,
|
getOAuthSignedQuery,
|
||||||
} from "@/lib/auth-redirect";
|
} from "@/lib/auth-redirect";
|
||||||
import { isHostedClientAuthMode } from "@/lib/auth-mode";
|
import { isHostedClientAuthMode } from "@/lib/auth-mode";
|
||||||
import {
|
|
||||||
getFieldError as getSharedFieldError,
|
|
||||||
getFormError as getSharedFormError,
|
|
||||||
} from "@/client/lib/forms";
|
|
||||||
|
|
||||||
export const authRedirectSearchSchema = z.object({
|
export const authRedirectSearchSchema = z.object({
|
||||||
redirect: z.string().optional(),
|
redirect: z.string().optional(),
|
||||||
@ -28,14 +24,6 @@ export function useAuthPageState(redirect: string | undefined) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getFieldError(errors: readonly unknown[]) {
|
|
||||||
return getSharedFieldError(errors);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getFormError(error: unknown) {
|
|
||||||
return getSharedFormError(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AuthMethodChooser({
|
export function AuthMethodChooser({
|
||||||
googleLabel,
|
googleLabel,
|
||||||
emailLabel = "Continue with email",
|
emailLabel = "Continue with email",
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import {
|
|||||||
} from "recharts";
|
} from "recharts";
|
||||||
import type { BacklinksOverviewData } from "./backlinksPageTypes";
|
import type { BacklinksOverviewData } from "./backlinksPageTypes";
|
||||||
import {
|
import {
|
||||||
formatFullDate,
|
formatCompactDate,
|
||||||
formatMonthLabel,
|
formatMonthLabel,
|
||||||
formatTooltipValue,
|
formatTooltipValue,
|
||||||
} from "./backlinksPageUtils";
|
} from "./backlinksPageUtils";
|
||||||
@ -194,5 +194,5 @@ function formatChartTick(value: unknown) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatChartLabel(value: unknown) {
|
function formatChartLabel(value: unknown) {
|
||||||
return typeof value === "string" ? formatFullDate(value) : "";
|
return typeof value === "string" ? formatCompactDate(value) : "";
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,6 @@ import {
|
|||||||
BacklinksResultsCard,
|
BacklinksResultsCard,
|
||||||
} from "./BacklinksPageSections";
|
} from "./BacklinksPageSections";
|
||||||
import {
|
import {
|
||||||
BacklinksAccessLoadingState,
|
|
||||||
BacklinksErrorState,
|
BacklinksErrorState,
|
||||||
BacklinksLoadingState,
|
BacklinksLoadingState,
|
||||||
BacklinksSetupGate,
|
BacklinksSetupGate,
|
||||||
@ -18,6 +17,7 @@ import type {
|
|||||||
BacklinksTopPagesData,
|
BacklinksTopPagesData,
|
||||||
} from "./backlinksPageTypes";
|
} from "./backlinksPageTypes";
|
||||||
import type { UseAccessGateResult } from "@/client/features/access-gate/useAccessGate";
|
import type { UseAccessGateResult } from "@/client/features/access-gate/useAccessGate";
|
||||||
|
import { AccessGateLoadingState } from "@/client/features/access-gate/AccessGate";
|
||||||
import { buildSummaryStats } from "./backlinksPageUtils";
|
import { buildSummaryStats } from "./backlinksPageUtils";
|
||||||
import {
|
import {
|
||||||
filterBacklinkRows,
|
filterBacklinkRows,
|
||||||
@ -118,7 +118,7 @@ export function BacklinksBody({
|
|||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
if (accessGate.isLoading) {
|
if (accessGate.isLoading) {
|
||||||
return <BacklinksAccessLoadingState />;
|
return <AccessGateLoadingState />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (accessGate.statusErrorMessage) {
|
if (accessGate.statusErrorMessage) {
|
||||||
|
|||||||
@ -1,18 +1,6 @@
|
|||||||
import { SafeExternalLink } from "@/client/components/SafeExternalLink";
|
import { SafeExternalLink } from "@/client/components/SafeExternalLink";
|
||||||
import { extractUrlPath, truncateMiddle } from "./backlinksPageUtils";
|
import { extractUrlPath, truncateMiddle } from "./backlinksPageUtils";
|
||||||
|
|
||||||
export function BacklinksExternalLink({
|
|
||||||
url,
|
|
||||||
label,
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
url: string;
|
|
||||||
label: string;
|
|
||||||
className: string;
|
|
||||||
}) {
|
|
||||||
return <SafeExternalLink url={url} label={label} className={className} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BacklinksSourceLink({
|
export function BacklinksSourceLink({
|
||||||
url,
|
url,
|
||||||
maxLength,
|
maxLength,
|
||||||
@ -23,7 +11,7 @@ export function BacklinksSourceLink({
|
|||||||
muted?: boolean;
|
muted?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<BacklinksExternalLink
|
<SafeExternalLink
|
||||||
url={url}
|
url={url}
|
||||||
label={truncateMiddle(extractUrlPath(url), maxLength)}
|
label={truncateMiddle(extractUrlPath(url), maxLength)}
|
||||||
className={`link link-hover break-all inline-flex items-center gap-1 ${muted ? "text-xs text-base-content/55" : "text-sm"}`}
|
className={`link link-hover break-all inline-flex items-center gap-1 ${muted ? "text-xs text-base-content/55" : "text-sm"}`}
|
||||||
|
|||||||
@ -1,12 +1,5 @@
|
|||||||
import { ShieldAlert } from "lucide-react";
|
import { ShieldAlert } from "lucide-react";
|
||||||
import {
|
import { AccessGate } from "@/client/features/access-gate/AccessGate";
|
||||||
AccessGate,
|
|
||||||
AccessGateLoadingState,
|
|
||||||
} from "@/client/features/access-gate/AccessGate";
|
|
||||||
|
|
||||||
export function BacklinksAccessLoadingState() {
|
|
||||||
return <AccessGateLoadingState />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BacklinksSetupGate({
|
export function BacklinksSetupGate({
|
||||||
errorMessage,
|
errorMessage,
|
||||||
|
|||||||
@ -8,7 +8,10 @@ import {
|
|||||||
shouldValidateFieldOnChange,
|
shouldValidateFieldOnChange,
|
||||||
} from "@/client/lib/forms";
|
} from "@/client/lib/forms";
|
||||||
import type { BacklinksSearchState } from "./backlinksPageTypes";
|
import type { BacklinksSearchState } from "./backlinksPageTypes";
|
||||||
import { resolveBacklinksSearchScope } from "./backlinksSearchScope";
|
import {
|
||||||
|
inferBacklinksSearchScopeFromTarget,
|
||||||
|
resolveBacklinksSearchScope,
|
||||||
|
} from "./backlinksSearchScope";
|
||||||
|
|
||||||
type SearchDraft = Pick<BacklinksSearchState, "target" | "scope">;
|
type SearchDraft = Pick<BacklinksSearchState, "target" | "scope">;
|
||||||
|
|
||||||
@ -124,11 +127,7 @@ export function BacklinksSearchCard({
|
|||||||
if (!userSelectedScope) {
|
if (!userSelectedScope) {
|
||||||
form.setFieldValue(
|
form.setFieldValue(
|
||||||
"scope",
|
"scope",
|
||||||
resolveBacklinksSearchScope({
|
inferBacklinksSearchScopeFromTarget(nextTarget),
|
||||||
target: nextTarget,
|
|
||||||
selectedScope: form.state.values.scope,
|
|
||||||
userSelectedScope: false,
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import {
|
|||||||
type SortingState,
|
type SortingState,
|
||||||
} from "@tanstack/react-table";
|
} from "@tanstack/react-table";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { SafeExternalLink } from "@/client/components/SafeExternalLink";
|
||||||
import {
|
import {
|
||||||
AppDataTable,
|
AppDataTable,
|
||||||
useAppTable,
|
useAppTable,
|
||||||
@ -23,7 +24,6 @@ import {
|
|||||||
formatDecimal,
|
formatDecimal,
|
||||||
formatNumber,
|
formatNumber,
|
||||||
} from "./backlinksPageUtils";
|
} from "./backlinksPageUtils";
|
||||||
import { BacklinksExternalLink } from "./BacklinksPageLinks";
|
|
||||||
|
|
||||||
type ReferringDomainRow = BacklinksOverviewData["referringDomains"][number];
|
type ReferringDomainRow = BacklinksOverviewData["referringDomains"][number];
|
||||||
|
|
||||||
@ -60,7 +60,7 @@ const columns = [
|
|||||||
const domain = getValue();
|
const domain = getValue();
|
||||||
if (!domain) return "-";
|
if (!domain) return "-";
|
||||||
return (
|
return (
|
||||||
<BacklinksExternalLink
|
<SafeExternalLink
|
||||||
url={getDomainWebsiteHref(domain)}
|
url={getDomainWebsiteHref(domain)}
|
||||||
label={domain}
|
label={domain}
|
||||||
className="link link-primary link-hover break-all inline-flex items-center gap-1"
|
className="link link-primary link-hover break-all inline-flex items-center gap-1"
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { createColumnHelper, type SortingState } from "@tanstack/react-table";
|
import { createColumnHelper, type SortingState } from "@tanstack/react-table";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { SafeExternalLink } from "@/client/components/SafeExternalLink";
|
||||||
import {
|
import {
|
||||||
AppDataTable,
|
AppDataTable,
|
||||||
useAppTable,
|
useAppTable,
|
||||||
@ -10,7 +11,6 @@ import {
|
|||||||
stringNullsLast,
|
stringNullsLast,
|
||||||
} from "@/client/components/table/nullSafeSort";
|
} from "@/client/components/table/nullSafeSort";
|
||||||
import { EmptyTableState } from "./BacklinksPageEmptyTableState";
|
import { EmptyTableState } from "./BacklinksPageEmptyTableState";
|
||||||
import { BacklinksExternalLink } from "./BacklinksPageLinks";
|
|
||||||
import type { BacklinksOverviewData } from "./backlinksPageTypes";
|
import type { BacklinksOverviewData } from "./backlinksPageTypes";
|
||||||
import { formatNumber } from "./backlinksPageUtils";
|
import { formatNumber } from "./backlinksPageUtils";
|
||||||
|
|
||||||
@ -30,7 +30,7 @@ const columns = [
|
|||||||
cell: ({ getValue }) => {
|
cell: ({ getValue }) => {
|
||||||
const page = getValue();
|
const page = getValue();
|
||||||
return page ? (
|
return page ? (
|
||||||
<BacklinksExternalLink
|
<SafeExternalLink
|
||||||
url={page}
|
url={page}
|
||||||
label={page}
|
label={page}
|
||||||
className="link link-hover break-all inline-flex items-center gap-1"
|
className="link link-hover break-all inline-flex items-center gap-1"
|
||||||
|
|||||||
@ -89,16 +89,6 @@ export function formatCompactDate(value: string | null | undefined) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatFullDate(value: string) {
|
|
||||||
const parsed = new Date(value);
|
|
||||||
if (Number.isNaN(parsed.getTime())) return value;
|
|
||||||
return parsed.toLocaleDateString(undefined, {
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
year: "numeric",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatMonthLabel(value: string) {
|
export function formatMonthLabel(value: string) {
|
||||||
const parsed = new Date(value);
|
const parsed = new Date(value);
|
||||||
if (Number.isNaN(parsed.getTime())) return value;
|
if (Number.isNaN(parsed.getTime())) return value;
|
||||||
|
|||||||
@ -80,10 +80,7 @@ function getSortSearchUpdate(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLocationSearchUpdate(
|
function getLocationSearchUpdate(nextLocationCode: number): DomainSearchUpdate {
|
||||||
nextLocationCode: number,
|
|
||||||
): DomainSearchUpdate | null {
|
|
||||||
if (!isSupportedLocationCode(nextLocationCode)) return null;
|
|
||||||
return {
|
return {
|
||||||
loc:
|
loc:
|
||||||
nextLocationCode === DEFAULT_LOCATION_CODE ? undefined : nextLocationCode,
|
nextLocationCode === DEFAULT_LOCATION_CODE ? undefined : nextLocationCode,
|
||||||
@ -208,8 +205,7 @@ function useDomainOverviewState({
|
|||||||
|
|
||||||
const applyLocationChange = useCallback(
|
const applyLocationChange = useCallback(
|
||||||
(nextLocationCode: number) => {
|
(nextLocationCode: number) => {
|
||||||
const update = getLocationSearchUpdate(nextLocationCode);
|
setSearchParams(getLocationSearchUpdate(nextLocationCode));
|
||||||
if (update) setSearchParams(update);
|
|
||||||
},
|
},
|
||||||
[setSearchParams],
|
[setSearchParams],
|
||||||
);
|
);
|
||||||
|
|||||||
@ -60,9 +60,6 @@ export function DomainFilterPanel<TValues extends FilterValues>({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [appliedKey]);
|
}, [appliedKey]);
|
||||||
|
|
||||||
const onValueChange = useCallback((key: keyof TValues, value: string) => {
|
|
||||||
setDraftFilters((current) => ({ ...current, [key]: value }));
|
|
||||||
}, []);
|
|
||||||
const meta = useMemo(
|
const meta = useMemo(
|
||||||
() =>
|
() =>
|
||||||
getFilterMeta({
|
getFilterMeta({
|
||||||
@ -114,9 +111,9 @@ export function DomainFilterPanel<TValues extends FilterValues>({
|
|||||||
field: String(key),
|
field: String(key),
|
||||||
valueLength: value.length,
|
valueLength: value.length,
|
||||||
});
|
});
|
||||||
onValueChange(key, value);
|
setDraftFilters((current) => ({ ...current, [key]: value }));
|
||||||
},
|
},
|
||||||
[debugName, onValueChange],
|
[debugName],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -29,7 +29,7 @@ export const PAGE_FILTER_FIELDS = [
|
|||||||
"maxVol",
|
"maxVol",
|
||||||
] as const satisfies ReadonlyArray<keyof PagesFilterValues>;
|
] as const satisfies ReadonlyArray<keyof PagesFilterValues>;
|
||||||
|
|
||||||
const PAGE_SEARCH_PARAM_BY_FIELD = {
|
export const PAGE_SEARCH_PARAM_BY_FIELD = {
|
||||||
include: "pInclude",
|
include: "pInclude",
|
||||||
exclude: "pExclude",
|
exclude: "pExclude",
|
||||||
minTraffic: "pMinTraffic",
|
minTraffic: "pMinTraffic",
|
||||||
@ -42,23 +42,10 @@ type SearchUpdate = Partial<DomainSearchParams>;
|
|||||||
type FilterValues = Record<string, string>;
|
type FilterValues = Record<string, string>;
|
||||||
type FilterKey<TValues extends FilterValues> = Extract<keyof TValues, string>;
|
type FilterKey<TValues extends FilterValues> = Extract<keyof TValues, string>;
|
||||||
|
|
||||||
export function getPageFilterSearchParam(
|
|
||||||
key: PageFilterKey,
|
|
||||||
): (typeof PAGE_SEARCH_PARAM_BY_FIELD)[PageFilterKey] {
|
|
||||||
return PAGE_SEARCH_PARAM_BY_FIELD[key];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function countKeywordFilterConditions(
|
export function countKeywordFilterConditions(
|
||||||
values: KeywordsFilterValues,
|
values: KeywordsFilterValues,
|
||||||
): number {
|
): number {
|
||||||
let n = 0;
|
return countFilterConditions(values, KEYWORD_FILTER_FIELDS);
|
||||||
for (const term of values.include.split(/[,+]/)) if (term.trim()) n += 1;
|
|
||||||
for (const term of values.exclude.split(/[,+]/)) if (term.trim()) n += 1;
|
|
||||||
for (const key of KEYWORD_FILTER_FIELDS) {
|
|
||||||
if (key === "include" || key === "exclude") continue;
|
|
||||||
if (values[key].trim() !== "") n += 1;
|
|
||||||
}
|
|
||||||
return n;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function countPageFilterConditions(values: PagesFilterValues): number {
|
export function countPageFilterConditions(values: PagesFilterValues): number {
|
||||||
@ -81,14 +68,14 @@ export function buildPagesSearchUpdate(
|
|||||||
return buildFilterSearchUpdate<PagesFilterValues>(
|
return buildFilterSearchUpdate<PagesFilterValues>(
|
||||||
values,
|
values,
|
||||||
PAGE_FILTER_FIELDS,
|
PAGE_FILTER_FIELDS,
|
||||||
(key) => getPageFilterSearchParam(key),
|
(key) => PAGE_SEARCH_PARAM_BY_FIELD[key],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildPagesClearSearchUpdate(): SearchUpdate {
|
export function buildPagesClearSearchUpdate(): SearchUpdate {
|
||||||
return buildFilterClearSearchUpdate<PagesFilterValues>(
|
return buildFilterClearSearchUpdate<PagesFilterValues>(
|
||||||
PAGE_FILTER_FIELDS,
|
PAGE_FILTER_FIELDS,
|
||||||
(key) => getPageFilterSearchParam(key),
|
(key) => PAGE_SEARCH_PARAM_BY_FIELD[key],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -99,8 +86,9 @@ export function buildDomainFiltersClearSearchUpdate(): SearchUpdate {
|
|||||||
);
|
);
|
||||||
Object.assign(
|
Object.assign(
|
||||||
update,
|
update,
|
||||||
buildFilterClearSearchUpdate<PagesFilterValues>(PAGE_FILTER_FIELDS, (key) =>
|
buildFilterClearSearchUpdate<PagesFilterValues>(
|
||||||
getPageFilterSearchParam(key),
|
PAGE_FILTER_FIELDS,
|
||||||
|
(key) => PAGE_SEARCH_PARAM_BY_FIELD[key],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return update;
|
return update;
|
||||||
|
|||||||
@ -18,7 +18,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
KEYWORD_FILTER_FIELDS,
|
KEYWORD_FILTER_FIELDS,
|
||||||
PAGE_FILTER_FIELDS,
|
PAGE_FILTER_FIELDS,
|
||||||
getPageFilterSearchParam,
|
PAGE_SEARCH_PARAM_BY_FIELD,
|
||||||
} from "@/client/features/domain/domainFilterUtils";
|
} from "@/client/features/domain/domainFilterUtils";
|
||||||
import { resolveSortOrder, toSortMode, toSortOrder } from "./utils";
|
import { resolveSortOrder, toSortMode, toSortOrder } from "./utils";
|
||||||
|
|
||||||
@ -95,6 +95,6 @@ function hasKeywordSearchFilters(search: DomainSearchParams): boolean {
|
|||||||
|
|
||||||
function hasPageSearchFilters(search: DomainSearchParams): boolean {
|
function hasPageSearchFilters(search: DomainSearchParams): boolean {
|
||||||
return PAGE_FILTER_FIELDS.some(
|
return PAGE_FILTER_FIELDS.some(
|
||||||
(key) => search[getPageFilterSearchParam(key)] != null,
|
(key) => search[PAGE_SEARCH_PARAM_BY_FIELD[key]] != null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,7 +17,7 @@ export function OverviewStats({ keyword }: { keyword: KeywordResearchRow }) {
|
|||||||
<span className="font-bold text-base truncate max-w-[240px] capitalize">
|
<span className="font-bold text-base truncate max-w-[240px] capitalize">
|
||||||
{keyword.keyword}
|
{keyword.keyword}
|
||||||
</span>
|
</span>
|
||||||
<ScoreBadge value={keyword.keywordDifficulty} size="sm" />
|
<ScoreBadge value={keyword.keywordDifficulty} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-px h-6 bg-base-300 shrink-0" />
|
<div className="w-px h-6 bg-base-300 shrink-0" />
|
||||||
@ -47,24 +47,14 @@ export function OverviewStats({ keyword }: { keyword: KeywordResearchRow }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ScoreBadge({
|
function ScoreBadge({ value }: { value: number | null }) {
|
||||||
value,
|
|
||||||
size = "sm",
|
|
||||||
}: {
|
|
||||||
value: number | null;
|
|
||||||
size?: "sm" | "lg";
|
|
||||||
}) {
|
|
||||||
if (value == null) return null;
|
if (value == null) return null;
|
||||||
|
|
||||||
const tierClass = scoreTierClass(value);
|
const tierClass = scoreTierClass(value);
|
||||||
const sizeClasses =
|
|
||||||
size === "lg"
|
|
||||||
? "size-9 text-sm font-bold"
|
|
||||||
: "size-6 text-[10px] font-semibold";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={`score-badge ${tierClass} inline-flex items-center justify-center rounded-full ${sizeClasses}`}
|
className={`score-badge ${tierClass} inline-flex items-center justify-center rounded-full size-6 text-[10px] font-semibold`}
|
||||||
>
|
>
|
||||||
{value}
|
{value}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@ -58,121 +58,6 @@ export type KeywordResearchControllerInput = {
|
|||||||
export function useKeywordResearchController(
|
export function useKeywordResearchController(
|
||||||
input: KeywordResearchControllerInput,
|
input: KeywordResearchControllerInput,
|
||||||
) {
|
) {
|
||||||
const state = useKeywordControllerState(input);
|
|
||||||
const controlsForm = state.controlsForm;
|
|
||||||
const setSearchParams = state.setSearchParams;
|
|
||||||
const retryResearch = state.retryResearch;
|
|
||||||
|
|
||||||
const onSearch = useCallback(
|
|
||||||
(overrides?: Partial<{ keyword: string; locationCode: number }>) => {
|
|
||||||
if (overrides?.keyword !== undefined) {
|
|
||||||
controlsForm.setFieldValue("keyword", overrides.keyword);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (overrides?.locationCode !== undefined) {
|
|
||||||
controlsForm.setFieldValue("locationCode", overrides.locationCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
void controlsForm.handleSubmit();
|
|
||||||
},
|
|
||||||
[controlsForm],
|
|
||||||
);
|
|
||||||
|
|
||||||
const retrySearch = useCallback(() => {
|
|
||||||
void retryResearch();
|
|
||||||
}, [retryResearch]);
|
|
||||||
|
|
||||||
const handleSearchSubmit = useCallback(
|
|
||||||
(event: FormEvent) => {
|
|
||||||
event.preventDefault();
|
|
||||||
void controlsForm.handleSubmit();
|
|
||||||
},
|
|
||||||
[controlsForm],
|
|
||||||
);
|
|
||||||
|
|
||||||
const toggleSort = useCallback(
|
|
||||||
(field: SortField) => {
|
|
||||||
setSearchParams(getNextSortParams(input.sortField, input.sortDir, field));
|
|
||||||
},
|
|
||||||
[input.sortDir, input.sortField, setSearchParams],
|
|
||||||
);
|
|
||||||
|
|
||||||
const { handleSaveKeywords, confirmSave, exportCsv, sheetsExportRows } =
|
|
||||||
useSaveAndExportActions({
|
|
||||||
selectedRows: state.selectedRows,
|
|
||||||
rows: state.rows,
|
|
||||||
filteredRows: state.filteredRows,
|
|
||||||
input,
|
|
||||||
saveKeywordsMutate: state.saveMutation.mutate,
|
|
||||||
setShowSaveDialog: state.setShowSaveDialog,
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleToggleAllRows = () => {
|
|
||||||
state.toggleAllRows(state.filteredRows.map((row) => row.keyword));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRowClick = (row: KeywordResearchRow) => {
|
|
||||||
captureClientEvent("keyword_research:serp_open");
|
|
||||||
state.setSelectedKeyword(row);
|
|
||||||
state.setSerpKeyword(row.keyword);
|
|
||||||
state.setSerpPage(0);
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
activeFilterCount: state.activeFilterCount,
|
|
||||||
activeSerpKeyword: state.activeSerpKeyword,
|
|
||||||
confirmSave,
|
|
||||||
controlsForm: state.controlsForm,
|
|
||||||
exportCsv,
|
|
||||||
sheetsExportRows,
|
|
||||||
filteredRows: state.filteredRows,
|
|
||||||
filtersForm: state.filtersForm,
|
|
||||||
handleRowClick,
|
|
||||||
handleSaveKeywords,
|
|
||||||
handleSearchSubmit,
|
|
||||||
hasSearched: state.hasSearched,
|
|
||||||
history: state.history,
|
|
||||||
historyLoaded: state.historyLoaded,
|
|
||||||
isLoading: state.isLoading,
|
|
||||||
lastResultSource: state.lastResultSource,
|
|
||||||
lastSearchError: state.lastSearchError,
|
|
||||||
lastSearchKeyword: state.lastSearchKeyword,
|
|
||||||
lastSearchLocationCode: state.lastSearchLocationCode,
|
|
||||||
lastUsedFallback: state.lastUsedFallback,
|
|
||||||
mobileTab: state.mobileTab,
|
|
||||||
onSearch,
|
|
||||||
overviewKeyword: state.overviewKeyword,
|
|
||||||
removeHistoryItem: state.removeHistoryItem,
|
|
||||||
researchError: state.researchError,
|
|
||||||
researchMutationError: state.researchMutationError,
|
|
||||||
retrySearch,
|
|
||||||
resetFilters: state.resetFilters,
|
|
||||||
rows: state.rows,
|
|
||||||
searchedKeyword: state.searchedKeyword,
|
|
||||||
selectedRows: state.selectedRows,
|
|
||||||
serpError: state.serpError,
|
|
||||||
serpLoading: state.serpLoading,
|
|
||||||
serpPage: state.serpPage,
|
|
||||||
serpQuery: state.serpQuery,
|
|
||||||
serpResults: state.serpResults,
|
|
||||||
setMobileTab: state.setMobileTab,
|
|
||||||
setSelectedRows: state.setSelectedRows,
|
|
||||||
setSerpPage: state.setSerpPage,
|
|
||||||
setShowFilters: state.setShowFilters,
|
|
||||||
setShowSaveDialog: state.setShowSaveDialog,
|
|
||||||
showApproximateMatchNotice: state.showApproximateMatchNotice,
|
|
||||||
showFilters: state.showFilters,
|
|
||||||
showSaveDialog: state.showSaveDialog,
|
|
||||||
sortDir: input.sortDir,
|
|
||||||
sortField: input.sortField,
|
|
||||||
toggleAllRows: handleToggleAllRows,
|
|
||||||
toggleRowSelection: state.toggleRowSelection,
|
|
||||||
toggleSort,
|
|
||||||
SERP_PAGE_SIZE: state.SERP_PAGE_SIZE,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function useKeywordControllerState(input: KeywordResearchControllerInput) {
|
|
||||||
const { locationCode, setPreferredLocationCode } =
|
const { locationCode, setPreferredLocationCode } =
|
||||||
useResolvedKeywordLocation(input);
|
useResolvedKeywordLocation(input);
|
||||||
const {
|
const {
|
||||||
@ -277,11 +162,6 @@ function useKeywordControllerState(input: KeywordResearchControllerInput) {
|
|||||||
previousSearchKeyRef.current = activeSearchKey;
|
previousSearchKeyRef.current = activeSearchKey;
|
||||||
handledSerpSearchKeyRef.current = null;
|
handledSerpSearchKeyRef.current = null;
|
||||||
|
|
||||||
if (!activeSearchKey) {
|
|
||||||
clearActiveKeywordResult();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
clearActiveKeywordResult();
|
clearActiveKeywordResult();
|
||||||
}, [activeSearchKey, clearActiveKeywordResult]);
|
}, [activeSearchKey, clearActiveKeywordResult]);
|
||||||
|
|
||||||
@ -319,13 +199,58 @@ function useKeywordControllerState(input: KeywordResearchControllerInput) {
|
|||||||
keywordMode: input.keywordMode,
|
keywordMode: input.keywordMode,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const retrySearch = useCallback(() => {
|
||||||
|
void retryResearch();
|
||||||
|
}, [retryResearch]);
|
||||||
|
|
||||||
|
const handleSearchSubmit = useCallback(
|
||||||
|
(event: FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
void controlsForm.handleSubmit();
|
||||||
|
},
|
||||||
|
[controlsForm],
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleSort = useCallback(
|
||||||
|
(field: SortField) => {
|
||||||
|
setSearchParams(getNextSortParams(input.sortField, input.sortDir, field));
|
||||||
|
},
|
||||||
|
[input.sortDir, input.sortField, setSearchParams],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { handleSaveKeywords, confirmSave, exportCsv, sheetsExportRows } =
|
||||||
|
useSaveAndExportActions({
|
||||||
|
selectedRows,
|
||||||
|
rows,
|
||||||
|
filteredRows,
|
||||||
|
input,
|
||||||
|
saveKeywordsMutate: saveMutation.mutate,
|
||||||
|
setShowSaveDialog: uiState.setShowSaveDialog,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleToggleAllRows = () => {
|
||||||
|
toggleAllRows(filteredRows.map((row) => row.keyword));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRowClick = (row: KeywordResearchRow) => {
|
||||||
|
captureClientEvent("keyword_research:serp_open");
|
||||||
|
uiState.setSelectedKeyword(row);
|
||||||
|
setSerpKeyword(row.keyword);
|
||||||
|
setSerpPage(0);
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
activeFilterCount,
|
activeFilterCount,
|
||||||
activeSerpKeyword,
|
activeSerpKeyword,
|
||||||
clearSelection,
|
confirmSave,
|
||||||
controlsForm,
|
controlsForm,
|
||||||
|
exportCsv,
|
||||||
|
sheetsExportRows,
|
||||||
filteredRows,
|
filteredRows,
|
||||||
filtersForm,
|
filtersForm,
|
||||||
|
handleRowClick,
|
||||||
|
handleSaveKeywords,
|
||||||
|
handleSearchSubmit,
|
||||||
hasSearched,
|
hasSearched,
|
||||||
history,
|
history,
|
||||||
historyLoaded,
|
historyLoaded,
|
||||||
@ -340,32 +265,29 @@ function useKeywordControllerState(input: KeywordResearchControllerInput) {
|
|||||||
removeHistoryItem,
|
removeHistoryItem,
|
||||||
researchError,
|
researchError,
|
||||||
researchMutationError,
|
researchMutationError,
|
||||||
retryResearch,
|
retrySearch,
|
||||||
resetFilters,
|
resetFilters,
|
||||||
rows,
|
rows,
|
||||||
searchedKeyword,
|
searchedKeyword,
|
||||||
selectedKeyword: uiState.selectedKeyword,
|
|
||||||
selectedRows,
|
selectedRows,
|
||||||
setSelectedRows,
|
|
||||||
saveMutation,
|
|
||||||
setPreferredLocationCode,
|
|
||||||
setSelectedKeyword: uiState.setSelectedKeyword,
|
|
||||||
setSearchParams,
|
|
||||||
setSerpKeyword,
|
|
||||||
serpError,
|
serpError,
|
||||||
serpLoading,
|
serpLoading,
|
||||||
serpPage,
|
serpPage,
|
||||||
serpQuery,
|
serpQuery,
|
||||||
serpResults,
|
serpResults,
|
||||||
setMobileTab: uiState.setMobileTab,
|
setMobileTab: uiState.setMobileTab,
|
||||||
|
setSelectedRows,
|
||||||
setSerpPage,
|
setSerpPage,
|
||||||
setShowFilters: uiState.setShowFilters,
|
setShowFilters: uiState.setShowFilters,
|
||||||
setShowSaveDialog: uiState.setShowSaveDialog,
|
setShowSaveDialog: uiState.setShowSaveDialog,
|
||||||
showApproximateMatchNotice,
|
showApproximateMatchNotice,
|
||||||
showFilters: uiState.showFilters,
|
showFilters: uiState.showFilters,
|
||||||
showSaveDialog: uiState.showSaveDialog,
|
showSaveDialog: uiState.showSaveDialog,
|
||||||
toggleAllRows,
|
sortDir: input.sortDir,
|
||||||
|
sortField: input.sortField,
|
||||||
|
toggleAllRows: handleToggleAllRows,
|
||||||
toggleRowSelection,
|
toggleRowSelection,
|
||||||
|
toggleSort,
|
||||||
SERP_PAGE_SIZE,
|
SERP_PAGE_SIZE,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,15 +5,10 @@ import {
|
|||||||
exportAuditLighthouseIssues,
|
exportAuditLighthouseIssues,
|
||||||
getAuditLighthouseIssues,
|
getAuditLighthouseIssues,
|
||||||
} from "@/serverFunctions/lighthouse";
|
} from "@/serverFunctions/lighthouse";
|
||||||
|
import { downloadFile } from "@/client/lib/download";
|
||||||
import { exportTableToSheets } from "@/client/lib/exportToSheets";
|
import { exportTableToSheets } from "@/client/lib/exportToSheets";
|
||||||
import type { CategoryTab, ExportPayload, LighthouseIssue } from "./types";
|
import type { CategoryTab, ExportPayload, LighthouseIssue } from "./types";
|
||||||
import {
|
import { categoryLabel, issuesToCsv, issuesToTable } from "./utils";
|
||||||
categoryLabel,
|
|
||||||
categorySlug,
|
|
||||||
downloadTextFile,
|
|
||||||
issuesToCsv,
|
|
||||||
issuesToTable,
|
|
||||||
} from "./utils";
|
|
||||||
import {
|
import {
|
||||||
LighthouseIssueList,
|
LighthouseIssueList,
|
||||||
LighthouseIssuesHeader,
|
LighthouseIssuesHeader,
|
||||||
@ -170,7 +165,7 @@ function useLighthouseIssuesActions({
|
|||||||
const runExport = async (data: ExportPayload) => {
|
const runExport = async (data: ExportPayload) => {
|
||||||
try {
|
try {
|
||||||
const exported = await exportMutation.mutateAsync(data);
|
const exported = await exportMutation.mutateAsync(data);
|
||||||
downloadTextFile(exported.filename, exported.content, "application/json");
|
downloadFile(exported.content, exported.filename, "application/json");
|
||||||
toast.success("Download started");
|
toast.success("Download started");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message =
|
const message =
|
||||||
@ -183,8 +178,8 @@ function useLighthouseIssuesActions({
|
|||||||
rows: LighthouseIssue[],
|
rows: LighthouseIssue[],
|
||||||
variant: "all" | "current",
|
variant: "all" | "current",
|
||||||
) => {
|
) => {
|
||||||
const filename = `lighthouse-${variant}-${categorySlug(category)}-issues.csv`;
|
const filename = `lighthouse-${variant}-${category}-issues.csv`;
|
||||||
downloadTextFile(filename, issuesToCsv(rows), "text/csv");
|
downloadFile(issuesToCsv(rows), filename, "text/csv");
|
||||||
toast.success("CSV download started");
|
toast.success("CSV download started");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -37,23 +37,6 @@ export function categoryLabel(category: CategoryTab) {
|
|||||||
return `${category.charAt(0).toUpperCase()}${category.slice(1)}`;
|
return `${category.charAt(0).toUpperCase()}${category.slice(1)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function categorySlug(category: CategoryTab) {
|
|
||||||
return category === "all" ? "all" : category;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function issuesToCsv(issues: LighthouseIssue[]) {
|
export function issuesToCsv(issues: LighthouseIssue[]) {
|
||||||
return buildCsv(ISSUE_HEADERS, issuesToRows(issues));
|
return buildCsv(ISSUE_HEADERS, issuesToRows(issues));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function downloadTextFile(
|
|
||||||
filename: string,
|
|
||||||
content: string,
|
|
||||||
mimeType: string,
|
|
||||||
) {
|
|
||||||
const blob = new Blob([content], { type: mimeType });
|
|
||||||
const link = document.createElement("a");
|
|
||||||
link.href = URL.createObjectURL(blob);
|
|
||||||
link.download = filename;
|
|
||||||
link.click();
|
|
||||||
URL.revokeObjectURL(link.href);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -234,7 +234,6 @@ function McpRecommendation({
|
|||||||
function OnboardingChoiceGroup({
|
function OnboardingChoiceGroup({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
reason,
|
|
||||||
options,
|
options,
|
||||||
selectedValues,
|
selectedValues,
|
||||||
onToggle,
|
onToggle,
|
||||||
@ -246,7 +245,6 @@ function OnboardingChoiceGroup({
|
|||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
reason?: string;
|
|
||||||
options: string[];
|
options: string[];
|
||||||
selectedValues: string[];
|
selectedValues: string[];
|
||||||
onToggle: (value: string) => void;
|
onToggle: (value: string) => void;
|
||||||
@ -275,11 +273,6 @@ function OnboardingChoiceGroup({
|
|||||||
{description ? (
|
{description ? (
|
||||||
<p className="mt-1 text-sm text-base-content/60">{description}</p>
|
<p className="mt-1 text-sm text-base-content/60">{description}</p>
|
||||||
) : null}
|
) : null}
|
||||||
{reason ? (
|
|
||||||
<p className="mt-2 text-xs leading-relaxed text-base-content/55">
|
|
||||||
{reason}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
|
|||||||
@ -13,11 +13,7 @@ export function AddKeywordsPanel({
|
|||||||
}: {
|
}: {
|
||||||
configId: string;
|
configId: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
onSuccess: (result: {
|
onSuccess: (result: { added: number; checkTriggered: boolean }) => void;
|
||||||
added: number;
|
|
||||||
addedIds: string[];
|
|
||||||
checkTriggered: boolean;
|
|
||||||
}) => void;
|
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [keywordInput, setKeywordInput] = useState("");
|
const [keywordInput, setKeywordInput] = useState("");
|
||||||
|
|||||||
@ -4,7 +4,6 @@ import type { ColumnDef, SortingFn } from "@tanstack/react-table";
|
|||||||
import { makeSelectionColumn } from "@/client/components/table/AppDataTable";
|
import { makeSelectionColumn } from "@/client/components/table/AppDataTable";
|
||||||
import type { RankTrackingRow } from "@/types/schemas/rank-tracking";
|
import type { RankTrackingRow } from "@/types/schemas/rank-tracking";
|
||||||
import {
|
import {
|
||||||
comparePositions,
|
|
||||||
CpcCell,
|
CpcCell,
|
||||||
DeviceRankCell,
|
DeviceRankCell,
|
||||||
DeviceUrlCell,
|
DeviceUrlCell,
|
||||||
@ -101,20 +100,6 @@ const cpcColumn: ColumnDef<RankTrackingRow> = {
|
|||||||
sortingFn: nullsLastNumeric,
|
sortingFn: nullsLastNumeric,
|
||||||
};
|
};
|
||||||
|
|
||||||
const positionSort: SortingFn<RankTrackingRow> = (rowA, rowB, columnId) => {
|
|
||||||
const device = columnId === "desktopPosition" ? "desktop" : "mobile";
|
|
||||||
return comparePositions(
|
|
||||||
rowA.original[device].position,
|
|
||||||
rowB.original[device].position,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
function makeSelectColumn(
|
|
||||||
anchorRef: MutableRefObject<SelectionAnchor | null>,
|
|
||||||
): ColumnDef<RankTrackingRow> {
|
|
||||||
return makeSelectionColumn<RankTrackingRow>(anchorRef);
|
|
||||||
}
|
|
||||||
|
|
||||||
const keywordColumn: ColumnDef<RankTrackingRow> = {
|
const keywordColumn: ColumnDef<RankTrackingRow> = {
|
||||||
id: "keyword",
|
id: "keyword",
|
||||||
accessorKey: "keyword",
|
accessorKey: "keyword",
|
||||||
@ -140,7 +125,7 @@ function makeDeviceColumn(
|
|||||||
size: 120,
|
size: 120,
|
||||||
maxSize: 140,
|
maxSize: 140,
|
||||||
cell: ({ row }) => <DeviceRankCell result={row.original[device]} />,
|
cell: ({ row }) => <DeviceRankCell result={row.original[device]} />,
|
||||||
sortingFn: positionSort,
|
sortingFn: nullsLastNumeric,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -196,7 +181,7 @@ export function useRankTrackingColumns(
|
|||||||
): ColumnDef<RankTrackingRow>[] {
|
): ColumnDef<RankTrackingRow>[] {
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
const cols: ColumnDef<RankTrackingRow>[] = [
|
const cols: ColumnDef<RankTrackingRow>[] = [
|
||||||
makeSelectColumn(selectAnchorRef),
|
makeSelectionColumn<RankTrackingRow>(selectAnchorRef),
|
||||||
keywordColumn,
|
keywordColumn,
|
||||||
];
|
];
|
||||||
if (showDesktop) {
|
if (showDesktop) {
|
||||||
|
|||||||
@ -122,7 +122,6 @@ function RankTrackingDomainDetailInner({
|
|||||||
|
|
||||||
const handleKeywordsAdded = (result: {
|
const handleKeywordsAdded = (result: {
|
||||||
added: number;
|
added: number;
|
||||||
addedIds: string[];
|
|
||||||
checkTriggered: boolean;
|
checkTriggered: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
void queryClient.invalidateQueries({
|
void queryClient.invalidateQueries({
|
||||||
|
|||||||
@ -14,10 +14,7 @@ import {
|
|||||||
getRankTrackingConfigSummaries,
|
getRankTrackingConfigSummaries,
|
||||||
updateRankTrackingConfig,
|
updateRankTrackingConfig,
|
||||||
} from "@/serverFunctions/rank-tracking";
|
} from "@/serverFunctions/rank-tracking";
|
||||||
import {
|
import { devicesLabel, scheduleLabel } from "@/shared/rank-tracking";
|
||||||
devicesLabel as getDevicesLabel,
|
|
||||||
scheduleLabel as getScheduleLabel,
|
|
||||||
} from "@/shared/rank-tracking";
|
|
||||||
import { Modal } from "@/client/components/Modal";
|
import { Modal } from "@/client/components/Modal";
|
||||||
|
|
||||||
type ConfigSummary = Awaited<
|
type ConfigSummary = Awaited<
|
||||||
@ -139,9 +136,6 @@ function DomainRow({
|
|||||||
summary: ConfigSummary;
|
summary: ConfigSummary;
|
||||||
onArchive: () => void;
|
onArchive: () => void;
|
||||||
}) {
|
}) {
|
||||||
const dl = getDevicesLabel(summary.devices);
|
|
||||||
const sl = getScheduleLabel(summary.scheduleInterval);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex w-full items-center gap-4 px-5 py-3.5 transition-colors hover:bg-base-200/50">
|
<div className="relative flex w-full items-center gap-4 px-5 py-3.5 transition-colors hover:bg-base-200/50">
|
||||||
<Link
|
<Link
|
||||||
@ -153,7 +147,9 @@ function DomainRow({
|
|||||||
<div className="min-w-0 flex-1 pointer-events-none">
|
<div className="min-w-0 flex-1 pointer-events-none">
|
||||||
<p className="font-medium truncate">{summary.domain}</p>
|
<p className="font-medium truncate">{summary.domain}</p>
|
||||||
<p className="text-xs text-base-content/60">
|
<p className="text-xs text-base-content/60">
|
||||||
{LOCATIONS[summary.locationCode] ?? "US"} · {dl} · {sl}
|
{LOCATIONS[summary.locationCode] ?? "US"} ·{" "}
|
||||||
|
{devicesLabel(summary.devices)} ·{" "}
|
||||||
|
{scheduleLabel(summary.scheduleInterval)}
|
||||||
{summary.lastRunCompletedAt && (
|
{summary.lastRunCompletedAt && (
|
||||||
<>
|
<>
|
||||||
{" "}
|
{" "}
|
||||||
|
|||||||
@ -159,13 +159,6 @@ export function CpcCell({ value }: { value: number | null }) {
|
|||||||
return <span className="font-mono text-sm">${value.toFixed(2)}</span>;
|
return <span className="font-mono text-sm">${value.toFixed(2)}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function comparePositions(a: number | null, b: number | null): number {
|
|
||||||
if (a === null && b === null) return 0;
|
|
||||||
if (a === null) return 1; // nulls sort last
|
|
||||||
if (b === null) return -1;
|
|
||||||
return a - b;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Numeric change for CSV export — numbers bypass the CSV formula-injection sanitizer */
|
/** Numeric change for CSV export — numbers bypass the CSV formula-injection sanitizer */
|
||||||
function csvChange(
|
function csvChange(
|
||||||
current: number | null,
|
current: number | null,
|
||||||
|
|||||||
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;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRuntimeAuthMode() {
|
|
||||||
return getAuthMode(import.meta.env.AUTH_MODE ?? env.AUTH_MODE);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ensureUserMiddleware = createMiddleware({
|
export const ensureUserMiddleware = createMiddleware({
|
||||||
type: "function",
|
type: "function",
|
||||||
}).server(async ({ next, data }) => {
|
}).server(async ({ next, data }) => {
|
||||||
const authMode = getRuntimeAuthMode();
|
const authMode = getAuthMode(import.meta.env.AUTH_MODE ?? env.AUTH_MODE);
|
||||||
const headers = getRequest().headers;
|
const headers = getRequest().headers;
|
||||||
let context: EnsuredUserContext;
|
let context: EnsuredUserContext;
|
||||||
|
|
||||||
|
|||||||
@ -59,11 +59,7 @@ function AiPage() {
|
|||||||
<p className="text-xs font-medium uppercase tracking-wide text-base-content/50">
|
<p className="text-xs font-medium uppercase tracking-wide text-base-content/50">
|
||||||
MCP server URL
|
MCP server URL
|
||||||
</p>
|
</p>
|
||||||
<CopyButton
|
<CopyButton value={mcpUrl} successMessage="MCP URL copied" />
|
||||||
value={mcpUrl}
|
|
||||||
successMessage="MCP URL copied"
|
|
||||||
label="Copy"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<code className="mt-2 block break-all font-mono text-sm text-base-content">
|
<code className="mt-2 block break-all font-mono text-sm text-base-content">
|
||||||
{mcpUrl}
|
{mcpUrl}
|
||||||
|
|||||||
@ -6,10 +6,9 @@ import {
|
|||||||
AuthPageCard,
|
AuthPageCard,
|
||||||
AuthMethodChooser,
|
AuthMethodChooser,
|
||||||
authRedirectSearchSchema,
|
authRedirectSearchSchema,
|
||||||
getFieldError,
|
|
||||||
getFormError,
|
|
||||||
useAuthPageState,
|
useAuthPageState,
|
||||||
} from "@/client/features/auth/AuthPage";
|
} from "@/client/features/auth/AuthPage";
|
||||||
|
import { getFieldError, getFormError } from "@/client/lib/forms";
|
||||||
import { captureClientEvent } from "@/client/lib/posthog";
|
import { captureClientEvent } from "@/client/lib/posthog";
|
||||||
import { authClient } from "@/lib/auth-client";
|
import { authClient } from "@/lib/auth-client";
|
||||||
import { getSignInSearch } from "@/lib/auth-redirect";
|
import { getSignInSearch } from "@/lib/auth-redirect";
|
||||||
|
|||||||
@ -5,10 +5,9 @@ import {
|
|||||||
AuthPageCard,
|
AuthPageCard,
|
||||||
AuthMethodChooser,
|
AuthMethodChooser,
|
||||||
authRedirectSearchSchema,
|
authRedirectSearchSchema,
|
||||||
getFieldError,
|
|
||||||
getFormError,
|
|
||||||
useAuthPageState,
|
useAuthPageState,
|
||||||
} from "@/client/features/auth/AuthPage";
|
} from "@/client/features/auth/AuthPage";
|
||||||
|
import { getFieldError, getFormError } from "@/client/lib/forms";
|
||||||
import { captureClientEvent } from "@/client/lib/posthog";
|
import { captureClientEvent } from "@/client/lib/posthog";
|
||||||
import { authClient } from "@/lib/auth-client";
|
import { authClient } from "@/lib/auth-client";
|
||||||
import { getSignInSearch, getVerifyEmailSearch } from "@/lib/auth-redirect";
|
import { getSignInSearch, getVerifyEmailSearch } from "@/lib/auth-redirect";
|
||||||
|
|||||||
@ -4,9 +4,8 @@ import {
|
|||||||
AuthPageCard,
|
AuthPageCard,
|
||||||
AuthPageShell,
|
AuthPageShell,
|
||||||
authRedirectSearchSchema,
|
authRedirectSearchSchema,
|
||||||
getFieldError,
|
|
||||||
getFormError,
|
|
||||||
} from "@/client/features/auth/AuthPage";
|
} from "@/client/features/auth/AuthPage";
|
||||||
|
import { getFieldError, getFormError } from "@/client/lib/forms";
|
||||||
import { authClient } from "@/lib/auth-client";
|
import { authClient } from "@/lib/auth-client";
|
||||||
import { isHostedClientAuthMode } from "@/lib/auth-mode";
|
import { isHostedClientAuthMode } from "@/lib/auth-mode";
|
||||||
import { getSignInSearch, normalizeAuthRedirect } from "@/lib/auth-redirect";
|
import { getSignInSearch, normalizeAuthRedirect } from "@/lib/auth-redirect";
|
||||||
|
|||||||
@ -4,9 +4,8 @@ import {
|
|||||||
AuthPageCard,
|
AuthPageCard,
|
||||||
AuthPageShell,
|
AuthPageShell,
|
||||||
authRedirectSearchSchema,
|
authRedirectSearchSchema,
|
||||||
getFieldError,
|
|
||||||
getFormError,
|
|
||||||
} from "@/client/features/auth/AuthPage";
|
} from "@/client/features/auth/AuthPage";
|
||||||
|
import { getFieldError, getFormError } from "@/client/lib/forms";
|
||||||
import { authClient } from "@/lib/auth-client";
|
import { authClient } from "@/lib/auth-client";
|
||||||
import { isHostedClientAuthMode } from "@/lib/auth-mode";
|
import { isHostedClientAuthMode } from "@/lib/auth-mode";
|
||||||
import { getSignInSearch, normalizeAuthRedirect } from "@/lib/auth-redirect";
|
import { getSignInSearch, normalizeAuthRedirect } from "@/lib/auth-redirect";
|
||||||
|
|||||||
@ -230,11 +230,7 @@ function VerifyEmailPage() {
|
|||||||
{isResending ? "Sending email..." : "Resend email"}
|
{isResending ? "Sending email..." : "Resend email"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : isPending ? (
|
) : isPending || isVerified ? (
|
||||||
<div className="flex justify-center py-4">
|
|
||||||
<span className="loading loading-spinner loading-md" />
|
|
||||||
</div>
|
|
||||||
) : isVerified ? (
|
|
||||||
<div className="flex justify-center py-4">
|
<div className="flex justify-center py-4">
|
||||||
<span className="loading loading-spinner loading-md" />
|
<span className="loading loading-spinner loading-md" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -17,9 +17,7 @@ import { handleSelfHostedOpenSeoMcpRequest } from "@/server/mcp/transport";
|
|||||||
import { computeNextCheckAt } from "@/shared/rank-tracking";
|
import { computeNextCheckAt } from "@/shared/rank-tracking";
|
||||||
|
|
||||||
const appFetch = createStartHandler(defaultStreamHandler);
|
const appFetch = createStartHandler(defaultStreamHandler);
|
||||||
const handleAppFetch = (request: Request): Response | Promise<Response> =>
|
const openSeoOAuthProvider = createOpenSeoOAuthProvider(appFetch);
|
||||||
appFetch(request);
|
|
||||||
const openSeoOAuthProvider = createOpenSeoOAuthProvider(handleAppFetch);
|
|
||||||
|
|
||||||
function fetch(
|
function fetch(
|
||||||
request: Request,
|
request: Request,
|
||||||
@ -44,7 +42,7 @@ function fetch(
|
|||||||
return handleSelfHostedOpenSeoMcpRequest(publicRequest, authMode, env, ctx);
|
return handleSelfHostedOpenSeoMcpRequest(publicRequest, authMode, env, ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
return handleAppFetch(request);
|
return appFetch(request);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export Workflow classes as named exports
|
// Export Workflow classes as named exports
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { asc, eq } from "drizzle-orm";
|
import { asc, eq } from "drizzle-orm";
|
||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { member, user as authUser } from "@/db/better-auth-schema";
|
import { member, user as authUser } from "@/db/better-auth-schema";
|
||||||
|
import { slugify, toHex } from "./org-slug";
|
||||||
|
|
||||||
type HostedUser = {
|
type HostedUser = {
|
||||||
id: string;
|
id: string;
|
||||||
@ -18,23 +19,6 @@ type HostedOrganizationCreator = (
|
|||||||
input: HostedOrganizationCreateInput,
|
input: HostedOrganizationCreateInput,
|
||||||
) => Promise<{ id: string }>;
|
) => Promise<{ id: string }>;
|
||||||
|
|
||||||
function slugify(value: string) {
|
|
||||||
const slug = value
|
|
||||||
.toLowerCase()
|
|
||||||
.trim()
|
|
||||||
.replace(/[^a-z0-9]+/g, "-")
|
|
||||||
.replace(/^-+|-+$/g, "")
|
|
||||||
.slice(0, 48);
|
|
||||||
|
|
||||||
return slug || "workspace";
|
|
||||||
}
|
|
||||||
|
|
||||||
function toHex(value: string) {
|
|
||||||
return Array.from(new TextEncoder().encode(value), (byte) =>
|
|
||||||
byte.toString(16).padStart(2, "0"),
|
|
||||||
).join("");
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDefaultHostedOrganizationName(user: HostedUser) {
|
function getDefaultHostedOrganizationName(user: HostedUser) {
|
||||||
const name = user.name?.trim() || user.email.split("@")[0] || "OpenSEO";
|
const name = user.name?.trim() || user.email.split("@")[0] || "OpenSEO";
|
||||||
return `${name}'s workspace`;
|
return `${name}'s workspace`;
|
||||||
|
|||||||
@ -1,22 +1,6 @@
|
|||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { organization } from "@/db/better-auth-schema";
|
import { organization } from "@/db/better-auth-schema";
|
||||||
|
import { slugify, toHex } from "./org-slug";
|
||||||
function slugify(value: string) {
|
|
||||||
const slug = value
|
|
||||||
.toLowerCase()
|
|
||||||
.trim()
|
|
||||||
.replace(/[^a-z0-9]+/g, "-")
|
|
||||||
.replace(/^-+|-+$/g, "")
|
|
||||||
.slice(0, 48);
|
|
||||||
|
|
||||||
return slug || "workspace";
|
|
||||||
}
|
|
||||||
|
|
||||||
function toHex(value: string) {
|
|
||||||
return Array.from(new TextEncoder().encode(value), (byte) =>
|
|
||||||
byte.toString(16).padStart(2, "0"),
|
|
||||||
).join("");
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDelegatedOrganizationId(userId: string) {
|
function getDelegatedOrganizationId(userId: string) {
|
||||||
return `delegated-${userId}`;
|
return `delegated-${userId}`;
|
||||||
|
|||||||
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_TRANSACTIONAL_URL = "https://app.loops.so/api/v1/transactional";
|
||||||
const LOOPS_CONTACT_UPDATE_URL = "https://app.loops.so/api/v1/contacts/update";
|
const LOOPS_CONTACT_UPDATE_URL = "https://app.loops.so/api/v1/contacts/update";
|
||||||
|
|
||||||
function getRequiredEnv(name: string) {
|
function getOptionalEnv(name: string) {
|
||||||
const value: unknown = Reflect.get(env, name);
|
const value: unknown = Reflect.get(env, name);
|
||||||
const trimmed = typeof value === "string" ? value.trim() : "";
|
const trimmed = typeof value === "string" ? value.trim() : "";
|
||||||
|
|
||||||
if (!trimmed) {
|
return trimmed || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRequiredEnv(name: string) {
|
||||||
|
const value = getOptionalEnv(name);
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
throw new Error(`${name} is required in hosted mode`);
|
throw new Error(`${name} is required in hosted mode`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return trimmed;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getHostedAuthEmailConfig() {
|
function getHostedAuthEmailConfig() {
|
||||||
@ -68,13 +74,6 @@ async function sendLoopsTransactionalEmail({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getOptionalEnv(name: string) {
|
|
||||||
const value: unknown = Reflect.get(env, name);
|
|
||||||
const trimmed = typeof value === "string" ? value.trim() : "";
|
|
||||||
|
|
||||||
return trimmed || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getContactNameParts(name: string | null | undefined) {
|
function getContactNameParts(name: string | null | undefined) {
|
||||||
const trimmedName = name?.trim();
|
const trimmedName = name?.trim();
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type { BillingCustomerContext } from "@/server/billing/subscription";
|
import type { BillingCustomerContext } from "@/server/billing/subscription";
|
||||||
import {
|
import {
|
||||||
type BacklinksRequest,
|
|
||||||
type fetchBacklinksHistoryRaw,
|
type fetchBacklinksHistoryRaw,
|
||||||
type fetchBacklinksRowsRaw,
|
type fetchBacklinksRowsRaw,
|
||||||
type fetchBacklinksSummaryRaw,
|
type fetchBacklinksSummaryRaw,
|
||||||
@ -159,9 +158,10 @@ export async function profileTopPagesRows(
|
|||||||
|
|
||||||
const dataforseo = createDataforseoClient(billingCustomer);
|
const dataforseo = createDataforseoClient(billingCustomer);
|
||||||
|
|
||||||
const request = buildBacklinksRequest(
|
const request = {
|
||||||
normalizeBacklinksTarget(input.target, { scope: input.scope }).apiTarget,
|
target: normalizeBacklinksTarget(input.target, { scope: input.scope })
|
||||||
);
|
.apiTarget,
|
||||||
|
};
|
||||||
const response = await dataforseo.backlinks.domainPages({
|
const response = await dataforseo.backlinks.domainPages({
|
||||||
...request,
|
...request,
|
||||||
limit: 100,
|
limit: 100,
|
||||||
@ -173,17 +173,13 @@ export async function profileTopPagesRows(
|
|||||||
return { rows };
|
return { rows };
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildBacklinksRequest(target: string): BacklinksRequest {
|
|
||||||
return { target };
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildBacklinksListRequest(
|
function buildBacklinksListRequest(
|
||||||
target: string,
|
target: string,
|
||||||
limit: number,
|
limit: number,
|
||||||
options?: BacklinksSpamFilterOptions,
|
options?: BacklinksSpamFilterOptions,
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
...buildBacklinksRequest(target),
|
target,
|
||||||
limit,
|
limit,
|
||||||
...normalizeBacklinksSpamFilterOptions(options),
|
...normalizeBacklinksSpamFilterOptions(options),
|
||||||
};
|
};
|
||||||
|
|||||||
@ -83,10 +83,6 @@ const cachedResultSchema = z.object({
|
|||||||
|
|
||||||
const CACHE_VERSION = 2;
|
const CACHE_VERSION = 2;
|
||||||
|
|
||||||
function getMode(input: ResearchKeywordsInput): KeywordMode {
|
|
||||||
return input.mode ?? "auto";
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchRowsFromSource(
|
async function fetchRowsFromSource(
|
||||||
source: KeywordSource,
|
source: KeywordSource,
|
||||||
input: ResearchKeywordsInput,
|
input: ResearchKeywordsInput,
|
||||||
@ -195,12 +191,6 @@ async function fetchManualRows(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function isUsableCachedResult(cached: CachedResult): boolean {
|
|
||||||
if (cached.rows.length === 0) return false;
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function buildResearchCacheKey(
|
async function buildResearchCacheKey(
|
||||||
input: ResearchKeywordsInput,
|
input: ResearchKeywordsInput,
|
||||||
normalizedKeywords: string[],
|
normalizedKeywords: string[],
|
||||||
@ -254,7 +244,7 @@ export async function research(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const seedKeyword = uniqueKeywords[0];
|
const seedKeyword = uniqueKeywords[0];
|
||||||
const mode = getMode(input);
|
const mode = input.mode ?? "auto";
|
||||||
const cacheKey = await buildResearchCacheKey(
|
const cacheKey = await buildResearchCacheKey(
|
||||||
input,
|
input,
|
||||||
uniqueKeywords,
|
uniqueKeywords,
|
||||||
@ -268,7 +258,7 @@ export async function research(
|
|||||||
? cachedResult.data
|
? cachedResult.data
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (cached && isUsableCachedResult(cached)) {
|
if (cached && cached.rows.length > 0) {
|
||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -69,7 +69,7 @@ async function createConfig(input: {
|
|||||||
id: configId,
|
id: configId,
|
||||||
projectId: input.projectId,
|
projectId: input.projectId,
|
||||||
domain: normalizedDomain,
|
domain: normalizedDomain,
|
||||||
locationCode: input.locationCode ?? 2840,
|
locationCode,
|
||||||
languageCode: input.languageCode ?? "en",
|
languageCode: input.languageCode ?? "en",
|
||||||
devices: input.devices ?? "both",
|
devices: input.devices ?? "both",
|
||||||
serpDepth: input.serpDepth,
|
serpDepth: input.serpDepth,
|
||||||
|
|||||||
@ -124,9 +124,7 @@ export async function getLatestResults(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rows: activeKeywords
|
rows: [...rows.values()],
|
||||||
.map((keyword) => rows.get(keyword.id))
|
|
||||||
.filter((row): row is RankTrackingRow => row != null),
|
|
||||||
run:
|
run:
|
||||||
latestRunId && latestStartedAt
|
latestRunId && latestStartedAt
|
||||||
? { id: latestRunId, lastCheckedAt: latestStartedAt }
|
? { id: latestRunId, lastCheckedAt: latestStartedAt }
|
||||||
|
|||||||
@ -119,10 +119,6 @@ function isTimeoutError(error: unknown): boolean {
|
|||||||
return "name" in error && error.name === "TimeoutError";
|
return "name" in error && error.name === "TimeoutError";
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseXmlDocument(body: string): unknown {
|
|
||||||
return xmlParser.parse(body) as unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchSitemapDocumentWithRetry(sitemapUrl: string): Promise<{
|
async function fetchSitemapDocumentWithRetry(sitemapUrl: string): Promise<{
|
||||||
nestedSitemaps: string[];
|
nestedSitemaps: string[];
|
||||||
pageUrls: string[];
|
pageUrls: string[];
|
||||||
@ -156,7 +152,7 @@ async function fetchSitemapDocumentWithRetry(sitemapUrl: string): Promise<{
|
|||||||
return { nestedSitemaps: [], pageUrls: [], timedOut: false };
|
return { nestedSitemaps: [], pageUrls: [], timedOut: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = parseXmlDocument(body);
|
const parsed = xmlParser.parse(body) as unknown;
|
||||||
const sections = getParsedSitemapSections(parsed);
|
const sections = getParsedSitemapSections(parsed);
|
||||||
const nestedSitemaps = getSitemapLocations(sections.sitemap)
|
const nestedSitemaps = getSitemapLocations(sections.sitemap)
|
||||||
.map((loc) => normalizeUrl(loc, finalUrl))
|
.map((loc) => normalizeUrl(loc, finalUrl))
|
||||||
|
|||||||
@ -21,17 +21,13 @@ export function analyzeHtml(
|
|||||||
): PageAnalysis {
|
): PageAnalysis {
|
||||||
const $ = cheerio.load(html);
|
const $ = cheerio.load(html);
|
||||||
|
|
||||||
// --- Title ---
|
|
||||||
const title = $("title").first().text().trim();
|
const title = $("title").first().text().trim();
|
||||||
|
|
||||||
// --- Meta description ---
|
|
||||||
const metaDescription =
|
const metaDescription =
|
||||||
$('meta[name="description"]').first().attr("content")?.trim() ?? "";
|
$('meta[name="description"]').first().attr("content")?.trim() ?? "";
|
||||||
|
|
||||||
// --- Canonical ---
|
|
||||||
const canonical = $('link[rel="canonical"]').first().attr("href") ?? null;
|
const canonical = $('link[rel="canonical"]').first().attr("href") ?? null;
|
||||||
|
|
||||||
// --- Robots meta ---
|
|
||||||
const robotsMeta = $('meta[name="robots"]').first().attr("content") ?? null;
|
const robotsMeta = $('meta[name="robots"]').first().attr("content") ?? null;
|
||||||
|
|
||||||
// --- Open Graph ---
|
// --- Open Graph ---
|
||||||
@ -67,7 +63,6 @@ export function analyzeHtml(
|
|||||||
const bodyText = bodyClone.text().replace(/\s+/g, " ").trim();
|
const bodyText = bodyClone.text().replace(/\s+/g, " ").trim();
|
||||||
const wordCount = bodyText ? bodyText.split(/\s+/).length : 0;
|
const wordCount = bodyText ? bodyText.split(/\s+/).length : 0;
|
||||||
|
|
||||||
// --- Images ---
|
|
||||||
const images: Array<{ src: string | null; alt: string | null }> = [];
|
const images: Array<{ src: string | null; alt: string | null }> = [];
|
||||||
$("img").each((_, el) => {
|
$("img").each((_, el) => {
|
||||||
images.push({
|
images.push({
|
||||||
@ -103,7 +98,6 @@ export function analyzeHtml(
|
|||||||
hasStructuredData = true;
|
hasStructuredData = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Hreflang ---
|
|
||||||
const hreflangTags: string[] = [];
|
const hreflangTags: string[] = [];
|
||||||
$('link[rel="alternate"][hreflang]').each((_, el) => {
|
$('link[rel="alternate"][hreflang]').each((_, el) => {
|
||||||
const hreflang = $(el).attr("hreflang");
|
const hreflang = $(el).attr("hreflang");
|
||||||
|
|||||||
@ -37,17 +37,6 @@ function key(auditId: string): string {
|
|||||||
return `${KV_PREFIX}${auditId}`;
|
return `${KV_PREFIX}${auditId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Append a crawled URL entry to the progress list.
|
|
||||||
* Newest entries are prepended so the array is sorted newest-first.
|
|
||||||
*/
|
|
||||||
async function pushCrawledUrl(
|
|
||||||
auditId: string,
|
|
||||||
entry: CrawledUrlEntry,
|
|
||||||
): Promise<void> {
|
|
||||||
await pushCrawledUrls(auditId, [entry]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Append multiple crawled URL entries in one KV write.
|
* Append multiple crawled URL entries in one KV write.
|
||||||
* New entries are prepended and the list is capped.
|
* New entries are prepended and the list is capped.
|
||||||
@ -85,7 +74,6 @@ async function clear(auditId: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const AuditProgressKV = {
|
export const AuditProgressKV = {
|
||||||
pushCrawledUrl,
|
|
||||||
pushCrawledUrls,
|
pushCrawledUrls,
|
||||||
getCrawledUrls,
|
getCrawledUrls,
|
||||||
clear,
|
clear,
|
||||||
|
|||||||
@ -19,11 +19,7 @@ export function asAppError(error: unknown): AppError | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toErrorCode(error: unknown): ErrorCode {
|
|
||||||
return asAppError(error)?.code ?? "INTERNAL_ERROR";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toClientError(error: unknown): Error {
|
export function toClientError(error: unknown): Error {
|
||||||
const appError = asAppError(error);
|
const appError = asAppError(error);
|
||||||
return new Error(appError?.code ?? toErrorCode(error));
|
return new Error(appError?.code ?? "INTERNAL_ERROR");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,62 +1,5 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
import { LIGHTHOUSE_CATEGORIES } from "@/shared/lighthouse";
|
||||||
LIGHTHOUSE_CATEGORIES,
|
|
||||||
type LighthouseCategory,
|
|
||||||
} from "@/shared/lighthouse";
|
|
||||||
|
|
||||||
export type StoredLighthouseIssue = {
|
|
||||||
category: LighthouseCategory;
|
|
||||||
auditKey: string;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
score: number | null;
|
|
||||||
scoreDisplayMode: string | null;
|
|
||||||
displayValue: string | null;
|
|
||||||
impactMs: number | null;
|
|
||||||
impactBytes: number | null;
|
|
||||||
severity: "critical" | "warning" | "info";
|
|
||||||
items: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type StoredLighthouseMetric = {
|
|
||||||
score: number | null;
|
|
||||||
displayValue: string | null;
|
|
||||||
numericValue: number | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type StoredLighthouseMetrics = {
|
|
||||||
firstContentfulPaint: StoredLighthouseMetric;
|
|
||||||
largestContentfulPaint: StoredLighthouseMetric;
|
|
||||||
totalBlockingTime: StoredLighthouseMetric;
|
|
||||||
cumulativeLayoutShift: StoredLighthouseMetric;
|
|
||||||
speedIndex: StoredLighthouseMetric;
|
|
||||||
timeToInteractive: StoredLighthouseMetric;
|
|
||||||
interactionToNextPaint: StoredLighthouseMetric;
|
|
||||||
serverResponseTime: StoredLighthouseMetric;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type StoredLighthousePayload = {
|
|
||||||
version: 2;
|
|
||||||
source: "dataforseo-lighthouse";
|
|
||||||
hasIssueDetails: boolean;
|
|
||||||
metadata: {
|
|
||||||
requestedUrl: string;
|
|
||||||
finalUrl: string;
|
|
||||||
strategy: "mobile" | "desktop";
|
|
||||||
fetchedAt: string;
|
|
||||||
lighthouseVersion: string | null;
|
|
||||||
taskId: string | null;
|
|
||||||
cost: number | null;
|
|
||||||
};
|
|
||||||
scores: {
|
|
||||||
performance: number | null;
|
|
||||||
accessibility: number | null;
|
|
||||||
"best-practices": number | null;
|
|
||||||
seo: number | null;
|
|
||||||
};
|
|
||||||
metrics: StoredLighthouseMetrics;
|
|
||||||
issues: StoredLighthouseIssue[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type RawLighthouseAudit = {
|
export type RawLighthouseAudit = {
|
||||||
title?: string;
|
title?: string;
|
||||||
@ -85,6 +28,31 @@ const storedLighthouseMetricSchema = z.object({
|
|||||||
numericValue: z.number().nullable(),
|
numericValue: z.number().nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const storedLighthouseMetricsSchema = z.object({
|
||||||
|
firstContentfulPaint: storedLighthouseMetricSchema,
|
||||||
|
largestContentfulPaint: storedLighthouseMetricSchema,
|
||||||
|
totalBlockingTime: storedLighthouseMetricSchema,
|
||||||
|
cumulativeLayoutShift: storedLighthouseMetricSchema,
|
||||||
|
speedIndex: storedLighthouseMetricSchema,
|
||||||
|
timeToInteractive: storedLighthouseMetricSchema,
|
||||||
|
interactionToNextPaint: storedLighthouseMetricSchema,
|
||||||
|
serverResponseTime: storedLighthouseMetricSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
const storedLighthouseIssueSchema = z.object({
|
||||||
|
category: z.enum(LIGHTHOUSE_CATEGORIES),
|
||||||
|
auditKey: z.string(),
|
||||||
|
title: z.string(),
|
||||||
|
description: z.string(),
|
||||||
|
score: z.number().nullable(),
|
||||||
|
scoreDisplayMode: z.string().nullable(),
|
||||||
|
displayValue: z.string().nullable(),
|
||||||
|
impactMs: z.number().nullable(),
|
||||||
|
impactBytes: z.number().nullable(),
|
||||||
|
severity: z.enum(["critical", "warning", "info"]),
|
||||||
|
items: z.array(z.string()),
|
||||||
|
});
|
||||||
|
|
||||||
export const storedLighthousePayloadSchema = z.object({
|
export const storedLighthousePayloadSchema = z.object({
|
||||||
version: z.literal(2),
|
version: z.literal(2),
|
||||||
source: z.literal("dataforseo-lighthouse"),
|
source: z.literal("dataforseo-lighthouse"),
|
||||||
@ -104,33 +72,17 @@ export const storedLighthousePayloadSchema = z.object({
|
|||||||
"best-practices": z.number().nullable(),
|
"best-practices": z.number().nullable(),
|
||||||
seo: z.number().nullable(),
|
seo: z.number().nullable(),
|
||||||
}),
|
}),
|
||||||
metrics: z.object({
|
metrics: storedLighthouseMetricsSchema,
|
||||||
firstContentfulPaint: storedLighthouseMetricSchema,
|
issues: z.array(storedLighthouseIssueSchema),
|
||||||
largestContentfulPaint: storedLighthouseMetricSchema,
|
|
||||||
totalBlockingTime: storedLighthouseMetricSchema,
|
|
||||||
cumulativeLayoutShift: storedLighthouseMetricSchema,
|
|
||||||
speedIndex: storedLighthouseMetricSchema,
|
|
||||||
timeToInteractive: storedLighthouseMetricSchema,
|
|
||||||
interactionToNextPaint: storedLighthouseMetricSchema,
|
|
||||||
serverResponseTime: storedLighthouseMetricSchema,
|
|
||||||
}),
|
|
||||||
issues: z.array(
|
|
||||||
z.object({
|
|
||||||
category: z.enum(LIGHTHOUSE_CATEGORIES),
|
|
||||||
auditKey: z.string(),
|
|
||||||
title: z.string(),
|
|
||||||
description: z.string(),
|
|
||||||
score: z.number().nullable(),
|
|
||||||
scoreDisplayMode: z.string().nullable(),
|
|
||||||
displayValue: z.string().nullable(),
|
|
||||||
impactMs: z.number().nullable(),
|
|
||||||
impactBytes: z.number().nullable(),
|
|
||||||
severity: z.enum(["critical", "warning", "info"]),
|
|
||||||
items: z.array(z.string()),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
type StoredLighthouseMetric = z.infer<typeof storedLighthouseMetricSchema>;
|
||||||
|
type StoredLighthouseMetrics = z.infer<typeof storedLighthouseMetricsSchema>;
|
||||||
|
export type StoredLighthouseIssue = z.infer<typeof storedLighthouseIssueSchema>;
|
||||||
|
export type StoredLighthousePayload = z.infer<
|
||||||
|
typeof storedLighthousePayloadSchema
|
||||||
|
>;
|
||||||
|
|
||||||
export function scoreToPercent(
|
export function scoreToPercent(
|
||||||
score: number | null | undefined,
|
score: number | null | undefined,
|
||||||
): number | null {
|
): number | null {
|
||||||
@ -244,7 +196,7 @@ export function buildStoredLighthouseIssues(input: {
|
|||||||
|
|
||||||
const isPass =
|
const isPass =
|
||||||
score == null ||
|
score == null ||
|
||||||
(score != null && score >= 90) ||
|
score >= 90 ||
|
||||||
scoreDisplayMode === "notApplicable" ||
|
scoreDisplayMode === "notApplicable" ||
|
||||||
scoreDisplayMode === "informative" ||
|
scoreDisplayMode === "informative" ||
|
||||||
scoreDisplayMode === "manual" ||
|
scoreDisplayMode === "manual" ||
|
||||||
|
|||||||
@ -82,12 +82,7 @@ async function upsertAttribution(args: CaptureRedditConversionArgs) {
|
|||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
async function hasSentConversion(args: CaptureRedditConversionArgs) {
|
|
||||||
const existing = await db.query.redditAttributions.findFirst({
|
|
||||||
where: eq(redditAttributions.userId, args.userId),
|
|
||||||
});
|
|
||||||
return args.eventType === "SIGN_UP"
|
return args.eventType === "SIGN_UP"
|
||||||
? Boolean(existing?.signupSentAt)
|
? Boolean(existing?.signupSentAt)
|
||||||
: Boolean(existing?.purchaseSentAt);
|
: Boolean(existing?.purchaseSentAt);
|
||||||
@ -112,8 +107,8 @@ export async function captureRedditConversion(
|
|||||||
) {
|
) {
|
||||||
if (!hasRedditAttribution(args.attribution)) return "skipped" as const;
|
if (!hasRedditAttribution(args.attribution)) return "skipped" as const;
|
||||||
|
|
||||||
await upsertAttribution(args);
|
const alreadySent = await upsertAttribution(args);
|
||||||
if (await hasSentConversion(args)) return "already_sent" as const;
|
if (alreadySent) return "already_sent" as const;
|
||||||
|
|
||||||
const config = getRedditConfig();
|
const config = getRedditConfig();
|
||||||
if (!config) return "stored" as const;
|
if (!config) return "stored" as const;
|
||||||
|
|||||||
@ -90,10 +90,6 @@ export function getAuth(extra: ToolExtra): McpAuth {
|
|||||||
return auth;
|
return auth;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getBaseUrl(extra: ToolExtra): string {
|
|
||||||
return requireMcpToolAuthContext(extra).baseUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildBillingCustomer(
|
export function buildBillingCustomer(
|
||||||
auth: McpAuth,
|
auth: McpAuth,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
|
|||||||
@ -24,18 +24,16 @@ export function mcpResponse(opts: {
|
|||||||
if (value !== undefined) meta[key] = value;
|
if (value !== undefined) meta[key] = value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const hasMeta = meta != null && Object.keys(meta).length > 0;
|
||||||
if (opts.structuredContent) {
|
if (opts.structuredContent) {
|
||||||
result.structuredContent =
|
result.structuredContent = hasMeta
|
||||||
meta && Object.keys(meta).length > 0
|
|
||||||
? { ...opts.structuredContent, meta }
|
? { ...opts.structuredContent, meta }
|
||||||
: opts.structuredContent;
|
: opts.structuredContent;
|
||||||
} else if (meta && Object.keys(meta).length > 0) {
|
} else if (hasMeta) {
|
||||||
result.structuredContent = { meta };
|
result.structuredContent = { meta };
|
||||||
}
|
}
|
||||||
if (meta) {
|
if (hasMeta) {
|
||||||
if (Object.keys(meta).length > 0) {
|
|
||||||
result._meta = meta;
|
result._meta = meta;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,8 +9,8 @@ type ProjectScopedArgs = {
|
|||||||
projectId: string;
|
projectId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
async function requireProjectAccess(_extra: ToolExtra, projectId: string) {
|
async function requireProjectAccess(extra: ToolExtra, projectId: string) {
|
||||||
const { baseUrl, ...auth } = requireMcpToolAuthContext(_extra);
|
const { baseUrl, ...auth } = requireMcpToolAuthContext(extra);
|
||||||
|
|
||||||
// This lookup enforces that the project belongs to the authenticated org.
|
// This lookup enforces that the project belongs to the authenticated org.
|
||||||
await ProjectService.getProjectForOrganization(
|
await ProjectService.getProjectForOrganization(
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
/* eslint-disable max-lines */
|
/* eslint-disable max-lines */
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { AppError } from "@/server/lib/errors";
|
|
||||||
import { createDataforseoClient } from "@/server/lib/dataforseoClient";
|
import { createDataforseoClient } from "@/server/lib/dataforseoClient";
|
||||||
import { buildProjectMeta } from "@/server/mcp/context";
|
import { buildProjectMeta } from "@/server/mcp/context";
|
||||||
import { mcpResponse } from "@/server/mcp/formatters";
|
import { mcpResponse } from "@/server/mcp/formatters";
|
||||||
@ -85,7 +84,6 @@ const rankedTargetSchema = z
|
|||||||
"Use a domain without protocol/www or an absolute page URL.",
|
"Use a domain without protocol/www or an absolute page URL.",
|
||||||
);
|
);
|
||||||
|
|
||||||
const looseRecordSchema = z.record(z.string(), z.unknown());
|
|
||||||
const getRankedKeywordsInputSchema = {
|
const getRankedKeywordsInputSchema = {
|
||||||
projectId: projectIdSchema,
|
projectId: projectIdSchema,
|
||||||
target: rankedTargetSchema,
|
target: rankedTargetSchema,
|
||||||
@ -180,18 +178,10 @@ type GetGoogleBusinessQuestionsArgs = z.infer<
|
|||||||
const QUESTIONS_ANSWERS_MIN_RADIUS = 200;
|
const QUESTIONS_ANSWERS_MIN_RADIUS = 200;
|
||||||
const QUESTIONS_ANSWERS_MAX_RADIUS = 199999;
|
const QUESTIONS_ANSWERS_MAX_RADIUS = 199999;
|
||||||
|
|
||||||
function resolveMarketLocationCode(market: Market | undefined): number {
|
function resolveMarketLocationCode(_market: Market | undefined): number {
|
||||||
const country = market?.country?.trim().toLowerCase();
|
// The Zod enum on market.country already restricts values to United States
|
||||||
if (
|
// variants, so no other country can reach this code path.
|
||||||
!country ||
|
|
||||||
["us", "usa", "united states", "united states of america"].includes(country)
|
|
||||||
) {
|
|
||||||
return DEFAULT_LOCATION_CODE;
|
return DEFAULT_LOCATION_CODE;
|
||||||
}
|
|
||||||
throw new AppError(
|
|
||||||
"VALIDATION_ERROR",
|
|
||||||
"Only United States country targeting is supported by this MCP tool today.",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatCoordinate(value: number): string {
|
function formatCoordinate(value: number): string {
|
||||||
@ -263,9 +253,12 @@ function buildRankedKeywordFilters(args: {
|
|||||||
return filters.length > 0 ? filters : undefined;
|
return filters.length > 0 ? filters : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
||||||
const parsed = looseRecordSchema.safeParse(value);
|
return isRecord(value) ? value : undefined;
|
||||||
return parsed.success ? parsed.data : undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function displayValue(value: unknown): string {
|
function displayValue(value: unknown): string {
|
||||||
|
|||||||
@ -1,6 +1,9 @@
|
|||||||
import { ProjectService } from "@/server/features/projects/services/ProjectService";
|
import { ProjectService } from "@/server/features/projects/services/ProjectService";
|
||||||
import { mcpResponse } from "@/server/mcp/formatters";
|
import { mcpResponse } from "@/server/mcp/formatters";
|
||||||
import { getAuth, getBaseUrl, type ToolExtra } from "@/server/mcp/context";
|
import {
|
||||||
|
requireMcpToolAuthContext,
|
||||||
|
type ToolExtra,
|
||||||
|
} from "@/server/mcp/context";
|
||||||
import { optionalMetaOutputSchema } from "@/server/mcp/output-schemas";
|
import { optionalMetaOutputSchema } from "@/server/mcp/output-schemas";
|
||||||
import { buildDashboardUrl } from "@/server/mcp/urls";
|
import { buildDashboardUrl } from "@/server/mcp/urls";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@ -32,8 +35,7 @@ export const listProjectsTool = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
handler: async (_args: Record<string, never>, extra: ToolExtra) => {
|
handler: async (_args: Record<string, never>, extra: ToolExtra) => {
|
||||||
const auth = getAuth(extra);
|
const { baseUrl, ...auth } = requireMcpToolAuthContext(extra);
|
||||||
const baseUrl = getBaseUrl(extra);
|
|
||||||
const projects = await ProjectService.listProjects(auth.organizationId);
|
const projects = await ProjectService.listProjects(auth.organizationId);
|
||||||
const lines =
|
const lines =
|
||||||
projects.length === 0
|
projects.length === 0
|
||||||
|
|||||||
@ -51,17 +51,16 @@ interface CheckContext {
|
|||||||
export async function runLiveCheck(
|
export async function runLiveCheck(
|
||||||
step: WorkflowStep,
|
step: WorkflowStep,
|
||||||
ctx: CheckContext,
|
ctx: CheckContext,
|
||||||
): Promise<{ totalFailed: number }> {
|
): Promise<void> {
|
||||||
const deviceList: Array<"desktop" | "mobile"> =
|
const deviceList: Array<"desktop" | "mobile"> =
|
||||||
ctx.devices === "both" ? ["desktop", "mobile"] : [ctx.devices];
|
ctx.devices === "both" ? ["desktop", "mobile"] : [ctx.devices];
|
||||||
let checked = 0;
|
let checked = 0;
|
||||||
let totalFailed = 0;
|
|
||||||
|
|
||||||
for (let i = 0; i < ctx.keywords.length; i += KEYWORDS_PER_BATCH) {
|
for (let i = 0; i < ctx.keywords.length; i += KEYWORDS_PER_BATCH) {
|
||||||
const batch = ctx.keywords.slice(i, i + KEYWORDS_PER_BATCH);
|
const batch = ctx.keywords.slice(i, i + KEYWORDS_PER_BATCH);
|
||||||
const batchIndex = Math.floor(i / KEYWORDS_PER_BATCH);
|
const batchIndex = Math.floor(i / KEYWORDS_PER_BATCH);
|
||||||
|
|
||||||
const batchResults = await step.do(
|
await step.do(
|
||||||
`live-batch-${batchIndex}`,
|
`live-batch-${batchIndex}`,
|
||||||
SINGLE_ATTEMPT_STEP_CONFIG,
|
SINGLE_ATTEMPT_STEP_CONFIG,
|
||||||
async () => {
|
async () => {
|
||||||
@ -82,12 +81,10 @@ export async function runLiveCheck(
|
|||||||
);
|
);
|
||||||
const settled = await Promise.allSettled(promises);
|
const settled = await Promise.allSettled(promises);
|
||||||
const results: RankCheckResultWithDevice[] = [];
|
const results: RankCheckResultWithDevice[] = [];
|
||||||
let batchFailed = 0;
|
|
||||||
for (const outcome of settled) {
|
for (const outcome of settled) {
|
||||||
if (outcome.status === "fulfilled") {
|
if (outcome.status === "fulfilled") {
|
||||||
results.push(outcome.value);
|
results.push(outcome.value);
|
||||||
} else {
|
} else {
|
||||||
batchFailed++;
|
|
||||||
console.error("Rank check call failed:", outcome.reason);
|
console.error("Rank check call failed:", outcome.reason);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -101,16 +98,7 @@ export async function runLiveCheck(
|
|||||||
mapResultsToSnapshotRows(ctx.runId, results),
|
mapResultsToSnapshotRows(ctx.runId, results),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return { batchFailed };
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
totalFailed += batchResults.batchFailed;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (totalFailed > 0) {
|
|
||||||
console.warn(`Rank check completed with ${totalFailed} failed API call(s)`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { totalFailed };
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,7 +20,7 @@ export function FeaturePageTemplate({ page }: FeaturePageProps) {
|
|||||||
href="https://app.openseo.so/sign-up"
|
href="https://app.openseo.so/sign-up"
|
||||||
className="inline-flex h-10 items-center justify-center rounded-md bg-neutral-900 px-5 text-sm font-medium text-white transition-colors hover:bg-neutral-800"
|
className="inline-flex h-10 items-center justify-center rounded-md bg-neutral-900 px-5 text-sm font-medium text-white transition-colors hover:bg-neutral-800"
|
||||||
>
|
>
|
||||||
{page.ctaLabel ?? "Try OpenSEO"}
|
Try OpenSEO
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@ -142,7 +142,7 @@ export function FeaturePageTemplate({ page }: FeaturePageProps) {
|
|||||||
href="https://app.openseo.so/sign-up"
|
href="https://app.openseo.so/sign-up"
|
||||||
className="inline-flex h-9 items-center justify-center rounded-md bg-neutral-900 px-4 text-sm font-medium text-white transition-colors hover:bg-neutral-800"
|
className="inline-flex h-9 items-center justify-center rounded-md bg-neutral-900 px-4 text-sm font-medium text-white transition-colors hover:bg-neutral-800"
|
||||||
>
|
>
|
||||||
{page.ctaLabel ?? "Try OpenSEO"}
|
Try OpenSEO
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@ -151,7 +151,6 @@ export function FeaturePageTemplate({ page }: FeaturePageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function FeatureImage({ page }: FeaturePageProps) {
|
function FeatureImage({ page }: FeaturePageProps) {
|
||||||
if (page.imageSrc) {
|
|
||||||
return (
|
return (
|
||||||
<figure className="mt-9">
|
<figure className="mt-9">
|
||||||
<img
|
<img
|
||||||
@ -168,27 +167,4 @@ function FeatureImage({ page }: FeaturePageProps) {
|
|||||||
</figcaption>
|
</figcaption>
|
||||||
</figure>
|
</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[]) {
|
function getContentPost(slugs: string[]) {
|
||||||
const page = source.getPage(slugs);
|
const page = docsSource.getPage(slugs);
|
||||||
if (!page) throw notFound();
|
if (!page) throw notFound();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -40,12 +40,12 @@ function getContentPost(source: typeof docsSource, slugs: string[]) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getContentPosts(source: typeof docsSource) {
|
function getContentPosts() {
|
||||||
const topLevelOrder = new Map([
|
const topLevelOrder = new Map([
|
||||||
["mcp", 0],
|
["mcp", 0],
|
||||||
["skills", 1],
|
["skills", 1],
|
||||||
]);
|
]);
|
||||||
const pages = source.getPages();
|
const pages = docsSource.getPages();
|
||||||
|
|
||||||
return pages
|
return pages
|
||||||
.map((page: (typeof pages)[number]) => ({
|
.map((page: (typeof pages)[number]) => ({
|
||||||
@ -69,10 +69,10 @@ function getContentPosts(source: typeof docsSource) {
|
|||||||
|
|
||||||
export const getDocsPost = createServerFn({ method: "GET" })
|
export const getDocsPost = createServerFn({ method: "GET" })
|
||||||
.inputValidator((slugs: string[]) => slugs)
|
.inputValidator((slugs: string[]) => slugs)
|
||||||
.handler(async ({ data: slugs }) => getContentPost(docsSource, slugs));
|
.handler(async ({ data: slugs }) => getContentPost(slugs));
|
||||||
|
|
||||||
export const getDocsPosts = createServerFn({ method: "GET" }).handler(
|
export const getDocsPosts = createServerFn({ method: "GET" }).handler(
|
||||||
async () => getContentPosts(docsSource),
|
async () => getContentPosts(),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const getDocsPageTree = createServerFn({ method: "GET" }).handler(
|
export const getDocsPageTree = createServerFn({ method: "GET" }).handler(
|
||||||
|
|||||||
@ -9,9 +9,7 @@ export type FeaturePage = {
|
|||||||
primaryKeyword: string;
|
primaryKeyword: string;
|
||||||
secondaryKeywords: string[];
|
secondaryKeywords: string[];
|
||||||
imageAlt: string;
|
imageAlt: string;
|
||||||
imagePlaceholder: string;
|
imageSrc: string;
|
||||||
imageSrc?: string;
|
|
||||||
ctaLabel?: string;
|
|
||||||
workflows: Array<{
|
workflows: Array<{
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
@ -47,7 +45,6 @@ export const featurePages = {
|
|||||||
"keyword research tools",
|
"keyword research tools",
|
||||||
],
|
],
|
||||||
imageAlt: "OpenSEO keyword research dashboard",
|
imageAlt: "OpenSEO keyword research dashboard",
|
||||||
imagePlaceholder: "Keyword research screenshot placeholder",
|
|
||||||
imageSrc:
|
imageSrc:
|
||||||
"https://imagedelivery.net/ysLOa6bzFaM49Jxok-TAlw/d77077d0-cdf4-4523-0c41-56a7b4861300/public",
|
"https://imagedelivery.net/ysLOa6bzFaM49Jxok-TAlw/d77077d0-cdf4-4523-0c41-56a7b4861300/public",
|
||||||
workflows: [
|
workflows: [
|
||||||
@ -123,7 +120,6 @@ export const featurePages = {
|
|||||||
"seo audit tools",
|
"seo audit tools",
|
||||||
],
|
],
|
||||||
imageAlt: "OpenSEO site audit report",
|
imageAlt: "OpenSEO site audit report",
|
||||||
imagePlaceholder: "Site audit screenshot placeholder",
|
|
||||||
imageSrc:
|
imageSrc:
|
||||||
"https://imagedelivery.net/ysLOa6bzFaM49Jxok-TAlw/53149e87-0027-4fa8-5d13-bcaab60c7100/public",
|
"https://imagedelivery.net/ysLOa6bzFaM49Jxok-TAlw/53149e87-0027-4fa8-5d13-bcaab60c7100/public",
|
||||||
workflows: [
|
workflows: [
|
||||||
@ -196,7 +192,6 @@ export const featurePages = {
|
|||||||
"google backlink checker",
|
"google backlink checker",
|
||||||
],
|
],
|
||||||
imageAlt: "OpenSEO backlinks report",
|
imageAlt: "OpenSEO backlinks report",
|
||||||
imagePlaceholder: "Backlink checker screenshot placeholder",
|
|
||||||
imageSrc:
|
imageSrc:
|
||||||
"https://imagedelivery.net/ysLOa6bzFaM49Jxok-TAlw/d97206ed-bd64-447c-2b9e-1b9f07c5ec00/public",
|
"https://imagedelivery.net/ysLOa6bzFaM49Jxok-TAlw/d97206ed-bd64-447c-2b9e-1b9f07c5ec00/public",
|
||||||
workflows: [
|
workflows: [
|
||||||
@ -272,7 +267,6 @@ export const featurePages = {
|
|||||||
"competitor analysis seo tool",
|
"competitor analysis seo tool",
|
||||||
],
|
],
|
||||||
imageAlt: "OpenSEO domain overview",
|
imageAlt: "OpenSEO domain overview",
|
||||||
imagePlaceholder: "Domain overview screenshot placeholder",
|
|
||||||
imageSrc:
|
imageSrc:
|
||||||
"https://imagedelivery.net/ysLOa6bzFaM49Jxok-TAlw/189e22b8-fdf8-46b4-198c-e912beef2300/public",
|
"https://imagedelivery.net/ysLOa6bzFaM49Jxok-TAlw/189e22b8-fdf8-46b4-198c-e912beef2300/public",
|
||||||
workflows: [
|
workflows: [
|
||||||
@ -348,7 +342,6 @@ export const featurePages = {
|
|||||||
"google rank tracker",
|
"google rank tracker",
|
||||||
],
|
],
|
||||||
imageAlt: "OpenSEO rank tracking table",
|
imageAlt: "OpenSEO rank tracking table",
|
||||||
imagePlaceholder: "Rank tracking screenshot placeholder",
|
|
||||||
imageSrc:
|
imageSrc:
|
||||||
"https://imagedelivery.net/ysLOa6bzFaM49Jxok-TAlw/4a0f8508-1527-46a8-c91c-086456f21c00/public",
|
"https://imagedelivery.net/ysLOa6bzFaM49Jxok-TAlw/4a0f8508-1527-46a8-c91c-086456f21c00/public",
|
||||||
workflows: [
|
workflows: [
|
||||||
@ -424,7 +417,6 @@ export const featurePages = {
|
|||||||
"keyword planning",
|
"keyword planning",
|
||||||
],
|
],
|
||||||
imageAlt: "OpenSEO saved keywords list",
|
imageAlt: "OpenSEO saved keywords list",
|
||||||
imagePlaceholder: "Saved keywords screenshot placeholder",
|
|
||||||
imageSrc:
|
imageSrc:
|
||||||
"https://imagedelivery.net/ysLOa6bzFaM49Jxok-TAlw/8938a529-b443-4d4f-9869-c972f3cef900/public",
|
"https://imagedelivery.net/ysLOa6bzFaM49Jxok-TAlw/8938a529-b443-4d4f-9869-c972f3cef900/public",
|
||||||
workflows: [
|
workflows: [
|
||||||
@ -497,7 +489,6 @@ export const featurePages = {
|
|||||||
"answer engine optimization",
|
"answer engine optimization",
|
||||||
],
|
],
|
||||||
imageAlt: "OpenSEO AI brand visibility report",
|
imageAlt: "OpenSEO AI brand visibility report",
|
||||||
imagePlaceholder: "AI brand visibility screenshot placeholder",
|
|
||||||
imageSrc:
|
imageSrc:
|
||||||
"https://imagedelivery.net/ysLOa6bzFaM49Jxok-TAlw/cde3e4f8-079f-4890-cb17-371087107400/public",
|
"https://imagedelivery.net/ysLOa6bzFaM49Jxok-TAlw/cde3e4f8-079f-4890-cb17-371087107400/public",
|
||||||
workflows: [
|
workflows: [
|
||||||
@ -570,7 +561,6 @@ export const featurePages = {
|
|||||||
"answer engine optimization tool",
|
"answer engine optimization tool",
|
||||||
],
|
],
|
||||||
imageAlt: "OpenSEO prompt explorer",
|
imageAlt: "OpenSEO prompt explorer",
|
||||||
imagePlaceholder: "Prompt explorer screenshot placeholder",
|
|
||||||
imageSrc:
|
imageSrc:
|
||||||
"https://imagedelivery.net/ysLOa6bzFaM49Jxok-TAlw/9f3d38f2-aa97-417c-ca74-ae378654d700/public",
|
"https://imagedelivery.net/ysLOa6bzFaM49Jxok-TAlw/9f3d38f2-aa97-417c-ca74-ae378654d700/public",
|
||||||
workflows: [
|
workflows: [
|
||||||
|
|||||||
@ -25,7 +25,6 @@ type BuildSeoParams = {
|
|||||||
description?: string;
|
description?: string;
|
||||||
titleSuffix?: string;
|
titleSuffix?: string;
|
||||||
ogType?: "website" | "article";
|
ogType?: "website" | "article";
|
||||||
imagePath?: string;
|
|
||||||
imageAlt?: string;
|
imageAlt?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -35,12 +34,11 @@ export function buildPageSeo({
|
|||||||
description,
|
description,
|
||||||
titleSuffix,
|
titleSuffix,
|
||||||
ogType = "website",
|
ogType = "website",
|
||||||
imagePath = DEFAULT_SOCIAL_IMAGE_PATH,
|
|
||||||
imageAlt = DEFAULT_SOCIAL_IMAGE_ALT,
|
imageAlt = DEFAULT_SOCIAL_IMAGE_ALT,
|
||||||
}: BuildSeoParams) {
|
}: BuildSeoParams) {
|
||||||
const fullTitle = titleSuffix ? `${title} - ${titleSuffix}` : title;
|
const fullTitle = titleSuffix ? `${title} - ${titleSuffix}` : title;
|
||||||
const canonicalUrl = toCanonicalUrl(path);
|
const canonicalUrl = toCanonicalUrl(path);
|
||||||
const socialImageUrl = toCanonicalUrl(imagePath);
|
const socialImageUrl = toCanonicalUrl(DEFAULT_SOCIAL_IMAGE_PATH);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
meta: [
|
meta: [
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user