Remove backlinks + LLM-mentions access gates
This commit is contained in:
parent
3c7e704b4a
commit
c3caf2009f
@ -1,83 +0,0 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { ShieldAlert, Wrench } from "lucide-react";
|
||||
|
||||
export function AccessGateLoadingState() {
|
||||
return (
|
||||
<div className="card bg-base-100 border border-base-300">
|
||||
<div className="card-body gap-4">
|
||||
<div className="skeleton h-6 w-48" />
|
||||
<div className="skeleton h-4 w-full" />
|
||||
<div className="skeleton h-4 w-4/5" />
|
||||
<div className="skeleton h-10 w-48" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AccessGate({
|
||||
title,
|
||||
bodyText,
|
||||
helperText,
|
||||
buttonLabel,
|
||||
refetchingLabel = "Confirming...",
|
||||
externalUrl,
|
||||
externalLabel,
|
||||
errorMessage,
|
||||
isRefetching,
|
||||
onRetry,
|
||||
}: {
|
||||
title: string;
|
||||
bodyText: ReactNode;
|
||||
helperText?: ReactNode;
|
||||
buttonLabel: string;
|
||||
refetchingLabel?: string;
|
||||
externalUrl: string;
|
||||
externalLabel: string;
|
||||
errorMessage: string | null;
|
||||
isRefetching: boolean;
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
return (
|
||||
<section>
|
||||
<div className="rounded-2xl border border-base-300 bg-base-100 p-6 md:p-7 space-y-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="rounded-xl bg-warning/15 p-2.5 text-warning shrink-0">
|
||||
<Wrench className="size-5" />
|
||||
</div>
|
||||
<div className="max-w-3xl space-y-1.5">
|
||||
<h2 className="text-xl font-semibold">{title}</h2>
|
||||
<div className="text-sm text-base-content/68">{bodyText}</div>
|
||||
{helperText ? (
|
||||
<div className="text-xs text-base-content/50">{helperText}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={onRetry}
|
||||
disabled={isRefetching}
|
||||
>
|
||||
{isRefetching ? refetchingLabel : buttonLabel}
|
||||
</button>
|
||||
<a
|
||||
className="btn"
|
||||
href={externalUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{externalLabel}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{errorMessage ? (
|
||||
<div className="alert alert-warning">
|
||||
<ShieldAlert className="size-4 shrink-0" />
|
||||
<span>{errorMessage}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -1,46 +0,0 @@
|
||||
import { useCallback } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||
|
||||
type AccessGateStatus = {
|
||||
enabled: boolean;
|
||||
errorMessage: string | null;
|
||||
};
|
||||
|
||||
export type UseAccessGateResult = {
|
||||
enabled: boolean;
|
||||
isLoading: boolean;
|
||||
isRefetching: boolean;
|
||||
errorMessage: string | null;
|
||||
statusErrorMessage: string | null;
|
||||
onRetry: () => void;
|
||||
};
|
||||
|
||||
export function useAccessGate(config: {
|
||||
queryKey: readonly unknown[];
|
||||
queryFn: () => Promise<AccessGateStatus>;
|
||||
statusErrorFallback: string;
|
||||
}): UseAccessGateResult {
|
||||
const { data, error, isPending, isRefetching, refetch } = useQuery({
|
||||
queryKey: config.queryKey,
|
||||
queryFn: config.queryFn,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
const statusErrorMessage = error
|
||||
? getStandardErrorMessage(error, config.statusErrorFallback)
|
||||
: null;
|
||||
const onRetry = useCallback(() => {
|
||||
void refetch();
|
||||
}, [refetch]);
|
||||
|
||||
return {
|
||||
enabled: data?.enabled ?? false,
|
||||
isLoading: isPending,
|
||||
isRefetching,
|
||||
errorMessage: data?.errorMessage ?? null,
|
||||
statusErrorMessage,
|
||||
onRetry,
|
||||
};
|
||||
}
|
||||
@ -19,9 +19,6 @@ import { BrandLookupSearchCard } from "@/client/features/ai-search/components/Br
|
||||
import { BrandLookupHistorySection } from "@/client/features/ai-search/components/BrandLookupHistorySection";
|
||||
import { AiSearchLoadingState } from "@/client/features/ai-search/components/AiSearchLoadingState";
|
||||
import { AiSearchPaidPlanGate } from "@/client/features/ai-search/components/AiSearchPaidPlanGate";
|
||||
import { AiSearchSetupGate } from "@/client/features/ai-search/components/AiSearchSetupGate";
|
||||
import { AccessGateLoadingState } from "@/client/features/access-gate/AccessGate";
|
||||
import { useAiSearchAccess } from "@/client/features/ai-search/useAiSearchAccess";
|
||||
import { useBrandLookupSearchHistory } from "@/client/hooks/useBrandLookupSearchHistory";
|
||||
import {
|
||||
BRAND_LOOKUP_MAX_INPUT_LENGTH,
|
||||
@ -80,8 +77,6 @@ function BrandLookupPageInner({
|
||||
message: string;
|
||||
} | null>(null);
|
||||
|
||||
const access = useAiSearchAccess(projectId);
|
||||
|
||||
const trimmedInitialQuery = initialQuery.trim();
|
||||
const hasActiveQuery = trimmedInitialQuery.length > 0;
|
||||
// The URL `c` param is the source of truth for the active lookup; the local
|
||||
@ -101,7 +96,10 @@ function BrandLookupPageInner({
|
||||
languageCode: "en",
|
||||
},
|
||||
}),
|
||||
enabled: hasActiveQuery && !planGate.isFreePlan && access.enabled,
|
||||
// Client-side gate is a UX optimization only; the paywall is enforced
|
||||
// server-side (lookupBrand → assertPaidPlan) before any DataForSEO spend,
|
||||
// so a stale free-plan window here just yields a rejected request, not cost.
|
||||
enabled: hasActiveQuery && !planGate.isFreePlan,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
retry: false,
|
||||
});
|
||||
@ -200,8 +198,6 @@ function BrandLookupPageInner({
|
||||
: null;
|
||||
const resultData = hasActiveQuery ? lookupQuery.data : undefined;
|
||||
|
||||
if (planGate.isLoading) return null;
|
||||
|
||||
return (
|
||||
<div className="px-4 py-4 pb-24 overflow-auto md:px-6 md:py-6 md:pb-8">
|
||||
<div className="mx-auto max-w-7xl space-y-4">
|
||||
@ -212,15 +208,7 @@ function BrandLookupPageInner({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{access.isLoading ? (
|
||||
<AccessGateLoadingState />
|
||||
) : !access.enabled ? (
|
||||
<AiSearchSetupGate
|
||||
errorMessage={access.errorMessage ?? access.statusErrorMessage}
|
||||
isRefetching={access.isRefetching}
|
||||
onRetry={access.onRetry}
|
||||
/>
|
||||
) : planGate.isFreePlan ? (
|
||||
{planGate.isFreePlan ? (
|
||||
<AiSearchPaidPlanGate
|
||||
feature="Brand Lookup"
|
||||
description="See how ChatGPT and Google AI Overview cite any brand or domain — total mentions, sample prompts where it appears, and the pages cited alongside it."
|
||||
|
||||
@ -19,9 +19,6 @@ import { PromptExplorerResults } from "@/client/features/ai-search/components/Pr
|
||||
import { PromptExplorerLoadingState } from "@/client/features/ai-search/components/PromptExplorerLoadingState";
|
||||
import { PromptExplorerHistorySection } from "@/client/features/ai-search/components/PromptExplorerHistorySection";
|
||||
import { AiSearchPaidPlanGate } from "@/client/features/ai-search/components/AiSearchPaidPlanGate";
|
||||
import { AiSearchSetupGate } from "@/client/features/ai-search/components/AiSearchSetupGate";
|
||||
import { AccessGateLoadingState } from "@/client/features/access-gate/AccessGate";
|
||||
import { useAiSearchAccess } from "@/client/features/ai-search/useAiSearchAccess";
|
||||
import { usePromptExplorerSearchHistory } from "@/client/hooks/usePromptExplorerSearchHistory";
|
||||
import {
|
||||
PROMPT_EXPLORER_MAX_PROMPT_LENGTH,
|
||||
@ -77,7 +74,6 @@ function PromptExplorerPageInner({
|
||||
}: Props & { planGate: HostedPlanGateState }) {
|
||||
const [form, setForm] = useState<PromptExplorerFormValues>(urlState);
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
const access = useAiSearchAccess(projectId);
|
||||
|
||||
const {
|
||||
history,
|
||||
@ -110,11 +106,11 @@ function PromptExplorerPageInner({
|
||||
webSearchCountryCode: urlState.webSearchCountryCode,
|
||||
},
|
||||
}),
|
||||
// Client-side gate is a UX optimization only; the paywall is enforced
|
||||
// server-side (explorePrompt → assertPaidPlan) before any DataForSEO spend,
|
||||
// so a stale free-plan window here just yields a rejected request, not cost.
|
||||
enabled:
|
||||
hasActivePrompt &&
|
||||
urlState.models.length > 0 &&
|
||||
!planGate.isFreePlan &&
|
||||
access.enabled,
|
||||
hasActivePrompt && urlState.models.length > 0 && !planGate.isFreePlan,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
retry: false,
|
||||
});
|
||||
@ -199,8 +195,6 @@ function PromptExplorerPageInner({
|
||||
if (validationError) setValidationError(null);
|
||||
};
|
||||
|
||||
if (planGate.isLoading) return null;
|
||||
|
||||
return (
|
||||
<div className="px-4 py-4 pb-24 overflow-auto md:px-6 md:py-6 md:pb-8">
|
||||
<div className="mx-auto max-w-7xl space-y-4">
|
||||
@ -212,15 +206,7 @@ function PromptExplorerPageInner({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{access.isLoading ? (
|
||||
<AccessGateLoadingState />
|
||||
) : !access.enabled ? (
|
||||
<AiSearchSetupGate
|
||||
errorMessage={access.errorMessage ?? access.statusErrorMessage}
|
||||
isRefetching={access.isRefetching}
|
||||
onRetry={access.onRetry}
|
||||
/>
|
||||
) : planGate.isFreePlan ? (
|
||||
{planGate.isFreePlan ? (
|
||||
<AiSearchPaidPlanGate
|
||||
feature="Prompt Explorer"
|
||||
description="Ask one prompt across ChatGPT, Claude, Gemini, and Perplexity at the same time and compare their answers — including which sources each model cites."
|
||||
|
||||
@ -1,43 +0,0 @@
|
||||
import { AccessGate } from "@/client/features/access-gate/AccessGate";
|
||||
|
||||
export function AiSearchSetupGate({
|
||||
errorMessage,
|
||||
isRefetching,
|
||||
onRetry,
|
||||
}: {
|
||||
errorMessage: string | null;
|
||||
isRefetching: boolean;
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
return (
|
||||
<AccessGate
|
||||
title="Enable AI Optimization"
|
||||
bodyText="AI Optimization is not enabled for your DataForSEO account yet. You can enable it in DataForSEO, or use managed OpenSEO for long-term LLM Mentions access at $10/month."
|
||||
helperText={
|
||||
<>
|
||||
We are also planning an API so self-hosted apps can use OpenSEO's LLM
|
||||
Mentions data directly. Until then, <InlineManagedOpenSeoLink />.
|
||||
</>
|
||||
}
|
||||
buttonLabel="Confirm AI Optimization Access"
|
||||
externalUrl="https://app.dataforseo.com/api-access-subscriptions"
|
||||
externalLabel="Open DataForSEO API Access"
|
||||
errorMessage={errorMessage}
|
||||
isRefetching={isRefetching}
|
||||
onRetry={onRetry}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function InlineManagedOpenSeoLink() {
|
||||
return (
|
||||
<a
|
||||
className="underline underline-offset-2 hover:text-base-content/70"
|
||||
href="https://openseo.so/?utm_source=self_hosted_app&utm_medium=access_gate&utm_campaign=llm_mentions"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
use managed OpenSEO
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@ -1,10 +0,0 @@
|
||||
import { useAccessGate } from "@/client/features/access-gate/useAccessGate";
|
||||
import { getAiSearchAccessSetupStatus } from "@/serverFunctions/aiSearchAccess";
|
||||
|
||||
export function useAiSearchAccess(projectId: string) {
|
||||
return useAccessGate({
|
||||
queryKey: ["aiSearchAccessStatus", projectId],
|
||||
queryFn: () => getAiSearchAccessSetupStatus({ data: { projectId } }),
|
||||
statusErrorFallback: "Could not load AI Optimization setup status.",
|
||||
});
|
||||
}
|
||||
@ -101,10 +101,8 @@ export function BacklinksPage({
|
||||
});
|
||||
|
||||
const {
|
||||
accessGate,
|
||||
activeTabErrorMessage,
|
||||
activeTabQuery,
|
||||
backlinksDisabledByError,
|
||||
overviewErrorMessage,
|
||||
overviewQuery,
|
||||
referringDomainsQuery,
|
||||
@ -192,28 +190,22 @@ export function BacklinksPage({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!accessGate.isLoading &&
|
||||
accessGate.enabled &&
|
||||
!backlinksDisabledByError ? (
|
||||
<BacklinksSearchCard
|
||||
errorMessage={overviewErrorMessage}
|
||||
initialValues={searchCardInitialValues}
|
||||
canOpenSearch={(values) =>
|
||||
searchTabs.canOpenTab(toBacklinksTabInput(values))
|
||||
}
|
||||
tabLimit={searchTabs.limit}
|
||||
onSubmit={(values) => {
|
||||
searchTabs.openTab(toBacklinksTabInput(values));
|
||||
navigateToBacklinksSearch(navigate, values);
|
||||
addSearch({ target: values.target, scope: values.scope });
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<BacklinksSearchCard
|
||||
errorMessage={overviewErrorMessage}
|
||||
initialValues={searchCardInitialValues}
|
||||
canOpenSearch={(values) =>
|
||||
searchTabs.canOpenTab(toBacklinksTabInput(values))
|
||||
}
|
||||
tabLimit={searchTabs.limit}
|
||||
onSubmit={(values) => {
|
||||
searchTabs.openTab(toBacklinksTabInput(values));
|
||||
navigateToBacklinksSearch(navigate, values);
|
||||
addSearch({ target: values.target, scope: values.scope });
|
||||
}}
|
||||
/>
|
||||
|
||||
<BacklinksBody
|
||||
projectId={projectId}
|
||||
accessGate={accessGate}
|
||||
backlinksDisabledByError={backlinksDisabledByError}
|
||||
history={history}
|
||||
historyLoaded={historyLoaded}
|
||||
overviewData={overviewQuery.data}
|
||||
|
||||
@ -5,7 +5,6 @@ import { BacklinksResultsCard } from "./BacklinksPageSections";
|
||||
import {
|
||||
BacklinksErrorState,
|
||||
BacklinksLoadingState,
|
||||
BacklinksSetupGate,
|
||||
} from "./BacklinksPageStates";
|
||||
import { BacklinksHistorySection } from "./BacklinksHistorySection";
|
||||
import type { BacklinksSearchHistoryItem } from "@/client/hooks/useBacklinksSearchHistory";
|
||||
@ -17,8 +16,6 @@ import type {
|
||||
BacklinksTabRows,
|
||||
BacklinksTopPagesData,
|
||||
} from "./backlinksPageTypes";
|
||||
import type { UseAccessGateResult } from "@/client/features/access-gate/useAccessGate";
|
||||
import { AccessGateLoadingState } from "@/client/features/access-gate/AccessGate";
|
||||
import { buildSummaryStats } from "./backlinksPageUtils";
|
||||
import type { BacklinksDomainExpansion } from "./useBacklinksDomainExpansion";
|
||||
import type { BacklinksFiltersState } from "./useBacklinksFilters";
|
||||
@ -29,8 +26,6 @@ import {
|
||||
|
||||
type BacklinksBodyProps = {
|
||||
projectId: string;
|
||||
accessGate: UseAccessGateResult;
|
||||
backlinksDisabledByError: boolean;
|
||||
history: BacklinksSearchHistoryItem[];
|
||||
historyLoaded: boolean;
|
||||
overviewData: BacklinksOverviewData | undefined;
|
||||
@ -64,8 +59,6 @@ type BacklinksBodyProps = {
|
||||
|
||||
export function BacklinksBody({
|
||||
projectId,
|
||||
accessGate,
|
||||
backlinksDisabledByError,
|
||||
history,
|
||||
historyLoaded,
|
||||
overviewData,
|
||||
@ -119,29 +112,6 @@ export function BacklinksBody({
|
||||
/>
|
||||
) : null;
|
||||
|
||||
if (accessGate.isLoading) {
|
||||
return <AccessGateLoadingState />;
|
||||
}
|
||||
|
||||
if (accessGate.statusErrorMessage) {
|
||||
return (
|
||||
<BacklinksErrorState
|
||||
errorMessage={accessGate.statusErrorMessage}
|
||||
onRetry={accessGate.onRetry}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!accessGate.enabled || backlinksDisabledByError) {
|
||||
return (
|
||||
<BacklinksSetupGate
|
||||
errorMessage={accessGate.errorMessage}
|
||||
isRefetching={accessGate.isRefetching}
|
||||
onRetry={accessGate.onRetry}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!searchState.target) {
|
||||
return (
|
||||
<BacklinksHistorySection
|
||||
|
||||
@ -1,35 +1,4 @@
|
||||
import { ShieldAlert } from "lucide-react";
|
||||
import { AccessGate } from "@/client/features/access-gate/AccessGate";
|
||||
|
||||
export function BacklinksSetupGate({
|
||||
errorMessage,
|
||||
isRefetching,
|
||||
onRetry,
|
||||
}: {
|
||||
errorMessage: string | null;
|
||||
isRefetching: boolean;
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
return (
|
||||
<AccessGate
|
||||
title="Enable Backlinks"
|
||||
bodyText="Backlinks are not enabled for your DataForSEO account yet. You can enable them in DataForSEO, or use managed OpenSEO for long-term backlinks access at $10/month."
|
||||
helperText={
|
||||
<>
|
||||
We are also planning a Backlinks API so self-hosted apps can use
|
||||
OpenSEO's backlinks data directly. Until then,{" "}
|
||||
<InlineManagedOpenSeoLink />.
|
||||
</>
|
||||
}
|
||||
buttonLabel="Confirm DataForSEO Access"
|
||||
externalUrl="https://app.dataforseo.com/api-access-subscriptions"
|
||||
externalLabel="Open DataForSEO Backlinks"
|
||||
errorMessage={errorMessage}
|
||||
isRefetching={isRefetching}
|
||||
onRetry={onRetry}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function BacklinksLoadingState() {
|
||||
return (
|
||||
@ -90,16 +59,3 @@ export function BacklinksErrorState({
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function InlineManagedOpenSeoLink() {
|
||||
return (
|
||||
<a
|
||||
className="underline underline-offset-2 hover:text-base-content/70"
|
||||
href="https://openseo.so/?utm_source=self_hosted_app&utm_medium=access_gate&utm_campaign=backlinks"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
use managed OpenSEO
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,10 +1,9 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type {
|
||||
BacklinksPageProps,
|
||||
BacklinksSearchState,
|
||||
} from "./backlinksPageTypes";
|
||||
import { useAccessGate } from "@/client/features/access-gate/useAccessGate";
|
||||
import {
|
||||
getErrorCode,
|
||||
getStandardErrorMessage,
|
||||
@ -15,7 +14,6 @@ import {
|
||||
getBacklinksRows,
|
||||
getBacklinksTopPages,
|
||||
} from "@/serverFunctions/backlinks";
|
||||
import { getBacklinksAccessSetupStatus } from "@/serverFunctions/backlinksAccess";
|
||||
import {
|
||||
BACKLINKS_DEFAULT_SORT,
|
||||
backlinksRowsSortFieldSchema,
|
||||
@ -76,13 +74,6 @@ export function useBacklinksPageData({
|
||||
searchState,
|
||||
filters,
|
||||
}: UseBacklinksPageDataArgs) {
|
||||
const accessGate = useAccessGate({
|
||||
queryKey: ["backlinksAccessStatus", projectId],
|
||||
queryFn: () => getBacklinksAccessSetupStatus({ data: { projectId } }),
|
||||
statusErrorFallback: "Could not load Backlinks setup status.",
|
||||
});
|
||||
const backlinksEnabled = accessGate.enabled;
|
||||
const retryAccessGate = accessGate.onRetry;
|
||||
const searchCardInitialValues = useMemo(
|
||||
() => ({
|
||||
target: searchState.target,
|
||||
@ -93,7 +84,7 @@ export function useBacklinksPageData({
|
||||
|
||||
const { target, scope, tab, page, pageSize, sort, order, view } = searchState;
|
||||
const rowsMode = view === "all" ? "as_is" : "one_per_domain";
|
||||
const targetReady = backlinksEnabled && Boolean(target);
|
||||
const targetReady = Boolean(target);
|
||||
const baseQueryKeyParts = [projectId, scope, target] as const;
|
||||
const pageInputBase = { projectId, target, scope, page, pageSize };
|
||||
|
||||
@ -209,8 +200,6 @@ export function useBacklinksPageData({
|
||||
overviewQuery.error,
|
||||
"Could not load backlinks data.",
|
||||
);
|
||||
const backlinksDisabledByError =
|
||||
getErrorCode(overviewQuery.error) === "BACKLINKS_NOT_ENABLED";
|
||||
const activeTabQuery =
|
||||
tab === "backlinks"
|
||||
? rowsQuery
|
||||
@ -221,28 +210,10 @@ export function useBacklinksPageData({
|
||||
activeTabQuery.error,
|
||||
"Could not load this tab.",
|
||||
);
|
||||
const backlinksDisabledByTabError =
|
||||
getErrorCode(activeTabQuery.error) === "BACKLINKS_NOT_ENABLED";
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
(backlinksDisabledByError || backlinksDisabledByTabError) &&
|
||||
backlinksEnabled
|
||||
) {
|
||||
retryAccessGate();
|
||||
}
|
||||
}, [
|
||||
backlinksDisabledByError,
|
||||
backlinksDisabledByTabError,
|
||||
backlinksEnabled,
|
||||
retryAccessGate,
|
||||
]);
|
||||
|
||||
return {
|
||||
accessGate,
|
||||
activeTabErrorMessage,
|
||||
activeTabQuery,
|
||||
backlinksDisabledByError,
|
||||
overviewErrorMessage,
|
||||
overviewQuery,
|
||||
referringDomainsQuery,
|
||||
|
||||
@ -7,7 +7,6 @@ import {
|
||||
invalidateSamSessions,
|
||||
samSessionsQueryOptions,
|
||||
} from "@/client/features/sam/samQueries";
|
||||
import { AccessGateLoadingState } from "@/client/features/access-gate/AccessGate";
|
||||
import { useSamAccess } from "./useSamAccess";
|
||||
import { SamSetupGate } from "./SamSetupGate";
|
||||
import { SamConversation } from "./SamConversation";
|
||||
@ -57,22 +56,19 @@ export function SamChat({
|
||||
goToSession(firstSessionId);
|
||||
}, [activeSessionId, firstSessionId, goToSession]);
|
||||
|
||||
// Gate the whole page until OPENROUTER_API_KEY is configured — SAM cannot
|
||||
// answer a single turn without it, so surface setup instructions instead of
|
||||
// letting a chat fail mid-stream.
|
||||
if (access.isLoading || !access.enabled) {
|
||||
// SAM cannot answer a turn without OPENROUTER_API_KEY, so surface setup
|
||||
// instructions instead of letting a chat fail mid-stream. Only shown once the
|
||||
// check confirms the key is missing (self-hosted) — never as a blocking
|
||||
// skeleton while the check is in flight.
|
||||
if (access.showSetupGate) {
|
||||
return (
|
||||
<div className="overflow-auto px-4 py-4 md:px-6 md:py-6">
|
||||
<div className="mx-auto max-w-3xl">
|
||||
{access.isLoading ? (
|
||||
<AccessGateLoadingState />
|
||||
) : (
|
||||
<SamSetupGate
|
||||
errorMessage={access.errorMessage ?? access.statusErrorMessage}
|
||||
isRefetching={access.isRefetching}
|
||||
onRetry={access.onRetry}
|
||||
/>
|
||||
)}
|
||||
<SamSetupGate
|
||||
errorMessage={access.errorMessage}
|
||||
isRefetching={access.isRefetching}
|
||||
onRetry={access.onRetry}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { AccessGate } from "@/client/features/access-gate/AccessGate";
|
||||
import { ShieldAlert, Wrench } from "lucide-react";
|
||||
|
||||
export function SamSetupGate({
|
||||
errorMessage,
|
||||
@ -11,33 +11,58 @@ export function SamSetupGate({
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
return (
|
||||
<AccessGate
|
||||
title="Enable AI Features"
|
||||
bodyText={
|
||||
<>
|
||||
SAM, OpenSEO's in-app AI agent, needs an OpenRouter API key. Create a
|
||||
key on OpenRouter, set it as the <code>OPENROUTER_API_KEY</code>{" "}
|
||||
environment variable, restart OpenSEO, then confirm here.
|
||||
</>
|
||||
}
|
||||
helperText={
|
||||
<>
|
||||
Step-by-step instructions for every deployment are in the{" "}
|
||||
<Link
|
||||
className="underline underline-offset-2 hover:text-base-content/70"
|
||||
to="/help/openrouter-api-key"
|
||||
<section>
|
||||
<div className="rounded-2xl border border-base-300 bg-base-100 p-6 md:p-7 space-y-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="rounded-xl bg-warning/15 p-2.5 text-warning shrink-0">
|
||||
<Wrench className="size-5" />
|
||||
</div>
|
||||
<div className="max-w-3xl space-y-1.5">
|
||||
<h2 className="text-xl font-semibold">Enable AI Features</h2>
|
||||
<div className="text-sm text-base-content/68">
|
||||
SAM, OpenSEO's in-app AI agent, needs an OpenRouter API key.
|
||||
Create a key on OpenRouter, set it as the{" "}
|
||||
<code>OPENROUTER_API_KEY</code> environment variable, restart
|
||||
OpenSEO, then confirm here.
|
||||
</div>
|
||||
<div className="text-xs text-base-content/50">
|
||||
Step-by-step instructions for every deployment are in the{" "}
|
||||
<Link
|
||||
className="underline underline-offset-2 hover:text-base-content/70"
|
||||
to="/help/openrouter-api-key"
|
||||
>
|
||||
OpenRouter API key setup guide
|
||||
</Link>
|
||||
.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={onRetry}
|
||||
disabled={isRefetching}
|
||||
>
|
||||
OpenRouter API key setup guide
|
||||
</Link>
|
||||
.
|
||||
</>
|
||||
}
|
||||
buttonLabel="Confirm API Key"
|
||||
externalUrl="https://openrouter.ai/settings/keys"
|
||||
externalLabel="Open OpenRouter Keys"
|
||||
errorMessage={errorMessage}
|
||||
isRefetching={isRefetching}
|
||||
onRetry={onRetry}
|
||||
/>
|
||||
{isRefetching ? "Confirming..." : "Confirm API Key"}
|
||||
</button>
|
||||
<a
|
||||
className="btn"
|
||||
href="https://openrouter.ai/settings/keys"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Open OpenRouter Keys
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{errorMessage ? (
|
||||
<div className="alert alert-warning">
|
||||
<ShieldAlert className="size-4 shrink-0" />
|
||||
<span>{errorMessage}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,10 +1,60 @@
|
||||
import { useAccessGate } from "@/client/features/access-gate/useAccessGate";
|
||||
import { useCallback } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||
import { isHostedClientAuthMode } from "@/lib/auth-mode";
|
||||
import { getSamAccessSetupStatus } from "@/serverFunctions/samAccess";
|
||||
|
||||
export function useSamAccess(projectId: string) {
|
||||
return useAccessGate({
|
||||
type SamAccess = {
|
||||
// Only true once the setup check has resolved to "no access". It stays false
|
||||
// while the check is in flight, so the chat renders immediately instead of
|
||||
// blocking behind a skeleton — the gate only replaces it if we confirm the
|
||||
// OpenRouter key is missing.
|
||||
showSetupGate: boolean;
|
||||
errorMessage: string | null;
|
||||
isRefetching: boolean;
|
||||
onRetry: () => void;
|
||||
};
|
||||
|
||||
export function useSamAccess(projectId: string): SamAccess {
|
||||
// Hosted deployments always have OPENROUTER_API_KEY provisioned (the server
|
||||
// function short-circuits to enabled), so skip the round-trip entirely.
|
||||
const isHosted = isHostedClientAuthMode();
|
||||
|
||||
const { data, error, isRefetching, refetch } = useQuery({
|
||||
queryKey: ["samAccessStatus", projectId],
|
||||
queryFn: () => getSamAccessSetupStatus({ data: { projectId } }),
|
||||
statusErrorFallback: "Could not load AI agent setup status.",
|
||||
enabled: !isHosted,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
const onRetry = useCallback(() => {
|
||||
void refetch();
|
||||
}, [refetch]);
|
||||
|
||||
if (isHosted) {
|
||||
return {
|
||||
showSetupGate: false,
|
||||
errorMessage: null,
|
||||
isRefetching: false,
|
||||
onRetry,
|
||||
};
|
||||
}
|
||||
|
||||
// Optimistic: only gate once the check has actually resolved (success or
|
||||
// error) and it says the key isn't there.
|
||||
const resolved = data !== undefined || error != null;
|
||||
return {
|
||||
showSetupGate: resolved && !(data?.enabled ?? false),
|
||||
errorMessage:
|
||||
data?.errorMessage ??
|
||||
(error
|
||||
? getStandardErrorMessage(
|
||||
error,
|
||||
"Could not load AI agent setup status.",
|
||||
)
|
||||
: null),
|
||||
isRefetching,
|
||||
onRetry,
|
||||
};
|
||||
}
|
||||
|
||||
@ -18,12 +18,8 @@ const STANDARD_MESSAGES: Record<ErrorCode, string> = {
|
||||
"You already have an audit running. Wait for it to finish or delete it before starting another.",
|
||||
VALIDATION_ERROR: "Please check your input and try again.",
|
||||
CRAWL_TARGET_BLOCKED: "This crawl target is blocked by security policy.",
|
||||
BACKLINKS_NOT_ENABLED:
|
||||
"Backlinks is not enabled for the connected DataForSEO account yet.",
|
||||
BACKLINKS_BILLING_ISSUE:
|
||||
"The connected DataForSEO account has a billing or balance issue.",
|
||||
AI_SEARCH_NOT_ENABLED:
|
||||
"AI Optimization is not enabled for the connected DataForSEO account yet.",
|
||||
AI_SEARCH_BILLING_ISSUE:
|
||||
"The connected DataForSEO account has a billing or balance issue.",
|
||||
DATAFORSEO_AUTH_FAILED:
|
||||
|
||||
@ -305,7 +305,6 @@ function rethrowIfBlockingAiSearchError(
|
||||
result.status === "rejected" &&
|
||||
result.reason instanceof AppError &&
|
||||
(result.reason.code === "INSUFFICIENT_CREDITS" ||
|
||||
result.reason.code === "AI_SEARCH_NOT_ENABLED" ||
|
||||
result.reason.code === "AI_SEARCH_BILLING_ISSUE")
|
||||
) {
|
||||
throw result.reason;
|
||||
|
||||
@ -286,7 +286,6 @@ function mapErrorToResult(
|
||||
if (
|
||||
reason instanceof AppError &&
|
||||
(reason.code === "INSUFFICIENT_CREDITS" ||
|
||||
reason.code === "AI_SEARCH_NOT_ENABLED" ||
|
||||
reason.code === "AI_SEARCH_BILLING_ISSUE")
|
||||
) {
|
||||
// These account-level failures apply to every model, so surface one clear
|
||||
|
||||
@ -25,7 +25,7 @@ import {
|
||||
type LlmResponseResult,
|
||||
type LlmTopPagesItem,
|
||||
} from "@/server/lib/dataforseoLlmSchemas";
|
||||
import { createDataforseoAccessClassifier } from "@/server/lib/dataforseoAccessClassification";
|
||||
import { createDataforseoBillingClassifier } from "@/server/lib/dataforseoBillingClassification";
|
||||
import { AppError } from "@/server/lib/errors";
|
||||
import { aiOptimizationApi } from "@/server/lib/dataforseo/core";
|
||||
import {
|
||||
@ -42,11 +42,8 @@ export const CHATGPT_LANGUAGE_CODE = "en";
|
||||
|
||||
export type LlmPlatform = "chat_gpt" | "google";
|
||||
|
||||
const classifyAiSearchError = createDataforseoAccessClassifier({
|
||||
const classifyAiSearchError = createDataforseoBillingClassifier({
|
||||
pathPrefix: "/ai_optimization/",
|
||||
notEnabledCode: "AI_SEARCH_NOT_ENABLED",
|
||||
notEnabledMessage:
|
||||
"AI Optimization is not enabled for the connected DataForSEO account",
|
||||
billingIssueCode: "AI_SEARCH_BILLING_ISSUE",
|
||||
billingIssueMessage:
|
||||
"The connected DataForSEO account has a billing or balance issue",
|
||||
|
||||
@ -9,10 +9,10 @@ const { classifyBacklinksError } = vi.hoisted(() => ({
|
||||
classifyBacklinksError: vi.fn(),
|
||||
}));
|
||||
|
||||
// The classifier is built inside backlinks.ts via createDataforseoAccessClassifier;
|
||||
// The classifier is built inside backlinks.ts via createDataforseoBillingClassifier;
|
||||
// returning our hoisted mock lets the test drive classification.
|
||||
vi.mock("@/server/lib/dataforseoAccessClassification", () => ({
|
||||
createDataforseoAccessClassifier: () => classifyBacklinksError,
|
||||
vi.mock("@/server/lib/dataforseoBillingClassification", () => ({
|
||||
createDataforseoBillingClassifier: () => classifyBacklinksError,
|
||||
}));
|
||||
|
||||
import {
|
||||
@ -107,18 +107,18 @@ describe("fetchBacklinksSummary", () => {
|
||||
vi.mocked(fetch).mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
status_code: 40204,
|
||||
status_message: "Backlinks subscription required",
|
||||
status_code: 40200,
|
||||
status_message: "Account balance is too low",
|
||||
tasks: [],
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
),
|
||||
);
|
||||
classifyBacklinksError.mockImplementation((status: number | undefined) => {
|
||||
if (status === 40204) {
|
||||
if (status === 40200) {
|
||||
return new AppError(
|
||||
"BACKLINKS_NOT_ENABLED",
|
||||
"Backlinks is not enabled",
|
||||
"BACKLINKS_BILLING_ISSUE",
|
||||
"The connected DataForSEO account has a billing or balance issue",
|
||||
);
|
||||
}
|
||||
return null;
|
||||
@ -126,11 +126,11 @@ describe("fetchBacklinksSummary", () => {
|
||||
|
||||
await expect(
|
||||
fetchBacklinksSummary({ target: "example.com" }),
|
||||
).rejects.toMatchObject({ code: "BACKLINKS_NOT_ENABLED" });
|
||||
).rejects.toMatchObject({ code: "BACKLINKS_BILLING_ISSUE" });
|
||||
|
||||
expect(classifyBacklinksError).toHaveBeenCalledWith(
|
||||
40204,
|
||||
expect.stringContaining("Backlinks subscription required"),
|
||||
40200,
|
||||
expect.stringContaining("Account balance is too low"),
|
||||
"/v3/backlinks/summary/live",
|
||||
);
|
||||
});
|
||||
|
||||
@ -10,7 +10,7 @@ import {
|
||||
normalizeBacklinksSpamFilterOptions,
|
||||
type BacklinksSpamFilterOptions,
|
||||
} from "@/types/schemas/backlinks";
|
||||
import { createDataforseoAccessClassifier } from "@/server/lib/dataforseoAccessClassification";
|
||||
import { createDataforseoBillingClassifier } from "@/server/lib/dataforseoBillingClassification";
|
||||
import { AppError } from "@/server/lib/errors";
|
||||
import { backlinksApi } from "@/server/lib/dataforseo/core";
|
||||
import {
|
||||
@ -41,11 +41,8 @@ type BacklinksTimeseriesRequest = {
|
||||
dateTo: string;
|
||||
};
|
||||
|
||||
const classifyBacklinksError = createDataforseoAccessClassifier({
|
||||
const classifyBacklinksError = createDataforseoBillingClassifier({
|
||||
pathPrefix: "/backlinks/",
|
||||
notEnabledCode: "BACKLINKS_NOT_ENABLED",
|
||||
notEnabledMessage:
|
||||
"Backlinks is not enabled for the connected DataForSEO account",
|
||||
billingIssueCode: "BACKLINKS_BILLING_ISSUE",
|
||||
billingIssueMessage:
|
||||
"The connected DataForSEO account has a billing or balance issue",
|
||||
|
||||
@ -22,9 +22,9 @@ const DATAFORSEO_RETRY_BACKOFF_MS = 250;
|
||||
|
||||
/**
|
||||
* Translates a DataForSEO HTTP/task failure into a product-specific AppError
|
||||
* (e.g. "backlinks not enabled", "billing issue"). Returns null when the
|
||||
* failure isn't one this classifier recognises, so the caller can fall back to
|
||||
* a generic error. See {@link createDataforseoAccessClassifier}.
|
||||
* (e.g. "billing issue"). Returns null when the failure isn't one this
|
||||
* classifier recognises, so the caller can fall back to a generic error. See
|
||||
* {@link createDataforseoBillingClassifier}.
|
||||
*/
|
||||
export type DataforseoErrorClassifier = (
|
||||
status: number | undefined,
|
||||
|
||||
@ -83,10 +83,12 @@ describe("assertOk", () => {
|
||||
});
|
||||
|
||||
it("uses the classifier for non-charged (no-cost) failures", () => {
|
||||
const classify = vi.fn(() => new AppError("BACKLINKS_NOT_ENABLED", "nope"));
|
||||
const classify = vi.fn(
|
||||
() => new AppError("BACKLINKS_BILLING_ISSUE", "nope"),
|
||||
);
|
||||
const task = {
|
||||
status_code: 40204,
|
||||
status_message: "subscription required",
|
||||
status_code: 40200,
|
||||
status_message: "balance is too low",
|
||||
};
|
||||
expect(() =>
|
||||
assertOk(
|
||||
@ -95,16 +97,16 @@ describe("assertOk", () => {
|
||||
),
|
||||
).toThrow("nope");
|
||||
expect(classify).toHaveBeenCalledWith(
|
||||
40204,
|
||||
"subscription required",
|
||||
40200,
|
||||
"balance is too low",
|
||||
"/v3/backlinks/summary/live",
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[40204, "BACKLINKS_NOT_ENABLED"],
|
||||
[403, "BACKLINKS_NOT_ENABLED"],
|
||||
[40200, "BACKLINKS_BILLING_ISSUE"],
|
||||
[40210, "BACKLINKS_BILLING_ISSUE"],
|
||||
[402, "BACKLINKS_BILLING_ISSUE"],
|
||||
] as const)(
|
||||
"uses the classifier for account failure %s before charging billed task metadata",
|
||||
(status, code) => {
|
||||
@ -113,7 +115,7 @@ describe("assertOk", () => {
|
||||
);
|
||||
const task = {
|
||||
status_code: status,
|
||||
status_message: "Backlinks subscription required",
|
||||
status_message: "Account balance is too low",
|
||||
path: ["v3", "backlinks", "summary", "live"],
|
||||
cost: 0.05,
|
||||
result_count: 0,
|
||||
@ -128,7 +130,7 @@ describe("assertOk", () => {
|
||||
}
|
||||
expect(classify).toHaveBeenCalledWith(
|
||||
status,
|
||||
"Backlinks subscription required",
|
||||
"Account balance is too low",
|
||||
"/v3/backlinks/summary/live",
|
||||
);
|
||||
},
|
||||
|
||||
@ -1,91 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import { AppError } from "@/server/lib/errors";
|
||||
import { getRequiredEnvValue } from "@/server/lib/runtime-env";
|
||||
|
||||
const API_BASE = "https://api.dataforseo.com";
|
||||
|
||||
const userDataResponseSchema = z
|
||||
.object({
|
||||
status_code: z.number().optional(),
|
||||
tasks: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
status_code: z.number().optional(),
|
||||
result: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
backlinks_subscription_expiry_date: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional(),
|
||||
llm_mentions_subscription_expiry_date: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
)
|
||||
.nullable()
|
||||
.optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
type DataforseoAccountState = {
|
||||
backlinksSubscriptionExpiryDate: string | null;
|
||||
llmMentionsSubscriptionExpiryDate: string | null;
|
||||
};
|
||||
|
||||
export function hasActiveDataforseoSubscription(
|
||||
expiryDate: string | null,
|
||||
): boolean {
|
||||
if (!expiryDate) return false;
|
||||
|
||||
const expiryTime = Date.parse(expiryDate);
|
||||
return Number.isFinite(expiryTime) && expiryTime > Date.now();
|
||||
}
|
||||
|
||||
export async function fetchDataforseoAccountState(): Promise<DataforseoAccountState | null> {
|
||||
const apiKey = await getRequiredEnvValue("DATAFORSEO_API_KEY");
|
||||
const response = await fetch(`${API_BASE}/v3/appendix/user_data`, {
|
||||
method: "GET",
|
||||
headers: { Authorization: `Basic ${apiKey}` },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// 401/403 here means the API key itself is invalid or missing — surface a
|
||||
// clear, actionable message instead of a generic "unexpected error".
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new AppError(
|
||||
"DATAFORSEO_AUTH_FAILED",
|
||||
`DataForSEO HTTP ${response.status} on /v3/appendix/user_data`,
|
||||
);
|
||||
}
|
||||
throw new AppError(
|
||||
"INTERNAL_ERROR",
|
||||
`DataForSEO HTTP ${response.status} on /v3/appendix/user_data`,
|
||||
);
|
||||
}
|
||||
|
||||
const raw = await response.json();
|
||||
const parsed = userDataResponseSchema.safeParse(raw);
|
||||
if (!parsed.success || parsed.data.status_code !== 20000) return null;
|
||||
|
||||
const task = parsed.data.tasks?.[0];
|
||||
if (!task || task.status_code !== 20000) return null;
|
||||
|
||||
const result = task.result?.[0];
|
||||
if (!result) return null;
|
||||
|
||||
return {
|
||||
backlinksSubscriptionExpiryDate:
|
||||
result.backlinks_subscription_expiry_date ?? null,
|
||||
llmMentionsSubscriptionExpiryDate:
|
||||
result.llm_mentions_subscription_expiry_date ?? null,
|
||||
};
|
||||
}
|
||||
@ -1,29 +1,19 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createDataforseoAccessClassifier } from "@/server/lib/dataforseoAccessClassification";
|
||||
import { createDataforseoBillingClassifier } from "@/server/lib/dataforseoBillingClassification";
|
||||
|
||||
const classify = createDataforseoAccessClassifier({
|
||||
const classify = createDataforseoBillingClassifier({
|
||||
pathPrefix: "/backlinks/",
|
||||
notEnabledCode: "BACKLINKS_NOT_ENABLED",
|
||||
notEnabledMessage: "not enabled",
|
||||
billingIssueCode: "BACKLINKS_BILLING_ISSUE",
|
||||
billingIssueMessage: "billing issue",
|
||||
});
|
||||
|
||||
describe("createDataforseoAccessClassifier", () => {
|
||||
describe("createDataforseoBillingClassifier", () => {
|
||||
it("returns null when the path is outside the configured prefix", () => {
|
||||
expect(classify(402, "payment required", "/v3/serp/google/live")).toBe(
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([40204, 403])(
|
||||
"translates status %s into the configured error code when inside the path prefix",
|
||||
(status) => {
|
||||
const err = classify(status, "", "/v3/backlinks/summary/live");
|
||||
expect(err?.code).toBe("BACKLINKS_NOT_ENABLED");
|
||||
},
|
||||
);
|
||||
|
||||
it.each([40200, 40210, 402])(
|
||||
"translates billing status %s into the configured billing error code",
|
||||
(status) => {
|
||||
@ -32,16 +22,6 @@ describe("createDataforseoAccessClassifier", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
"subscription required",
|
||||
"plans and subscriptions",
|
||||
"access denied",
|
||||
"forbidden",
|
||||
])("translates signal %s into the configured error code", (message) => {
|
||||
const err = classify(undefined, message, "/v3/backlinks/summary/live");
|
||||
expect(err?.code).toBe("BACKLINKS_NOT_ENABLED");
|
||||
});
|
||||
|
||||
it.each([
|
||||
"insufficient funds",
|
||||
"payment required",
|
||||
@ -56,16 +36,25 @@ describe("createDataforseoAccessClassifier", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("no longer classifies feature-access signals now that the add-ons are bundled", () => {
|
||||
expect(
|
||||
classify(40204, "subscription required", "/v3/backlinks/summary/live"),
|
||||
).toBe(null);
|
||||
expect(classify(403, "access denied", "/v3/backlinks/summary/live")).toBe(
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null when neither status nor text matches", () => {
|
||||
expect(classify(500, "boom", "/v3/backlinks/summary/live")).toBe(null);
|
||||
});
|
||||
|
||||
it("matches signals case-insensitively", () => {
|
||||
it("matches billing signals case-insensitively", () => {
|
||||
const err = classify(
|
||||
undefined,
|
||||
"SUBSCRIPTION required",
|
||||
"INSUFFICIENT funds",
|
||||
"/v3/backlinks/summary/live",
|
||||
);
|
||||
expect(err?.code).toBe("BACKLINKS_NOT_ENABLED");
|
||||
expect(err?.code).toBe("BACKLINKS_BILLING_ISSUE");
|
||||
});
|
||||
});
|
||||
@ -1,20 +1,6 @@
|
||||
import { AppError } from "@/server/lib/errors";
|
||||
import type { ErrorCode } from "@/shared/error-codes";
|
||||
|
||||
const ACCESS_SIGNALS = [
|
||||
"not available",
|
||||
"not enabled",
|
||||
"not allowed",
|
||||
"access denied",
|
||||
"forbidden",
|
||||
"insufficient",
|
||||
"subscription",
|
||||
"upgrade",
|
||||
"plan",
|
||||
"activate your subscription",
|
||||
"plans and subscriptions",
|
||||
];
|
||||
|
||||
const BILLING_SIGNALS = [
|
||||
"insufficient funds",
|
||||
"balance is too low",
|
||||
@ -25,22 +11,25 @@ const BILLING_SIGNALS = [
|
||||
"recharged",
|
||||
];
|
||||
|
||||
const ACCESS_STATUS_CODES = new Set([40204, 403]);
|
||||
const BILLING_STATUS_CODES = new Set([40200, 40210, 402]);
|
||||
|
||||
type DataforseoAccessClassifier = (
|
||||
type DataforseoBillingClassifier = (
|
||||
status: number | undefined,
|
||||
details: string,
|
||||
path: string,
|
||||
) => AppError | null;
|
||||
|
||||
export function createDataforseoAccessClassifier(config: {
|
||||
/**
|
||||
* Maps DataForSEO balance/payment failures for a given API section to a typed
|
||||
* billing error. Feature-enablement is no longer classified: Backlinks and AI
|
||||
* Optimization are included in every DataForSEO account, so the only remaining
|
||||
* account-level failure is a depleted balance.
|
||||
*/
|
||||
export function createDataforseoBillingClassifier(config: {
|
||||
pathPrefix: string;
|
||||
notEnabledCode: ErrorCode;
|
||||
notEnabledMessage: string;
|
||||
billingIssueCode: ErrorCode;
|
||||
billingIssueMessage: string;
|
||||
}): DataforseoAccessClassifier {
|
||||
}): DataforseoBillingClassifier {
|
||||
return (status, details, path) => {
|
||||
if (!path.includes(config.pathPrefix)) return null;
|
||||
|
||||
@ -54,13 +43,6 @@ export function createDataforseoAccessClassifier(config: {
|
||||
return new AppError(config.billingIssueCode, config.billingIssueMessage);
|
||||
}
|
||||
|
||||
const matchesAccessStatus =
|
||||
status != null && ACCESS_STATUS_CODES.has(status);
|
||||
const matchesAccessText = ACCESS_SIGNALS.some((signal) =>
|
||||
text.includes(signal),
|
||||
);
|
||||
if (!matchesAccessStatus && !matchesAccessText) return null;
|
||||
|
||||
return new AppError(config.notEnabledCode, config.notEnabledMessage);
|
||||
return null;
|
||||
};
|
||||
}
|
||||
@ -1,34 +0,0 @@
|
||||
import { createServerFn } from "@tanstack/react-start";
|
||||
import {
|
||||
fetchDataforseoAccountState,
|
||||
hasActiveDataforseoSubscription,
|
||||
} from "@/server/lib/dataforseoAccountState";
|
||||
import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
|
||||
import { requireProjectContext } from "@/serverFunctions/middleware";
|
||||
import { aiSearchProjectSchema } from "@/types/schemas/ai-search";
|
||||
|
||||
const AI_SEARCH_NOT_ENABLED_MESSAGE =
|
||||
"AI Optimization is not enabled for the connected DataForSEO account yet. Turn it on in DataForSEO, then confirm here.";
|
||||
|
||||
type AiSearchAccessStatus = {
|
||||
enabled: boolean;
|
||||
errorMessage: string | null;
|
||||
};
|
||||
|
||||
export const getAiSearchAccessSetupStatus = createServerFn({ method: "GET" })
|
||||
.middleware(requireProjectContext)
|
||||
.validator(aiSearchProjectSchema)
|
||||
.handler(async (): Promise<AiSearchAccessStatus> => {
|
||||
if (await isHostedServerAuthMode()) {
|
||||
return { enabled: true, errorMessage: null };
|
||||
}
|
||||
|
||||
const state = await fetchDataforseoAccountState();
|
||||
const enabled = hasActiveDataforseoSubscription(
|
||||
state?.llmMentionsSubscriptionExpiryDate ?? null,
|
||||
);
|
||||
return {
|
||||
enabled,
|
||||
errorMessage: enabled ? null : AI_SEARCH_NOT_ENABLED_MESSAGE,
|
||||
};
|
||||
});
|
||||
@ -1,34 +0,0 @@
|
||||
import { createServerFn } from "@tanstack/react-start";
|
||||
import {
|
||||
fetchDataforseoAccountState,
|
||||
hasActiveDataforseoSubscription,
|
||||
} from "@/server/lib/dataforseoAccountState";
|
||||
import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
|
||||
import { requireProjectContext } from "@/serverFunctions/middleware";
|
||||
import { backlinksProjectSchema } from "@/types/schemas/backlinks";
|
||||
|
||||
const BACKLINKS_NOT_ENABLED_MESSAGE =
|
||||
"Backlinks is not enabled for the connected DataForSEO account yet. Turn it on in DataForSEO, then confirm here.";
|
||||
|
||||
type BacklinksAccessStatus = {
|
||||
enabled: boolean;
|
||||
errorMessage: string | null;
|
||||
};
|
||||
|
||||
export const getBacklinksAccessSetupStatus = createServerFn({ method: "GET" })
|
||||
.middleware(requireProjectContext)
|
||||
.validator(backlinksProjectSchema)
|
||||
.handler(async (): Promise<BacklinksAccessStatus> => {
|
||||
if (await isHostedServerAuthMode()) {
|
||||
return { enabled: true, errorMessage: null };
|
||||
}
|
||||
|
||||
const state = await fetchDataforseoAccountState();
|
||||
const enabled = hasActiveDataforseoSubscription(
|
||||
state?.backlinksSubscriptionExpiryDate ?? null,
|
||||
);
|
||||
return {
|
||||
enabled,
|
||||
errorMessage: enabled ? null : BACKLINKS_NOT_ENABLED_MESSAGE,
|
||||
};
|
||||
});
|
||||
@ -17,9 +17,9 @@ describe("shouldCaptureAppErrorCode", () => {
|
||||
it("captures unexpected errors and unknown failures", () => {
|
||||
expect(shouldCaptureAppErrorCode("INTERNAL_ERROR")).toBe(true);
|
||||
expect(shouldCaptureAppErrorCode(undefined)).toBe(true);
|
||||
// On cloud the shared DataForSEO account has these add-ons, so these firing
|
||||
// signals a real platform problem — keep them reportable, don't suppress.
|
||||
expect(shouldCaptureAppErrorCode("BACKLINKS_NOT_ENABLED")).toBe(true);
|
||||
expect(shouldCaptureAppErrorCode("AI_SEARCH_NOT_ENABLED")).toBe(true);
|
||||
// A depleted DataForSEO balance is a real platform problem on cloud — keep
|
||||
// the billing codes reportable, don't suppress them.
|
||||
expect(shouldCaptureAppErrorCode("BACKLINKS_BILLING_ISSUE")).toBe(true);
|
||||
expect(shouldCaptureAppErrorCode("AI_SEARCH_BILLING_ISSUE")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@ -12,9 +12,7 @@ const ERROR_CODES = [
|
||||
"AUDIT_ALREADY_RUNNING",
|
||||
"VALIDATION_ERROR",
|
||||
"CRAWL_TARGET_BLOCKED",
|
||||
"BACKLINKS_NOT_ENABLED",
|
||||
"BACKLINKS_BILLING_ISSUE",
|
||||
"AI_SEARCH_NOT_ENABLED",
|
||||
"AI_SEARCH_BILLING_ISSUE",
|
||||
"DATAFORSEO_AUTH_FAILED",
|
||||
"RATE_LIMITED",
|
||||
|
||||
@ -7,14 +7,6 @@ import { z } from "zod";
|
||||
* by server functions, services, R2 cache validation, and the client UI.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AI Search access setup (self-hosted mode only)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const aiSearchProjectSchema = z.object({
|
||||
projectId: z.string().min(1),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Brand Lookup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@ -36,10 +36,6 @@ export const backlinksLookupSchema = z.object({
|
||||
scope: backlinksTargetScopeSchema.optional(),
|
||||
});
|
||||
|
||||
export const backlinksProjectSchema = z.object({
|
||||
projectId: z.string().min(1),
|
||||
});
|
||||
|
||||
export const backlinksOverviewInputSchema = backlinksLookupSchema.extend({
|
||||
projectId: z.string().min(1),
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user